Compare commits
12 Commits
ff38b74548
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7ce0f6115d | |||
|
|
6492fdff82 | ||
|
|
44d7841852 | ||
|
|
38c600aca3 | ||
|
|
eeda94926f | ||
|
|
57be9bf1f1 | ||
|
|
8431784708 | ||
|
|
c771a86675 | ||
|
|
65ea0920db | ||
|
|
1f3fa7a718 | ||
|
|
a9c9b1fd48 | ||
|
|
4c213c96ee |
43
CHANGELOG.md
@@ -1,10 +1,48 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to Seismo Fleet Manager will be documented in this file.
|
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.5.1] - 2026-01-27
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
|
||||||
|
- New "Today's Actions" panel showing upcoming and past scheduled events
|
||||||
|
- Schedule list partial for project-specific schedule views
|
||||||
|
- API endpoint for fetching today's schedule data
|
||||||
|
- **New Branding Assets**: Complete logo rework for Terra-View
|
||||||
|
- New Terra-View logos for light and dark themes
|
||||||
|
- Retina-ready (@2x) logo variants
|
||||||
|
- Updated favicons (16px and 32px)
|
||||||
|
- Refreshed PWA icons (72px through 512px)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Layout**: Reorganized to include schedule information panel
|
||||||
|
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-01-23
|
||||||
|
|
||||||
|
_Note: This version was not formally released; changes were included in v0.5.1._
|
||||||
|
|
||||||
|
## [0.4.4] - 2026-01-23
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
|
||||||
|
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
|
||||||
|
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
|
||||||
|
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
|
||||||
|
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
|
||||||
|
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Scheduler actions**: Strict command definitions so actions run reliably.
|
||||||
|
- **Project view title**: Resolved JSON string rendering in project headers.
|
||||||
|
|
||||||
## [0.4.3] - 2026-01-14
|
## [0.4.3] - 2026-01-14
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -361,6 +399,9 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[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
|
||||||
|
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
||||||
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
|
|||||||
10
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.4.3
|
# Terra-View v0.5.1
|
||||||
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
|
||||||
@@ -571,9 +571,13 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.4.3** — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
**Current: 0.5.1** — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
||||||
|
|
||||||
Previous: 0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
|
Previous: 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
||||||
|
|
||||||
|
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
||||||
|
|
||||||
|
0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
|
||||||
|
|
||||||
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
||||||
|
|
||||||
|
|||||||
BIN
assets/terra-view-icon_large.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -29,7 +29,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.4.3"
|
VERSION = "0.5.1"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -58,8 +58,8 @@ app.add_middleware(
|
|||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||||
|
|
||||||
# Setup Jinja2 templates
|
# Use shared templates configuration with timezone filters
|
||||||
templates = Jinja2Templates(directory="templates")
|
from backend.templates_config import templates
|
||||||
|
|
||||||
# Add custom context processor to inject environment variable into all templates
|
# Add custom context processor to inject environment variable into all templates
|
||||||
@app.middleware("http")
|
@app.middleware("http")
|
||||||
@@ -92,6 +92,7 @@ app.include_router(slmm.router)
|
|||||||
app.include_router(slm_ui.router)
|
app.include_router(slm_ui.router)
|
||||||
app.include_router(slm_dashboard.router)
|
app.include_router(slm_dashboard.router)
|
||||||
app.include_router(seismo_dashboard.router)
|
app.include_router(seismo_dashboard.router)
|
||||||
|
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)
|
||||||
@@ -101,8 +102,21 @@ app.include_router(projects.router)
|
|||||||
app.include_router(project_locations.router)
|
app.include_router(project_locations.router)
|
||||||
app.include_router(scheduler.router)
|
app.include_router(scheduler.router)
|
||||||
|
|
||||||
# Start scheduler service on application startup
|
# Report templates router
|
||||||
|
from backend.routers import report_templates
|
||||||
|
app.include_router(report_templates.router)
|
||||||
|
|
||||||
|
# Alerts router
|
||||||
|
from backend.routers import alerts
|
||||||
|
app.include_router(alerts.router)
|
||||||
|
|
||||||
|
# Recurring schedules router
|
||||||
|
from backend.routers import recurring_schedules
|
||||||
|
app.include_router(recurring_schedules.router)
|
||||||
|
|
||||||
|
# Start scheduler service and device status monitor on application startup
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
|
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||||
|
|
||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def startup_event():
|
async def startup_event():
|
||||||
@@ -111,9 +125,17 @@ async def startup_event():
|
|||||||
await start_scheduler()
|
await start_scheduler()
|
||||||
logger.info("Scheduler service started")
|
logger.info("Scheduler service started")
|
||||||
|
|
||||||
|
logger.info("Starting device status monitor...")
|
||||||
|
await start_device_status_monitor()
|
||||||
|
logger.info("Device status monitor started")
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
@app.on_event("shutdown")
|
||||||
def shutdown_event():
|
def shutdown_event():
|
||||||
"""Clean up services on app shutdown"""
|
"""Clean up services on app shutdown"""
|
||||||
|
logger.info("Stopping device status monitor...")
|
||||||
|
stop_device_status_monitor()
|
||||||
|
logger.info("Device status monitor stopped")
|
||||||
|
|
||||||
logger.info("Stopping scheduler service...")
|
logger.info("Stopping scheduler service...")
|
||||||
stop_scheduler()
|
stop_scheduler()
|
||||||
logger.info("Scheduler service stopped")
|
logger.info("Scheduler service stopped")
|
||||||
@@ -195,6 +217,12 @@ async def seismographs_page(request: Request):
|
|||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/modems", response_class=HTMLResponse)
|
||||||
|
async def modems_page(request: Request):
|
||||||
|
"""Field modems management dashboard"""
|
||||||
|
return templates.TemplateResponse("modems.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects", response_class=HTMLResponse)
|
@app.get("/projects", response_class=HTMLResponse)
|
||||||
async def projects_page(request: Request):
|
async def projects_page(request: Request):
|
||||||
"""Projects management and overview"""
|
"""Projects management and overview"""
|
||||||
|
|||||||
67
backend/migrate_add_auto_increment_index.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""
|
||||||
|
Migration: Add auto_increment_index column to recurring_schedules table
|
||||||
|
|
||||||
|
This migration adds the auto_increment_index column that controls whether
|
||||||
|
the scheduler should automatically find an unused store index before starting
|
||||||
|
a new measurement.
|
||||||
|
|
||||||
|
Run this script once to update existing databases:
|
||||||
|
python -m backend.migrate_add_auto_increment_index
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
DB_PATH = "data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate():
|
||||||
|
"""Add auto_increment_index column to recurring_schedules table."""
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Check if recurring_schedules table exists
|
||||||
|
cursor.execute("""
|
||||||
|
SELECT name FROM sqlite_master
|
||||||
|
WHERE type='table' AND name='recurring_schedules'
|
||||||
|
""")
|
||||||
|
if not cursor.fetchone():
|
||||||
|
print("recurring_schedules table does not exist yet. Will be created on app startup.")
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check if auto_increment_index column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(recurring_schedules)")
|
||||||
|
columns = [row[1] for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
if "auto_increment_index" in columns:
|
||||||
|
print("auto_increment_index column already exists in recurring_schedules table.")
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Add the column
|
||||||
|
print("Adding auto_increment_index column to recurring_schedules table...")
|
||||||
|
cursor.execute("""
|
||||||
|
ALTER TABLE recurring_schedules
|
||||||
|
ADD COLUMN auto_increment_index BOOLEAN DEFAULT 1
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
print("Successfully added auto_increment_index column.")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Migration failed: {e}")
|
||||||
|
conn.close()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
success = migrate()
|
||||||
|
exit(0 if success else 1)
|
||||||
84
backend/migrate_add_deployment_type.py
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add deployment_type and deployed_with_unit_id fields to roster table.
|
||||||
|
|
||||||
|
deployment_type: tracks what type of device a modem is deployed with:
|
||||||
|
- "seismograph" - Modem is connected to a seismograph
|
||||||
|
- "slm" - Modem is connected to a sound level meter
|
||||||
|
- NULL/empty - Not assigned or unknown
|
||||||
|
|
||||||
|
deployed_with_unit_id: stores the ID of the seismograph/SLM this modem is deployed with
|
||||||
|
(reverse relationship of deployed_with_modem_id)
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add deployment_type and deployed_with_unit_id columns to roster table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if roster table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='roster'")
|
||||||
|
table_exists = cursor.fetchone()
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
print("Roster table does not exist yet - will be created when app runs")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check existing columns
|
||||||
|
cursor.execute("PRAGMA table_info(roster)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add deployment_type if not exists
|
||||||
|
if 'deployment_type' not in columns:
|
||||||
|
print("Adding deployment_type column to roster table...")
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN deployment_type TEXT")
|
||||||
|
print(" Added deployment_type column")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployment_type ON roster(deployment_type)")
|
||||||
|
print(" Created index on deployment_type")
|
||||||
|
else:
|
||||||
|
print("deployment_type column already exists")
|
||||||
|
|
||||||
|
# Add deployed_with_unit_id if not exists
|
||||||
|
if 'deployed_with_unit_id' not in columns:
|
||||||
|
print("Adding deployed_with_unit_id column to roster table...")
|
||||||
|
cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_unit_id TEXT")
|
||||||
|
print(" Added deployed_with_unit_id column")
|
||||||
|
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployed_with_unit_id ON roster(deployed_with_unit_id)")
|
||||||
|
print(" Created index on deployed_with_unit_id")
|
||||||
|
else:
|
||||||
|
print("deployed_with_unit_id column already exists")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
80
backend/migrate_add_project_number.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add project_number field to projects table.
|
||||||
|
|
||||||
|
This adds a new column for TMI internal project numbering:
|
||||||
|
- Format: xxxx-YY (e.g., "2567-23")
|
||||||
|
- xxxx = incremental project number
|
||||||
|
- YY = year project was started
|
||||||
|
|
||||||
|
Combined with client_name and name (project/site name), this enables
|
||||||
|
smart searching across all project identifiers.
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Add project_number column to projects table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if projects table exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||||
|
table_exists = cursor.fetchone()
|
||||||
|
|
||||||
|
if not table_exists:
|
||||||
|
print("Projects table does not exist yet - will be created when app runs")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if project_number column already exists
|
||||||
|
cursor.execute("PRAGMA table_info(projects)")
|
||||||
|
columns = [col[1] for col in cursor.fetchall()]
|
||||||
|
|
||||||
|
if 'project_number' in columns:
|
||||||
|
print("Migration already applied - project_number column exists")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Adding project_number column to projects table...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("ALTER TABLE projects ADD COLUMN project_number TEXT")
|
||||||
|
print(" Added project_number column")
|
||||||
|
|
||||||
|
# Create index for faster searching
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS ix_projects_project_number ON projects(project_number)")
|
||||||
|
print(" Created index on project_number")
|
||||||
|
|
||||||
|
# Also add index on client_name if it doesn't exist
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS ix_projects_client_name ON projects(client_name)")
|
||||||
|
print(" Created index on client_name")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
88
backend/migrate_add_report_templates.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""
|
||||||
|
Migration script to add report_templates table.
|
||||||
|
|
||||||
|
This creates a new table for storing report generation configurations:
|
||||||
|
- Template name and project association
|
||||||
|
- Time filtering settings (start/end time)
|
||||||
|
- Date range filtering (optional)
|
||||||
|
- Report title defaults
|
||||||
|
|
||||||
|
Run this script once to migrate an existing database.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Database path
|
||||||
|
DB_PATH = "./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
def migrate_database():
|
||||||
|
"""Create report_templates table"""
|
||||||
|
|
||||||
|
if not os.path.exists(DB_PATH):
|
||||||
|
print(f"Database not found at {DB_PATH}")
|
||||||
|
print("The database will be created automatically when you run the application.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Migrating database: {DB_PATH}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Check if report_templates table already exists
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='report_templates'")
|
||||||
|
table_exists = cursor.fetchone()
|
||||||
|
|
||||||
|
if table_exists:
|
||||||
|
print("Migration already applied - report_templates table exists")
|
||||||
|
conn.close()
|
||||||
|
return
|
||||||
|
|
||||||
|
print("Creating report_templates table...")
|
||||||
|
|
||||||
|
try:
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE report_templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
project_id TEXT,
|
||||||
|
report_title TEXT DEFAULT 'Background Noise Study',
|
||||||
|
start_time TEXT,
|
||||||
|
end_time TEXT,
|
||||||
|
start_date TEXT,
|
||||||
|
end_date TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
print(" ✓ Created report_templates table")
|
||||||
|
|
||||||
|
# Insert default templates
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
default_templates = [
|
||||||
|
(str(uuid.uuid4()), "Nighttime (7PM-7AM)", None, "Background Noise Study", "19:00", "07:00", None, None),
|
||||||
|
(str(uuid.uuid4()), "Daytime (7AM-7PM)", None, "Background Noise Study", "07:00", "19:00", None, None),
|
||||||
|
(str(uuid.uuid4()), "Full Day (All Data)", None, "Background Noise Study", None, None, None, None),
|
||||||
|
]
|
||||||
|
|
||||||
|
cursor.executemany("""
|
||||||
|
INSERT INTO report_templates (id, name, project_id, report_title, start_time, end_time, start_date, end_date)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", default_templates)
|
||||||
|
print(" ✓ Inserted default templates (Nighttime, Daytime, Full Day)")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
print("\nMigration completed successfully!")
|
||||||
|
|
||||||
|
except sqlite3.Error as e:
|
||||||
|
print(f"\nError during migration: {e}")
|
||||||
|
conn.rollback()
|
||||||
|
raise
|
||||||
|
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
migrate_database()
|
||||||
@@ -50,6 +50,8 @@ class RosterUnit(Base):
|
|||||||
ip_address = Column(String, nullable=True)
|
ip_address = Column(String, nullable=True)
|
||||||
phone_number = Column(String, nullable=True)
|
phone_number = Column(String, nullable=True)
|
||||||
hardware_model = Column(String, nullable=True)
|
hardware_model = Column(String, nullable=True)
|
||||||
|
deployment_type = Column(String, nullable=True) # "seismograph" | "slm" - what type of device this modem is deployed with
|
||||||
|
deployed_with_unit_id = Column(String, nullable=True) # ID of seismograph/SLM this modem is deployed with
|
||||||
|
|
||||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
@@ -137,17 +139,26 @@ class Project(Base):
|
|||||||
"""
|
"""
|
||||||
Projects: top-level organization for monitoring work.
|
Projects: top-level organization for monitoring work.
|
||||||
Type-aware to enable/disable features based on project_type_id.
|
Type-aware to enable/disable features based on project_type_id.
|
||||||
|
|
||||||
|
Project naming convention:
|
||||||
|
- project_number: TMI internal ID format xxxx-YY (e.g., "2567-23")
|
||||||
|
- client_name: Client/contractor name (e.g., "PJ Dick")
|
||||||
|
- name: Project/site name (e.g., "RKM Hall", "CMU Campus")
|
||||||
|
|
||||||
|
Display format: "2567-23 - PJ Dick - RKM Hall"
|
||||||
|
Users can search by any of these fields.
|
||||||
"""
|
"""
|
||||||
__tablename__ = "projects"
|
__tablename__ = "projects"
|
||||||
|
|
||||||
id = Column(String, primary_key=True, index=True) # UUID
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
name = Column(String, nullable=False, unique=True)
|
project_number = Column(String, nullable=True, index=True) # TMI ID: xxxx-YY format (e.g., "2567-23")
|
||||||
|
name = Column(String, nullable=False, unique=True) # Project/site name (e.g., "RKM Hall")
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||||
status = Column(String, default="active") # active, completed, archived
|
status = Column(String, default="active") # active, completed, archived
|
||||||
|
|
||||||
# Project metadata
|
# Project metadata
|
||||||
client_name = Column(String, nullable=True)
|
client_name = Column(String, nullable=True, index=True) # Client name (e.g., "PJ Dick")
|
||||||
site_address = Column(String, nullable=True)
|
site_address = Column(String, nullable=True)
|
||||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||||
start_date = Column(Date, nullable=True)
|
start_date = Column(Date, nullable=True)
|
||||||
@@ -278,3 +289,116 @@ class DataFile(Base):
|
|||||||
file_metadata = Column(Text, nullable=True) # JSON
|
file_metadata = Column(Text, nullable=True) # JSON
|
||||||
|
|
||||||
created_at = Column(DateTime, default=datetime.utcnow)
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportTemplate(Base):
|
||||||
|
"""
|
||||||
|
Report templates: saved configurations for generating Excel reports.
|
||||||
|
Allows users to save time filter presets, titles, etc. for reuse.
|
||||||
|
"""
|
||||||
|
__tablename__ = "report_templates"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
name = Column(String, nullable=False) # "Nighttime Report", "Full Day Report"
|
||||||
|
project_id = Column(String, nullable=True) # Optional: project-specific template
|
||||||
|
|
||||||
|
# Template settings
|
||||||
|
report_title = Column(String, default="Background Noise Study")
|
||||||
|
start_time = Column(String, nullable=True) # "19:00" format
|
||||||
|
end_time = Column(String, nullable=True) # "07:00" format
|
||||||
|
start_date = Column(String, nullable=True) # "2025-01-15" format (optional)
|
||||||
|
end_date = Column(String, nullable=True) # "2025-01-20" format (optional)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Sound Monitoring Scheduler
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class RecurringSchedule(Base):
|
||||||
|
"""
|
||||||
|
Recurring schedule definitions for automated sound monitoring.
|
||||||
|
|
||||||
|
Supports two schedule types:
|
||||||
|
- "weekly_calendar": Select specific days with start/end times (e.g., Mon/Wed/Fri 7pm-7am)
|
||||||
|
- "simple_interval": For 24/7 monitoring with daily stop/download/restart cycles
|
||||||
|
"""
|
||||||
|
__tablename__ = "recurring_schedules"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||||
|
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||||
|
unit_id = Column(String, nullable=True, index=True) # FK to RosterUnit.id (optional, can use assignment)
|
||||||
|
|
||||||
|
name = Column(String, nullable=False) # "Weeknight Monitoring", "24/7 Continuous"
|
||||||
|
schedule_type = Column(String, nullable=False) # "weekly_calendar" | "simple_interval"
|
||||||
|
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||||
|
|
||||||
|
# Weekly Calendar fields (schedule_type = "weekly_calendar")
|
||||||
|
# JSON format: {
|
||||||
|
# "monday": {"enabled": true, "start": "19:00", "end": "07:00"},
|
||||||
|
# "tuesday": {"enabled": false},
|
||||||
|
# ...
|
||||||
|
# }
|
||||||
|
weekly_pattern = Column(Text, nullable=True)
|
||||||
|
|
||||||
|
# Simple Interval fields (schedule_type = "simple_interval")
|
||||||
|
interval_type = Column(String, nullable=True) # "daily" | "hourly"
|
||||||
|
cycle_time = Column(String, nullable=True) # "00:00" - time to run stop/download/restart
|
||||||
|
include_download = Column(Boolean, default=True) # Download data before restart
|
||||||
|
|
||||||
|
# Automation options (applies to both schedule types)
|
||||||
|
auto_increment_index = Column(Boolean, default=True) # Auto-increment store/index number before start
|
||||||
|
# When True: prevents "overwrite data?" prompts by using a new index each time
|
||||||
|
|
||||||
|
# Shared configuration
|
||||||
|
enabled = Column(Boolean, default=True)
|
||||||
|
timezone = Column(String, default="America/New_York")
|
||||||
|
|
||||||
|
# Tracking
|
||||||
|
last_generated_at = Column(DateTime, nullable=True) # When actions were last generated
|
||||||
|
next_occurrence = Column(DateTime, nullable=True) # Computed next action time
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class Alert(Base):
|
||||||
|
"""
|
||||||
|
In-app alerts for device status changes and system events.
|
||||||
|
|
||||||
|
Designed for future expansion to email/webhook notifications.
|
||||||
|
Currently supports:
|
||||||
|
- device_offline: Device became unreachable
|
||||||
|
- device_online: Device came back online
|
||||||
|
- schedule_failed: Scheduled action failed to execute
|
||||||
|
- schedule_completed: Scheduled action completed successfully
|
||||||
|
"""
|
||||||
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True) # UUID
|
||||||
|
|
||||||
|
# Alert classification
|
||||||
|
alert_type = Column(String, nullable=False) # "device_offline" | "device_online" | "schedule_failed" | "schedule_completed"
|
||||||
|
severity = Column(String, default="warning") # "info" | "warning" | "critical"
|
||||||
|
|
||||||
|
# Related entities (nullable - may not all apply)
|
||||||
|
project_id = Column(String, nullable=True, index=True)
|
||||||
|
location_id = Column(String, nullable=True, index=True)
|
||||||
|
unit_id = Column(String, nullable=True, index=True)
|
||||||
|
schedule_id = Column(String, nullable=True) # RecurringSchedule or ScheduledAction id
|
||||||
|
|
||||||
|
# Alert content
|
||||||
|
title = Column(String, nullable=False) # "NRL-001 Device Offline"
|
||||||
|
message = Column(Text, nullable=True) # Detailed description
|
||||||
|
alert_metadata = Column(Text, nullable=True) # JSON: additional context data
|
||||||
|
|
||||||
|
# Status tracking
|
||||||
|
status = Column(String, default="active") # "active" | "acknowledged" | "resolved" | "dismissed"
|
||||||
|
acknowledged_at = Column(DateTime, nullable=True)
|
||||||
|
resolved_at = Column(DateTime, nullable=True)
|
||||||
|
|
||||||
|
created_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
expires_at = Column(DateTime, nullable=True) # Auto-dismiss after this time
|
||||||
|
|||||||
326
backend/routers/alerts.py
Normal file
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
Alerts Router
|
||||||
|
|
||||||
|
API endpoints for managing in-app alerts.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import Alert, RosterUnit
|
||||||
|
from backend.services.alert_service import get_alert_service
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Alert List and Count
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_alerts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: Optional[str] = Query(None, description="Filter by status: active, acknowledged, resolved, dismissed"),
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
|
unit_id: Optional[str] = Query(None),
|
||||||
|
alert_type: Optional[str] = Query(None, description="Filter by type: device_offline, device_online, schedule_failed"),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List alerts with optional filters.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
|
||||||
|
alerts = alert_service.get_all_alerts(
|
||||||
|
status=status,
|
||||||
|
project_id=project_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
alert_type=alert_type,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"alert_type": a.alert_type,
|
||||||
|
"severity": a.severity,
|
||||||
|
"title": a.title,
|
||||||
|
"message": a.message,
|
||||||
|
"status": a.status,
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"location_id": a.location_id,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
"acknowledged_at": a.acknowledged_at.isoformat() if a.acknowledged_at else None,
|
||||||
|
"resolved_at": a.resolved_at.isoformat() if a.resolved_at else None,
|
||||||
|
}
|
||||||
|
for a in alerts
|
||||||
|
],
|
||||||
|
"count": len(alerts),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active")
|
||||||
|
async def list_active_alerts(
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
project_id: Optional[str] = Query(None),
|
||||||
|
unit_id: Optional[str] = Query(None),
|
||||||
|
alert_type: Optional[str] = Query(None),
|
||||||
|
min_severity: Optional[str] = Query(None, description="Minimum severity: info, warning, critical"),
|
||||||
|
limit: int = Query(50, le=100),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List only active alerts.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
|
||||||
|
alerts = alert_service.get_active_alerts(
|
||||||
|
project_id=project_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
alert_type=alert_type,
|
||||||
|
min_severity=min_severity,
|
||||||
|
limit=limit,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"alerts": [
|
||||||
|
{
|
||||||
|
"id": a.id,
|
||||||
|
"alert_type": a.alert_type,
|
||||||
|
"severity": a.severity,
|
||||||
|
"title": a.title,
|
||||||
|
"message": a.message,
|
||||||
|
"unit_id": a.unit_id,
|
||||||
|
"project_id": a.project_id,
|
||||||
|
"created_at": a.created_at.isoformat() if a.created_at else None,
|
||||||
|
}
|
||||||
|
for a in alerts
|
||||||
|
],
|
||||||
|
"count": len(alerts),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/active/count")
|
||||||
|
async def get_active_alert_count(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get count of active alerts (for navbar badge).
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
count = alert_service.get_active_alert_count()
|
||||||
|
return {"count": count}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Single Alert Operations
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/{alert_id}")
|
||||||
|
async def get_alert(
|
||||||
|
alert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific alert.
|
||||||
|
"""
|
||||||
|
alert = db.query(Alert).filter_by(id=alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
# Get related unit info
|
||||||
|
unit = None
|
||||||
|
if alert.unit_id:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=alert.unit_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": alert.id,
|
||||||
|
"alert_type": alert.alert_type,
|
||||||
|
"severity": alert.severity,
|
||||||
|
"title": alert.title,
|
||||||
|
"message": alert.message,
|
||||||
|
"metadata": alert.alert_metadata,
|
||||||
|
"status": alert.status,
|
||||||
|
"unit_id": alert.unit_id,
|
||||||
|
"unit_name": unit.id if unit else None,
|
||||||
|
"project_id": alert.project_id,
|
||||||
|
"location_id": alert.location_id,
|
||||||
|
"schedule_id": alert.schedule_id,
|
||||||
|
"created_at": alert.created_at.isoformat() if alert.created_at else None,
|
||||||
|
"acknowledged_at": alert.acknowledged_at.isoformat() if alert.acknowledged_at else None,
|
||||||
|
"resolved_at": alert.resolved_at.isoformat() if alert.resolved_at else None,
|
||||||
|
"expires_at": alert.expires_at.isoformat() if alert.expires_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/acknowledge")
|
||||||
|
async def acknowledge_alert(
|
||||||
|
alert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Mark alert as acknowledged.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alert = alert_service.acknowledge_alert(alert_id)
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"alert_id": alert.id,
|
||||||
|
"status": alert.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/dismiss")
|
||||||
|
async def dismiss_alert(
|
||||||
|
alert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Dismiss alert.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alert = alert_service.dismiss_alert(alert_id)
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"alert_id": alert.id,
|
||||||
|
"status": alert.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/resolve")
|
||||||
|
async def resolve_alert(
|
||||||
|
alert_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually resolve an alert.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alert = alert_service.resolve_alert(alert_id)
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
raise HTTPException(status_code=404, detail="Alert not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"alert_id": alert.id,
|
||||||
|
"status": alert.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HTML Partials for HTMX
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/partials/dropdown", response_class=HTMLResponse)
|
||||||
|
async def get_alert_dropdown(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return HTML partial for alert dropdown in navbar.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alerts = alert_service.get_active_alerts(limit=10)
|
||||||
|
|
||||||
|
# Calculate relative time for each alert
|
||||||
|
now = datetime.utcnow()
|
||||||
|
alerts_data = []
|
||||||
|
for alert in alerts:
|
||||||
|
delta = now - alert.created_at
|
||||||
|
if delta.days > 0:
|
||||||
|
time_ago = f"{delta.days}d ago"
|
||||||
|
elif delta.seconds >= 3600:
|
||||||
|
time_ago = f"{delta.seconds // 3600}h ago"
|
||||||
|
elif delta.seconds >= 60:
|
||||||
|
time_ago = f"{delta.seconds // 60}m ago"
|
||||||
|
else:
|
||||||
|
time_ago = "just now"
|
||||||
|
|
||||||
|
alerts_data.append({
|
||||||
|
"alert": alert,
|
||||||
|
"time_ago": time_ago,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/alerts/alert_dropdown.html", {
|
||||||
|
"request": request,
|
||||||
|
"alerts": alerts_data,
|
||||||
|
"total_count": alert_service.get_active_alert_count(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/partials/list", response_class=HTMLResponse)
|
||||||
|
async def get_alert_list(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
status: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(20),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return HTML partial for alert list page.
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
alerts = alert_service.get_all_alerts(status=status, limit=limit)
|
||||||
|
else:
|
||||||
|
alerts = alert_service.get_all_alerts(limit=limit)
|
||||||
|
|
||||||
|
# Calculate relative time for each alert
|
||||||
|
now = datetime.utcnow()
|
||||||
|
alerts_data = []
|
||||||
|
for alert in alerts:
|
||||||
|
delta = now - alert.created_at
|
||||||
|
if delta.days > 0:
|
||||||
|
time_ago = f"{delta.days}d ago"
|
||||||
|
elif delta.seconds >= 3600:
|
||||||
|
time_ago = f"{delta.seconds // 3600}h ago"
|
||||||
|
elif delta.seconds >= 60:
|
||||||
|
time_ago = f"{delta.seconds // 60}m ago"
|
||||||
|
else:
|
||||||
|
time_ago = "just now"
|
||||||
|
|
||||||
|
alerts_data.append({
|
||||||
|
"alert": alert,
|
||||||
|
"time_ago": time_ago,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/alerts/alert_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"alerts": alerts_data,
|
||||||
|
"status_filter": status,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Cleanup
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/cleanup-expired")
|
||||||
|
async def cleanup_expired_alerts(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Cleanup expired alerts (admin/maintenance endpoint).
|
||||||
|
"""
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
count = alert_service.cleanup_expired_alerts()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"cleaned_up": count,
|
||||||
|
}
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
from fastapi import APIRouter, Request, Depends
|
from fastapi import APIRouter, Request, Depends
|
||||||
from fastapi.templating import Jinja2Templates
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import ScheduledAction, MonitoringLocation, Project
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.templates_config import templates
|
||||||
|
from backend.utils.timezone import utc_to_local, local_to_utc, get_user_timezone
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/dashboard/active")
|
@router.get("/dashboard/active")
|
||||||
@@ -23,3 +27,71 @@ def dashboard_benched(request: Request):
|
|||||||
"partials/benched_table.html",
|
"partials/benched_table.html",
|
||||||
{"request": request, "units": snapshot["benched"]}
|
{"request": request, "units": snapshot["benched"]}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/todays-actions")
|
||||||
|
def dashboard_todays_actions(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get today's scheduled actions for the dashboard card.
|
||||||
|
Shows upcoming, completed, and failed actions for today.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
# Get today's date range in local timezone
|
||||||
|
tz = ZoneInfo(get_user_timezone())
|
||||||
|
now_local = datetime.now(tz)
|
||||||
|
today_start_local = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
today_end_local = today_start_local + timedelta(days=1)
|
||||||
|
|
||||||
|
# Convert to UTC for database query
|
||||||
|
today_start_utc = today_start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
today_end_utc = today_end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Query today's actions
|
||||||
|
actions = db.query(ScheduledAction).filter(
|
||||||
|
ScheduledAction.scheduled_time >= today_start_utc,
|
||||||
|
ScheduledAction.scheduled_time < today_end_utc,
|
||||||
|
).order_by(ScheduledAction.scheduled_time.asc()).all()
|
||||||
|
|
||||||
|
# Enrich with location/project info and parse results
|
||||||
|
enriched_actions = []
|
||||||
|
for action in actions:
|
||||||
|
location = None
|
||||||
|
project = None
|
||||||
|
if action.location_id:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||||
|
if action.project_id:
|
||||||
|
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||||
|
|
||||||
|
# Parse module_response for result details
|
||||||
|
result_data = None
|
||||||
|
if action.module_response:
|
||||||
|
try:
|
||||||
|
result_data = json.loads(action.module_response)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
enriched_actions.append({
|
||||||
|
"action": action,
|
||||||
|
"location": location,
|
||||||
|
"project": project,
|
||||||
|
"result": result_data,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Count by status
|
||||||
|
pending_count = sum(1 for a in actions if a.execution_status == "pending")
|
||||||
|
completed_count = sum(1 for a in actions if a.execution_status == "completed")
|
||||||
|
failed_count = sum(1 for a in actions if a.execution_status == "failed")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/dashboard/todays_actions.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"actions": enriched_actions,
|
||||||
|
"pending_count": pending_count,
|
||||||
|
"completed_count": completed_count,
|
||||||
|
"failed_count": failed_count,
|
||||||
|
"total_count": len(actions),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|||||||
286
backend/routers/modem_dashboard.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
"""
|
||||||
|
Modem Dashboard Router
|
||||||
|
|
||||||
|
Provides API endpoints for the Field Modems management page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
import subprocess
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/modem-dashboard", tags=["modem-dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_modem_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get summary statistics for modem dashboard.
|
||||||
|
Returns HTML partial with stat cards.
|
||||||
|
"""
|
||||||
|
# Query all modems
|
||||||
|
all_modems = db.query(RosterUnit).filter_by(device_type="modem").all()
|
||||||
|
|
||||||
|
# Get IDs of modems that have devices paired to them
|
||||||
|
paired_modem_ids = set()
|
||||||
|
devices_with_modems = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.deployed_with_modem_id.isnot(None),
|
||||||
|
RosterUnit.retired == False
|
||||||
|
).all()
|
||||||
|
for device in devices_with_modems:
|
||||||
|
if device.deployed_with_modem_id:
|
||||||
|
paired_modem_ids.add(device.deployed_with_modem_id)
|
||||||
|
|
||||||
|
# Count categories
|
||||||
|
total_count = len(all_modems)
|
||||||
|
retired_count = sum(1 for m in all_modems if m.retired)
|
||||||
|
|
||||||
|
# In use = deployed AND paired with a device
|
||||||
|
in_use_count = sum(1 for m in all_modems
|
||||||
|
if m.deployed and not m.retired and m.id in paired_modem_ids)
|
||||||
|
|
||||||
|
# Spare = deployed but NOT paired (available for assignment)
|
||||||
|
spare_count = sum(1 for m in all_modems
|
||||||
|
if m.deployed and not m.retired and m.id not in paired_modem_ids)
|
||||||
|
|
||||||
|
# Benched = not deployed and not retired
|
||||||
|
benched_count = sum(1 for m in all_modems if not m.deployed and not m.retired)
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/modem_stats.html", {
|
||||||
|
"request": request,
|
||||||
|
"total_count": total_count,
|
||||||
|
"in_use_count": in_use_count,
|
||||||
|
"spare_count": spare_count,
|
||||||
|
"benched_count": benched_count,
|
||||||
|
"retired_count": retired_count
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_modem_units(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
search: str = Query(None),
|
||||||
|
filter_status: str = Query(None), # "in_use", "spare", "benched", "retired"
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get list of modem units for the dashboard.
|
||||||
|
Returns HTML partial with modem cards.
|
||||||
|
"""
|
||||||
|
query = db.query(RosterUnit).filter_by(device_type="modem")
|
||||||
|
|
||||||
|
# Filter by search term if provided
|
||||||
|
if search:
|
||||||
|
search_term = f"%{search}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
|
(RosterUnit.hardware_model.ilike(search_term)) |
|
||||||
|
(RosterUnit.phone_number.ilike(search_term)) |
|
||||||
|
(RosterUnit.location.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
modems = query.order_by(
|
||||||
|
RosterUnit.retired.asc(),
|
||||||
|
RosterUnit.deployed.desc(),
|
||||||
|
RosterUnit.id.asc()
|
||||||
|
).all()
|
||||||
|
|
||||||
|
# Get paired device info for each modem
|
||||||
|
paired_devices = {}
|
||||||
|
devices_with_modems = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.deployed_with_modem_id.isnot(None),
|
||||||
|
RosterUnit.retired == False
|
||||||
|
).all()
|
||||||
|
for device in devices_with_modems:
|
||||||
|
if device.deployed_with_modem_id:
|
||||||
|
paired_devices[device.deployed_with_modem_id] = {
|
||||||
|
"id": device.id,
|
||||||
|
"device_type": device.device_type,
|
||||||
|
"deployed": device.deployed
|
||||||
|
}
|
||||||
|
|
||||||
|
# Annotate modems with paired device info
|
||||||
|
modem_list = []
|
||||||
|
for modem in modems:
|
||||||
|
paired = paired_devices.get(modem.id)
|
||||||
|
|
||||||
|
# Determine status category
|
||||||
|
if modem.retired:
|
||||||
|
status = "retired"
|
||||||
|
elif not modem.deployed:
|
||||||
|
status = "benched"
|
||||||
|
elif paired:
|
||||||
|
status = "in_use"
|
||||||
|
else:
|
||||||
|
status = "spare"
|
||||||
|
|
||||||
|
# Apply filter if specified
|
||||||
|
if filter_status and status != filter_status:
|
||||||
|
continue
|
||||||
|
|
||||||
|
modem_list.append({
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"phone_number": modem.phone_number,
|
||||||
|
"hardware_model": modem.hardware_model,
|
||||||
|
"deployed": modem.deployed,
|
||||||
|
"retired": modem.retired,
|
||||||
|
"location": modem.location,
|
||||||
|
"project_id": modem.project_id,
|
||||||
|
"paired_device": paired,
|
||||||
|
"status": status
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/modem_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"modems": modem_list
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{modem_id}/paired-device")
|
||||||
|
async def get_paired_device(modem_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get the device (SLM/seismograph) that is paired with this modem.
|
||||||
|
Returns JSON with device info or null if not paired.
|
||||||
|
"""
|
||||||
|
# Check modem exists
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
if not modem:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||||
|
|
||||||
|
# Find device paired with this modem
|
||||||
|
device = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.deployed_with_modem_id == modem_id,
|
||||||
|
RosterUnit.retired == False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if device:
|
||||||
|
return {
|
||||||
|
"paired": True,
|
||||||
|
"device": {
|
||||||
|
"id": device.id,
|
||||||
|
"device_type": device.device_type,
|
||||||
|
"deployed": device.deployed,
|
||||||
|
"project_id": device.project_id,
|
||||||
|
"location": device.location or device.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {"paired": False, "device": None}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{modem_id}/paired-device-html", response_class=HTMLResponse)
|
||||||
|
async def get_paired_device_html(modem_id: str, request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get HTML partial showing the device paired with this modem.
|
||||||
|
Used by unit_detail.html for modems.
|
||||||
|
"""
|
||||||
|
# Check modem exists
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
if not modem:
|
||||||
|
return HTMLResponse('<p class="text-red-500">Modem not found</p>')
|
||||||
|
|
||||||
|
# Find device paired with this modem
|
||||||
|
device = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.deployed_with_modem_id == modem_id,
|
||||||
|
RosterUnit.retired == False
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/modem_paired_device.html", {
|
||||||
|
"request": request,
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"device": device
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{modem_id}/ping")
|
||||||
|
async def ping_modem(modem_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Test modem connectivity with a simple ping.
|
||||||
|
Returns response time and connection status.
|
||||||
|
"""
|
||||||
|
# Get modem from database
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
|
||||||
|
if not modem:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||||
|
|
||||||
|
if not modem.ip_address:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Ping the modem (1 packet, 2 second timeout)
|
||||||
|
start_time = time.time()
|
||||||
|
result = subprocess.run(
|
||||||
|
["ping", "-c", "1", "-W", "2", modem.ip_address],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3
|
||||||
|
)
|
||||||
|
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
|
||||||
|
|
||||||
|
if result.returncode == 0:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"response_time_ms": response_time,
|
||||||
|
"message": "Modem is responding"
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"detail": "Modem not responding to ping"
|
||||||
|
}
|
||||||
|
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"detail": "Ping timeout"
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
||||||
|
return {
|
||||||
|
"status": "error",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"detail": str(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{modem_id}/diagnostics")
|
||||||
|
async def get_modem_diagnostics(modem_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get modem diagnostics (signal strength, data usage, uptime).
|
||||||
|
|
||||||
|
Currently returns placeholders. When ModemManager is available,
|
||||||
|
this endpoint will query it for real diagnostics.
|
||||||
|
"""
|
||||||
|
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||||
|
if not modem:
|
||||||
|
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||||
|
|
||||||
|
# TODO: Query ModemManager backend when available
|
||||||
|
return {
|
||||||
|
"status": "unavailable",
|
||||||
|
"message": "ModemManager integration not yet available",
|
||||||
|
"modem_id": modem_id,
|
||||||
|
"signal_strength_dbm": None,
|
||||||
|
"data_usage_mb": None,
|
||||||
|
"uptime_seconds": None,
|
||||||
|
"carrier": None,
|
||||||
|
"connection_type": None # LTE, 5G, etc.
|
||||||
|
}
|
||||||
@@ -6,7 +6,6 @@ and unit assignments within projects.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
@@ -24,9 +23,9 @@ from backend.models import (
|
|||||||
RosterUnit,
|
RosterUnit,
|
||||||
RecordingSession,
|
RecordingSession,
|
||||||
)
|
)
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -90,6 +89,40 @@ async def get_project_locations(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/locations-json")
|
||||||
|
async def get_project_locations_json(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
location_type: Optional[str] = Query(None),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get all monitoring locations for a project as JSON.
|
||||||
|
Used by the schedule modal to populate location dropdown.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
query = db.query(MonitoringLocation).filter_by(project_id=project_id)
|
||||||
|
|
||||||
|
if location_type:
|
||||||
|
query = query.filter_by(location_type=location_type)
|
||||||
|
|
||||||
|
locations = query.order_by(MonitoringLocation.name).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": loc.id,
|
||||||
|
"name": loc.name,
|
||||||
|
"location_type": loc.location_type,
|
||||||
|
"description": loc.description,
|
||||||
|
"address": loc.address,
|
||||||
|
"coordinates": loc.coordinates,
|
||||||
|
}
|
||||||
|
for loc in locations
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/locations/create")
|
@router.post("/locations/create")
|
||||||
async def create_location(
|
async def create_location(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
465
backend/routers/recurring_schedules.py
Normal file
@@ -0,0 +1,465 @@
|
|||||||
|
"""
|
||||||
|
Recurring Schedules Router
|
||||||
|
|
||||||
|
API endpoints for managing recurring monitoring schedules.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RecurringSchedule, MonitoringLocation, Project, RosterUnit
|
||||||
|
from backend.services.recurring_schedule_service import get_recurring_schedule_service
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# List and Get
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/")
|
||||||
|
async def list_recurring_schedules(
|
||||||
|
project_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
enabled_only: bool = Query(False),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all recurring schedules for a project.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
query = db.query(RecurringSchedule).filter_by(project_id=project_id)
|
||||||
|
if enabled_only:
|
||||||
|
query = query.filter_by(enabled=True)
|
||||||
|
|
||||||
|
schedules = query.order_by(RecurringSchedule.created_at.desc()).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schedules": [
|
||||||
|
{
|
||||||
|
"id": s.id,
|
||||||
|
"name": s.name,
|
||||||
|
"schedule_type": s.schedule_type,
|
||||||
|
"device_type": s.device_type,
|
||||||
|
"location_id": s.location_id,
|
||||||
|
"unit_id": s.unit_id,
|
||||||
|
"enabled": s.enabled,
|
||||||
|
"weekly_pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
|
||||||
|
"interval_type": s.interval_type,
|
||||||
|
"cycle_time": s.cycle_time,
|
||||||
|
"include_download": s.include_download,
|
||||||
|
"timezone": s.timezone,
|
||||||
|
"next_occurrence": s.next_occurrence.isoformat() if s.next_occurrence else None,
|
||||||
|
"last_generated_at": s.last_generated_at.isoformat() if s.last_generated_at else None,
|
||||||
|
"created_at": s.created_at.isoformat() if s.created_at else None,
|
||||||
|
}
|
||||||
|
for s in schedules
|
||||||
|
],
|
||||||
|
"count": len(schedules),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{schedule_id}")
|
||||||
|
async def get_recurring_schedule(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Get a specific recurring schedule.
|
||||||
|
"""
|
||||||
|
schedule = db.query(RecurringSchedule).filter_by(
|
||||||
|
id=schedule_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
# Get related location and unit info
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
|
||||||
|
unit = None
|
||||||
|
if schedule.unit_id:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=schedule.unit_id).first()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": schedule.id,
|
||||||
|
"name": schedule.name,
|
||||||
|
"schedule_type": schedule.schedule_type,
|
||||||
|
"device_type": schedule.device_type,
|
||||||
|
"location_id": schedule.location_id,
|
||||||
|
"location_name": location.name if location else None,
|
||||||
|
"unit_id": schedule.unit_id,
|
||||||
|
"unit_name": unit.id if unit else None,
|
||||||
|
"enabled": schedule.enabled,
|
||||||
|
"weekly_pattern": json.loads(schedule.weekly_pattern) if schedule.weekly_pattern else None,
|
||||||
|
"interval_type": schedule.interval_type,
|
||||||
|
"cycle_time": schedule.cycle_time,
|
||||||
|
"include_download": schedule.include_download,
|
||||||
|
"timezone": schedule.timezone,
|
||||||
|
"next_occurrence": schedule.next_occurrence.isoformat() if schedule.next_occurrence else None,
|
||||||
|
"last_generated_at": schedule.last_generated_at.isoformat() if schedule.last_generated_at else None,
|
||||||
|
"created_at": schedule.created_at.isoformat() if schedule.created_at else None,
|
||||||
|
"updated_at": schedule.updated_at.isoformat() if schedule.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Create
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/")
|
||||||
|
async def create_recurring_schedule(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create recurring schedules for one or more locations.
|
||||||
|
|
||||||
|
Body for weekly_calendar (supports multiple locations):
|
||||||
|
{
|
||||||
|
"name": "Weeknight Monitoring",
|
||||||
|
"schedule_type": "weekly_calendar",
|
||||||
|
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
|
||||||
|
"weekly_pattern": {
|
||||||
|
"monday": {"enabled": true, "start": "19:00", "end": "07:00"},
|
||||||
|
"tuesday": {"enabled": false},
|
||||||
|
...
|
||||||
|
},
|
||||||
|
"include_download": true,
|
||||||
|
"auto_increment_index": true,
|
||||||
|
"timezone": "America/New_York"
|
||||||
|
}
|
||||||
|
|
||||||
|
Body for simple_interval (supports multiple locations):
|
||||||
|
{
|
||||||
|
"name": "24/7 Continuous",
|
||||||
|
"schedule_type": "simple_interval",
|
||||||
|
"location_ids": ["uuid1", "uuid2"], // Array of location IDs
|
||||||
|
"interval_type": "daily",
|
||||||
|
"cycle_time": "00:00",
|
||||||
|
"include_download": true,
|
||||||
|
"auto_increment_index": true,
|
||||||
|
"timezone": "America/New_York"
|
||||||
|
}
|
||||||
|
|
||||||
|
Legacy single location support (backwards compatible):
|
||||||
|
{
|
||||||
|
"name": "...",
|
||||||
|
"location_id": "uuid", // Single location ID
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
|
||||||
|
# Support both location_ids (array) and location_id (single) for backwards compatibility
|
||||||
|
location_ids = data.get("location_ids", [])
|
||||||
|
if not location_ids and data.get("location_id"):
|
||||||
|
location_ids = [data.get("location_id")]
|
||||||
|
|
||||||
|
if not location_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one location is required")
|
||||||
|
|
||||||
|
# Validate all locations exist
|
||||||
|
locations = db.query(MonitoringLocation).filter(
|
||||||
|
MonitoringLocation.id.in_(location_ids),
|
||||||
|
MonitoringLocation.project_id == project_id,
|
||||||
|
).all()
|
||||||
|
|
||||||
|
if len(locations) != len(location_ids):
|
||||||
|
raise HTTPException(status_code=404, detail="One or more locations not found")
|
||||||
|
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
created_schedules = []
|
||||||
|
base_name = data.get("name", "Unnamed Schedule")
|
||||||
|
|
||||||
|
# Create a schedule for each location
|
||||||
|
for location in locations:
|
||||||
|
# Determine device type from location
|
||||||
|
device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||||
|
|
||||||
|
# Append location name if multiple locations
|
||||||
|
schedule_name = f"{base_name} - {location.name}" if len(locations) > 1 else base_name
|
||||||
|
|
||||||
|
schedule = service.create_schedule(
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location.id,
|
||||||
|
name=schedule_name,
|
||||||
|
schedule_type=data.get("schedule_type", "weekly_calendar"),
|
||||||
|
device_type=device_type,
|
||||||
|
unit_id=data.get("unit_id"),
|
||||||
|
weekly_pattern=data.get("weekly_pattern"),
|
||||||
|
interval_type=data.get("interval_type"),
|
||||||
|
cycle_time=data.get("cycle_time"),
|
||||||
|
include_download=data.get("include_download", True),
|
||||||
|
auto_increment_index=data.get("auto_increment_index", True),
|
||||||
|
timezone=data.get("timezone", "America/New_York"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate actions immediately so they appear right away
|
||||||
|
generated_actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
|
||||||
|
|
||||||
|
created_schedules.append({
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"location_id": location.id,
|
||||||
|
"location_name": location.name,
|
||||||
|
"actions_generated": len(generated_actions),
|
||||||
|
})
|
||||||
|
|
||||||
|
total_actions = sum(s.get("actions_generated", 0) for s in created_schedules)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"success": True,
|
||||||
|
"schedules": created_schedules,
|
||||||
|
"count": len(created_schedules),
|
||||||
|
"actions_generated": total_actions,
|
||||||
|
"message": f"Created {len(created_schedules)} recurring schedule(s) with {total_actions} upcoming actions",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Update
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.put("/{schedule_id}")
|
||||||
|
async def update_recurring_schedule(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update a recurring schedule.
|
||||||
|
"""
|
||||||
|
schedule = db.query(RecurringSchedule).filter_by(
|
||||||
|
id=schedule_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
|
||||||
|
# Build update kwargs
|
||||||
|
update_kwargs = {}
|
||||||
|
for field in ["name", "weekly_pattern", "interval_type", "cycle_time",
|
||||||
|
"include_download", "auto_increment_index", "timezone", "unit_id"]:
|
||||||
|
if field in data:
|
||||||
|
update_kwargs[field] = data[field]
|
||||||
|
|
||||||
|
updated = service.update_schedule(schedule_id, **update_kwargs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"schedule_id": updated.id,
|
||||||
|
"message": "Schedule updated successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Delete
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.delete("/{schedule_id}")
|
||||||
|
async def delete_recurring_schedule(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Delete a recurring schedule.
|
||||||
|
"""
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
deleted = service.delete_schedule(schedule_id)
|
||||||
|
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Schedule deleted successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Enable/Disable
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/{schedule_id}/enable")
|
||||||
|
async def enable_schedule(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Enable a disabled schedule.
|
||||||
|
"""
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
schedule = service.enable_schedule(schedule_id)
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"enabled": schedule.enabled,
|
||||||
|
"message": "Schedule enabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{schedule_id}/disable")
|
||||||
|
async def disable_schedule(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Disable a schedule.
|
||||||
|
"""
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
schedule = service.disable_schedule(schedule_id)
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"enabled": schedule.enabled,
|
||||||
|
"message": "Schedule disabled",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Preview Generated Actions
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/{schedule_id}/generate-preview")
|
||||||
|
async def preview_generated_actions(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
days: int = Query(7, ge=1, le=30),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Preview what actions would be generated without saving them.
|
||||||
|
"""
|
||||||
|
schedule = db.query(RecurringSchedule).filter_by(
|
||||||
|
id=schedule_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
actions = service.generate_actions_for_schedule(
|
||||||
|
schedule,
|
||||||
|
horizon_days=days,
|
||||||
|
preview_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schedule_id": schedule_id,
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"preview_days": days,
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"action_type": a.action_type,
|
||||||
|
"scheduled_time": a.scheduled_time.isoformat(),
|
||||||
|
"notes": a.notes,
|
||||||
|
}
|
||||||
|
for a in actions
|
||||||
|
],
|
||||||
|
"action_count": len(actions),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Manual Generation Trigger
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.post("/{schedule_id}/generate")
|
||||||
|
async def generate_actions_now(
|
||||||
|
project_id: str,
|
||||||
|
schedule_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
days: int = Query(7, ge=1, le=30),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Manually trigger action generation for a schedule.
|
||||||
|
"""
|
||||||
|
schedule = db.query(RecurringSchedule).filter_by(
|
||||||
|
id=schedule_id,
|
||||||
|
project_id=project_id,
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not schedule:
|
||||||
|
raise HTTPException(status_code=404, detail="Schedule not found")
|
||||||
|
|
||||||
|
if not schedule.enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="Schedule is disabled")
|
||||||
|
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
actions = service.generate_actions_for_schedule(
|
||||||
|
schedule,
|
||||||
|
horizon_days=days,
|
||||||
|
preview_only=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"schedule_id": schedule_id,
|
||||||
|
"generated_count": len(actions),
|
||||||
|
"message": f"Generated {len(actions)} scheduled actions",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# HTML Partials
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
@router.get("/partials/list", response_class=HTMLResponse)
|
||||||
|
async def get_schedule_list_partial(
|
||||||
|
project_id: str,
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Return HTML partial for schedule list.
|
||||||
|
"""
|
||||||
|
schedules = db.query(RecurringSchedule).filter_by(
|
||||||
|
project_id=project_id
|
||||||
|
).order_by(RecurringSchedule.created_at.desc()).all()
|
||||||
|
|
||||||
|
# Enrich with location info
|
||||||
|
schedule_data = []
|
||||||
|
for s in schedules:
|
||||||
|
location = db.query(MonitoringLocation).filter_by(id=s.location_id).first()
|
||||||
|
schedule_data.append({
|
||||||
|
"schedule": s,
|
||||||
|
"location": location,
|
||||||
|
"pattern": json.loads(s.weekly_pattern) if s.weekly_pattern else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return templates.TemplateResponse("partials/projects/recurring_schedule_list.html", {
|
||||||
|
"request": request,
|
||||||
|
"project_id": project_id,
|
||||||
|
"schedules": schedule_data,
|
||||||
|
})
|
||||||
187
backend/routers/report_templates.py
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
"""
|
||||||
|
Report Templates Router
|
||||||
|
|
||||||
|
CRUD operations for report template management.
|
||||||
|
Templates store time filter presets and report configuration for reuse.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import ReportTemplate
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/report-templates", tags=["report-templates"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("")
|
||||||
|
async def list_templates(
|
||||||
|
project_id: Optional[str] = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
List all report templates.
|
||||||
|
Optionally filter by project_id (includes global templates with project_id=None).
|
||||||
|
"""
|
||||||
|
query = db.query(ReportTemplate)
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
# Include global templates (project_id=None) AND project-specific templates
|
||||||
|
query = query.filter(
|
||||||
|
(ReportTemplate.project_id == None) | (ReportTemplate.project_id == project_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = query.order_by(ReportTemplate.name).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": t.id,
|
||||||
|
"name": t.name,
|
||||||
|
"project_id": t.project_id,
|
||||||
|
"report_title": t.report_title,
|
||||||
|
"start_time": t.start_time,
|
||||||
|
"end_time": t.end_time,
|
||||||
|
"start_date": t.start_date,
|
||||||
|
"end_date": t.end_date,
|
||||||
|
"created_at": t.created_at.isoformat() if t.created_at else None,
|
||||||
|
"updated_at": t.updated_at.isoformat() if t.updated_at else None,
|
||||||
|
}
|
||||||
|
for t in templates
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
|
async def create_template(
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Create a new report template.
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
- name: Template name (required)
|
||||||
|
- project_id: Optional project ID for project-specific template
|
||||||
|
- report_title: Default report title
|
||||||
|
- start_time: Start time filter (HH:MM format)
|
||||||
|
- end_time: End time filter (HH:MM format)
|
||||||
|
- start_date: Start date filter (YYYY-MM-DD format)
|
||||||
|
- end_date: End date filter (YYYY-MM-DD format)
|
||||||
|
"""
|
||||||
|
name = data.get("name")
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Template name is required")
|
||||||
|
|
||||||
|
template = ReportTemplate(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
name=name,
|
||||||
|
project_id=data.get("project_id"),
|
||||||
|
report_title=data.get("report_title", "Background Noise Study"),
|
||||||
|
start_time=data.get("start_time"),
|
||||||
|
end_time=data.get("end_time"),
|
||||||
|
start_date=data.get("start_date"),
|
||||||
|
end_date=data.get("end_date"),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(template)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"project_id": template.project_id,
|
||||||
|
"report_title": template.report_title,
|
||||||
|
"start_time": template.start_time,
|
||||||
|
"end_time": template.end_time,
|
||||||
|
"start_date": template.start_date,
|
||||||
|
"end_date": template.end_date,
|
||||||
|
"created_at": template.created_at.isoformat() if template.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{template_id}")
|
||||||
|
async def get_template(
|
||||||
|
template_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get a specific report template by ID."""
|
||||||
|
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"project_id": template.project_id,
|
||||||
|
"report_title": template.report_title,
|
||||||
|
"start_time": template.start_time,
|
||||||
|
"end_time": template.end_time,
|
||||||
|
"start_date": template.start_date,
|
||||||
|
"end_date": template.end_date,
|
||||||
|
"created_at": template.created_at.isoformat() if template.created_at else None,
|
||||||
|
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{template_id}")
|
||||||
|
async def update_template(
|
||||||
|
template_id: str,
|
||||||
|
data: dict,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an existing report template."""
|
||||||
|
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
# Update fields if provided
|
||||||
|
if "name" in data:
|
||||||
|
template.name = data["name"]
|
||||||
|
if "project_id" in data:
|
||||||
|
template.project_id = data["project_id"]
|
||||||
|
if "report_title" in data:
|
||||||
|
template.report_title = data["report_title"]
|
||||||
|
if "start_time" in data:
|
||||||
|
template.start_time = data["start_time"]
|
||||||
|
if "end_time" in data:
|
||||||
|
template.end_time = data["end_time"]
|
||||||
|
if "start_date" in data:
|
||||||
|
template.start_date = data["start_date"]
|
||||||
|
if "end_date" in data:
|
||||||
|
template.end_date = data["end_date"]
|
||||||
|
|
||||||
|
template.updated_at = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(template)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": template.id,
|
||||||
|
"name": template.name,
|
||||||
|
"project_id": template.project_id,
|
||||||
|
"report_title": template.report_title,
|
||||||
|
"start_time": template.start_time,
|
||||||
|
"end_time": template.end_time,
|
||||||
|
"start_date": template.start_date,
|
||||||
|
"end_date": template.end_date,
|
||||||
|
"updated_at": template.updated_at.isoformat() if template.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{template_id}")
|
||||||
|
async def delete_template(
|
||||||
|
template_id: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a report template."""
|
||||||
|
template = db.query(ReportTemplate).filter_by(id=template_id).first()
|
||||||
|
if not template:
|
||||||
|
raise HTTPException(status_code=404, detail="Template not found")
|
||||||
|
|
||||||
|
db.delete(template)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return JSONResponse({"status": "success", "message": "Template deleted"})
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request, Query
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
@@ -150,6 +150,8 @@ async def add_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
|
deployment_type: str = Form(None), # "seismograph" | "slm" - what device type modem is deployed with
|
||||||
|
deployed_with_unit_id: str = Form(None), # ID of seismograph/SLM this modem is deployed with
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host: str = Form(None),
|
slm_host: str = Form(None),
|
||||||
slm_tcp_port: str = Form(None),
|
slm_tcp_port: str = Form(None),
|
||||||
@@ -209,6 +211,7 @@ async def add_roster_unit(
|
|||||||
ip_address=ip_address if ip_address else None,
|
ip_address=ip_address if ip_address else None,
|
||||||
phone_number=phone_number if phone_number else None,
|
phone_number=phone_number if phone_number else None,
|
||||||
hardware_model=hardware_model if hardware_model else None,
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
|
deployment_type=deployment_type if deployment_type else None,
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host=slm_host if slm_host else None,
|
slm_host=slm_host if slm_host else None,
|
||||||
slm_tcp_port=slm_tcp_port_int,
|
slm_tcp_port=slm_tcp_port_int,
|
||||||
@@ -219,6 +222,23 @@ async def add_roster_unit(
|
|||||||
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||||
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Auto-fill location data from modem if pairing and fields are empty
|
||||||
|
if deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
|
RosterUnit.device_type == "modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
|
if not unit.project_id and modem.project_id:
|
||||||
|
unit.project_id = modem.project_id
|
||||||
|
|
||||||
db.add(unit)
|
db.add(unit)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
@@ -259,6 +279,145 @@ def get_modems_list(db: Session = Depends(get_db)):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/modems")
|
||||||
|
def search_modems(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
deployed_only: bool = Query(False, description="Only show deployed modems"),
|
||||||
|
exclude_retired: bool = Query(True, description="Exclude retired modems"),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search modems by ID, IP address, or note. Returns HTML partial for HTMX dropdown.
|
||||||
|
|
||||||
|
Used by modem picker component to find modems to link with seismographs/SLMs.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
query = db.query(RosterUnit).filter(RosterUnit.device_type == "modem")
|
||||||
|
|
||||||
|
if deployed_only:
|
||||||
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
|
|
||||||
|
if exclude_retired:
|
||||||
|
query = query.filter(RosterUnit.retired == False)
|
||||||
|
|
||||||
|
# Search by ID, IP address, or note
|
||||||
|
if q and q.strip():
|
||||||
|
search_term = f"%{q.strip()}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
|
(RosterUnit.ip_address.ilike(search_term)) |
|
||||||
|
(RosterUnit.note.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
modems = query.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
results = []
|
||||||
|
for modem in modems:
|
||||||
|
# Build display text: ID - IP - Note (if available)
|
||||||
|
display_parts = [modem.id]
|
||||||
|
if modem.ip_address:
|
||||||
|
display_parts.append(modem.ip_address)
|
||||||
|
if modem.note:
|
||||||
|
display_parts.append(modem.note)
|
||||||
|
display = " - ".join(display_parts)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address or "",
|
||||||
|
"phone_number": modem.phone_number or "",
|
||||||
|
"note": modem.note or "",
|
||||||
|
"deployed": modem.deployed,
|
||||||
|
"display": display
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine if we should show "no results" message
|
||||||
|
show_empty = len(results) == 0 and q and q.strip()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/modem_search_results.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"modems": results,
|
||||||
|
"query": q,
|
||||||
|
"show_empty": show_empty
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/search/units")
|
||||||
|
def search_units(
|
||||||
|
request: Request,
|
||||||
|
q: str = Query("", description="Search term"),
|
||||||
|
device_type: str = Query(None, description="Filter by device type: seismograph, modem, slm"),
|
||||||
|
deployed_only: bool = Query(False, description="Only show deployed units"),
|
||||||
|
exclude_retired: bool = Query(True, description="Exclude retired units"),
|
||||||
|
limit: int = Query(10, le=50),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Search roster units by ID or note. Returns HTML partial for HTMX dropdown.
|
||||||
|
|
||||||
|
Used by unit picker component to find seismographs/SLMs to link with modems.
|
||||||
|
"""
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
query = db.query(RosterUnit)
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if device_type:
|
||||||
|
query = query.filter(RosterUnit.device_type == device_type)
|
||||||
|
|
||||||
|
if deployed_only:
|
||||||
|
query = query.filter(RosterUnit.deployed == True)
|
||||||
|
|
||||||
|
if exclude_retired:
|
||||||
|
query = query.filter(RosterUnit.retired == False)
|
||||||
|
|
||||||
|
# Search by ID or note
|
||||||
|
if q and q.strip():
|
||||||
|
search_term = f"%{q.strip()}%"
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(search_term)) |
|
||||||
|
(RosterUnit.note.ilike(search_term))
|
||||||
|
)
|
||||||
|
|
||||||
|
units = query.order_by(RosterUnit.id).limit(limit).all()
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
results = []
|
||||||
|
for unit in units:
|
||||||
|
results.append({
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"note": unit.note or "",
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"display": f"{unit.id}" + (f" - {unit.note}" if unit.note else "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# Determine if we should show "no results" message
|
||||||
|
show_empty = len(results) == 0 and q and q.strip()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/unit_search_results.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": results,
|
||||||
|
"query": q,
|
||||||
|
"show_empty": show_empty
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{unit_id}")
|
@router.get("/{unit_id}")
|
||||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""Get a single roster unit by ID"""
|
"""Get a single roster unit by ID"""
|
||||||
@@ -283,6 +442,8 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"ip_address": unit.ip_address or "",
|
"ip_address": unit.ip_address or "",
|
||||||
"phone_number": unit.phone_number or "",
|
"phone_number": unit.phone_number or "",
|
||||||
"hardware_model": unit.hardware_model or "",
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"deployment_type": unit.deployment_type or "",
|
||||||
|
"deployed_with_unit_id": unit.deployed_with_unit_id or "",
|
||||||
"slm_host": unit.slm_host or "",
|
"slm_host": unit.slm_host or "",
|
||||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||||
@@ -314,6 +475,8 @@ def edit_roster_unit(
|
|||||||
ip_address: str = Form(None),
|
ip_address: str = Form(None),
|
||||||
phone_number: str = Form(None),
|
phone_number: str = Form(None),
|
||||||
hardware_model: str = Form(None),
|
hardware_model: str = Form(None),
|
||||||
|
deployment_type: str = Form(None),
|
||||||
|
deployed_with_unit_id: str = Form(None),
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
slm_host: str = Form(None),
|
slm_host: str = Form(None),
|
||||||
slm_tcp_port: str = Form(None),
|
slm_tcp_port: str = Form(None),
|
||||||
@@ -323,6 +486,14 @@ def edit_roster_unit(
|
|||||||
slm_frequency_weighting: str = Form(None),
|
slm_frequency_weighting: str = Form(None),
|
||||||
slm_time_weighting: str = Form(None),
|
slm_time_weighting: str = Form(None),
|
||||||
slm_measurement_range: str = Form(None),
|
slm_measurement_range: str = Form(None),
|
||||||
|
# Cascade options - sync fields to paired device
|
||||||
|
cascade_to_unit_id: str = Form(None),
|
||||||
|
cascade_deployed: str = Form(None),
|
||||||
|
cascade_retired: str = Form(None),
|
||||||
|
cascade_project: str = Form(None),
|
||||||
|
cascade_location: str = Form(None),
|
||||||
|
cascade_coordinates: str = Form(None),
|
||||||
|
cascade_note: str = Form(None),
|
||||||
db: Session = Depends(get_db)
|
db: Session = Depends(get_db)
|
||||||
):
|
):
|
||||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
@@ -374,10 +545,29 @@ def edit_roster_unit(
|
|||||||
unit.next_calibration_due = next_cal_date
|
unit.next_calibration_due = next_cal_date
|
||||||
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
|
# Auto-fill location data from modem if pairing and fields are empty
|
||||||
|
if deployed_with_modem_id:
|
||||||
|
modem = db.query(RosterUnit).filter(
|
||||||
|
RosterUnit.id == deployed_with_modem_id,
|
||||||
|
RosterUnit.device_type == "modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
# Only fill if the device field is empty
|
||||||
|
if not unit.location and modem.location:
|
||||||
|
unit.location = modem.location
|
||||||
|
if not unit.address and modem.address:
|
||||||
|
unit.address = modem.address
|
||||||
|
if not unit.coordinates and modem.coordinates:
|
||||||
|
unit.coordinates = modem.coordinates
|
||||||
|
if not unit.project_id and modem.project_id:
|
||||||
|
unit.project_id = modem.project_id
|
||||||
|
|
||||||
# Modem-specific fields
|
# Modem-specific fields
|
||||||
unit.ip_address = ip_address if ip_address else None
|
unit.ip_address = ip_address if ip_address else None
|
||||||
unit.phone_number = phone_number if phone_number else None
|
unit.phone_number = phone_number if phone_number else None
|
||||||
unit.hardware_model = hardware_model if hardware_model else None
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
unit.deployment_type = deployment_type if deployment_type else None
|
||||||
|
unit.deployed_with_unit_id = deployed_with_unit_id if deployed_with_unit_id else None
|
||||||
|
|
||||||
# Sound Level Meter-specific fields
|
# Sound Level Meter-specific fields
|
||||||
unit.slm_host = slm_host if slm_host else None
|
unit.slm_host = slm_host if slm_host else None
|
||||||
@@ -403,8 +593,79 @@ 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")
|
||||||
|
|
||||||
|
# Handle cascade to paired device
|
||||||
|
cascaded_unit_id = None
|
||||||
|
if cascade_to_unit_id and cascade_to_unit_id.strip():
|
||||||
|
paired_unit = db.query(RosterUnit).filter(RosterUnit.id == cascade_to_unit_id).first()
|
||||||
|
if paired_unit:
|
||||||
|
cascaded_unit_id = paired_unit.id
|
||||||
|
|
||||||
|
# Cascade deployed status
|
||||||
|
if cascade_deployed in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_deployed = paired_unit.deployed
|
||||||
|
paired_unit.deployed = deployed_bool
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_deployed != deployed_bool:
|
||||||
|
status_text = "deployed" if deployed_bool else "benched"
|
||||||
|
old_status_text = "deployed" if old_paired_deployed else "benched"
|
||||||
|
record_history(db, paired_unit.id, "deployed_change", "deployed",
|
||||||
|
old_status_text, status_text, f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade retired status
|
||||||
|
if cascade_retired in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_retired = paired_unit.retired
|
||||||
|
paired_unit.retired = retired_bool
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_retired != retired_bool:
|
||||||
|
status_text = "retired" if retired_bool else "active"
|
||||||
|
old_status_text = "retired" if old_paired_retired else "active"
|
||||||
|
record_history(db, paired_unit.id, "retired_change", "retired",
|
||||||
|
old_status_text, status_text, f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade project
|
||||||
|
if cascade_project in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_project = paired_unit.project_id
|
||||||
|
paired_unit.project_id = project_id
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_project != project_id:
|
||||||
|
record_history(db, paired_unit.id, "project_change", "project_id",
|
||||||
|
old_paired_project or "", project_id or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade address/location
|
||||||
|
if cascade_location in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_address = paired_unit.address
|
||||||
|
old_paired_location = paired_unit.location
|
||||||
|
paired_unit.address = address
|
||||||
|
paired_unit.location = location
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_address != address:
|
||||||
|
record_history(db, paired_unit.id, "address_change", "address",
|
||||||
|
old_paired_address or "", address or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade coordinates
|
||||||
|
if cascade_coordinates in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_coords = paired_unit.coordinates
|
||||||
|
paired_unit.coordinates = coordinates
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_coords != coordinates:
|
||||||
|
record_history(db, paired_unit.id, "coordinates_change", "coordinates",
|
||||||
|
old_paired_coords or "", coordinates or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
|
# Cascade note
|
||||||
|
if cascade_note in ['true', 'True', '1', 'yes']:
|
||||||
|
old_paired_note = paired_unit.note
|
||||||
|
paired_unit.note = note
|
||||||
|
paired_unit.last_updated = datetime.utcnow()
|
||||||
|
if old_paired_note != note:
|
||||||
|
record_history(db, paired_unit.id, "note_change", "note",
|
||||||
|
old_paired_note or "", note or "", f"cascade from {unit_id}")
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
|
||||||
|
response = {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
if cascaded_unit_id:
|
||||||
|
response["cascaded_to"] = cascaded_unit_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@router.post("/set-deployed/{unit_id}")
|
@router.post("/set-deployed/{unit_id}")
|
||||||
@@ -531,6 +792,37 @@ def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
|||||||
return {"message": "Updated", "id": unit_id, "note": note}
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_bool(value: str) -> bool:
|
||||||
|
"""Parse boolean from CSV string value."""
|
||||||
|
return value.lower() in ('true', '1', 'yes') if value else False
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_int(value: str) -> int | None:
|
||||||
|
"""Parse integer from CSV string value, return None if empty or invalid."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value.strip())
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(value: str) -> date | None:
|
||||||
|
"""Parse date from CSV string value (YYYY-MM-DD format)."""
|
||||||
|
if not value or not value.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(value.strip(), '%Y-%m-%d').date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_csv_value(row: dict, key: str, default=None):
|
||||||
|
"""Get value from CSV row, return default if empty."""
|
||||||
|
value = row.get(key, '').strip() if row.get(key) else ''
|
||||||
|
return value if value else default
|
||||||
|
|
||||||
|
|
||||||
@router.post("/import-csv")
|
@router.post("/import-csv")
|
||||||
async def import_csv(
|
async def import_csv(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -541,13 +833,40 @@ async def import_csv(
|
|||||||
Import roster units from CSV file.
|
Import roster units from CSV file.
|
||||||
|
|
||||||
Expected CSV columns (unit_id is required, others are optional):
|
Expected CSV columns (unit_id is required, others are optional):
|
||||||
- unit_id: Unique identifier for the unit
|
|
||||||
- unit_type: Type of unit (default: "series3")
|
Common fields (all device types):
|
||||||
- deployed: Boolean for deployment status (default: False)
|
- unit_id: Unique identifier for the unit (REQUIRED)
|
||||||
- retired: Boolean for retirement status (default: False)
|
- device_type: "seismograph", "modem", or "slm" (default: "seismograph")
|
||||||
|
- unit_type: Sub-type (e.g., "series3", "series4" for seismographs)
|
||||||
|
- deployed: Boolean (true/false/yes/no/1/0)
|
||||||
|
- retired: Boolean
|
||||||
- note: Notes about the unit
|
- note: Notes about the unit
|
||||||
- project_id: Project identifier
|
- project_id: Project identifier
|
||||||
- location: Location description
|
- location: Location description
|
||||||
|
- address: Street address
|
||||||
|
- coordinates: GPS coordinates (lat;lon or lat,lon)
|
||||||
|
|
||||||
|
Seismograph-specific:
|
||||||
|
- last_calibrated: Date (YYYY-MM-DD)
|
||||||
|
- next_calibration_due: Date (YYYY-MM-DD)
|
||||||
|
- deployed_with_modem_id: ID of paired modem
|
||||||
|
|
||||||
|
Modem-specific:
|
||||||
|
- ip_address: Device IP address
|
||||||
|
- phone_number: SIM card phone number
|
||||||
|
- hardware_model: Hardware model (e.g., IBR900, RV55)
|
||||||
|
|
||||||
|
SLM-specific:
|
||||||
|
- slm_host: Device IP or hostname
|
||||||
|
- slm_tcp_port: TCP control port (default 2255)
|
||||||
|
- slm_ftp_port: FTP port (default 21)
|
||||||
|
- slm_model: Device model (NL-43, NL-53)
|
||||||
|
- slm_serial_number: Serial number
|
||||||
|
- slm_frequency_weighting: A, C, or Z
|
||||||
|
- slm_time_weighting: F (Fast), S (Slow), I (Impulse)
|
||||||
|
- slm_measurement_range: e.g., "30-130 dB"
|
||||||
|
|
||||||
|
Lines starting with # are treated as comments and skipped.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
file: CSV file upload
|
file: CSV file upload
|
||||||
@@ -560,6 +879,46 @@ async def import_csv(
|
|||||||
# Read file content
|
# Read file content
|
||||||
contents = await file.read()
|
contents = await file.read()
|
||||||
csv_text = contents.decode('utf-8')
|
csv_text = contents.decode('utf-8')
|
||||||
|
|
||||||
|
# Filter out comment lines (starting with #)
|
||||||
|
lines = csv_text.split('\n')
|
||||||
|
filtered_lines = [line for line in lines if not line.strip().startswith('#')]
|
||||||
|
csv_text = '\n'.join(filtered_lines)
|
||||||
|
|
||||||
|
# First pass: validate for duplicates and empty unit_ids
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
seen_unit_ids = {} # unit_id -> list of row numbers
|
||||||
|
empty_unit_id_rows = []
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2):
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
empty_unit_id_rows.append(row_num)
|
||||||
|
else:
|
||||||
|
if unit_id not in seen_unit_ids:
|
||||||
|
seen_unit_ids[unit_id] = []
|
||||||
|
seen_unit_ids[unit_id].append(row_num)
|
||||||
|
|
||||||
|
# Check for validation errors
|
||||||
|
validation_errors = []
|
||||||
|
|
||||||
|
# Report empty unit_ids
|
||||||
|
if empty_unit_id_rows:
|
||||||
|
validation_errors.append(f"Empty unit_id on row(s): {', '.join(map(str, empty_unit_id_rows))}")
|
||||||
|
|
||||||
|
# Report duplicates
|
||||||
|
duplicates = {uid: rows for uid, rows in seen_unit_ids.items() if len(rows) > 1}
|
||||||
|
if duplicates:
|
||||||
|
for uid, rows in duplicates.items():
|
||||||
|
validation_errors.append(f"Duplicate unit_id '{uid}' on rows: {', '.join(map(str, rows))}")
|
||||||
|
|
||||||
|
if validation_errors:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="CSV validation failed:\n" + "\n".join(validation_errors)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Second pass: actually import the data
|
||||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
results = {
|
results = {
|
||||||
@@ -580,6 +939,9 @@ async def import_csv(
|
|||||||
})
|
})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Determine device type
|
||||||
|
device_type = _get_csv_value(row, 'device_type', 'seismograph')
|
||||||
|
|
||||||
# Check if unit exists
|
# Check if unit exists
|
||||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
@@ -588,31 +950,90 @@ async def import_csv(
|
|||||||
results["skipped"].append(unit_id)
|
results["skipped"].append(unit_id)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Update existing unit
|
# Update existing unit - common fields
|
||||||
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
existing_unit.device_type = device_type
|
||||||
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
existing_unit.unit_type = _get_csv_value(row, 'unit_type', existing_unit.unit_type or 'series3')
|
||||||
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
existing_unit.deployed = _parse_bool(row.get('deployed', '')) if row.get('deployed') else existing_unit.deployed
|
||||||
existing_unit.note = row.get('note', existing_unit.note or '')
|
existing_unit.retired = _parse_bool(row.get('retired', '')) if row.get('retired') else existing_unit.retired
|
||||||
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
existing_unit.note = _get_csv_value(row, 'note', existing_unit.note)
|
||||||
existing_unit.location = row.get('location', existing_unit.location)
|
existing_unit.project_id = _get_csv_value(row, 'project_id', existing_unit.project_id)
|
||||||
existing_unit.address = row.get('address', existing_unit.address)
|
existing_unit.location = _get_csv_value(row, 'location', existing_unit.location)
|
||||||
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
existing_unit.address = _get_csv_value(row, 'address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = _get_csv_value(row, 'coordinates', existing_unit.coordinates)
|
||||||
existing_unit.last_updated = datetime.utcnow()
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Seismograph-specific fields
|
||||||
|
if row.get('last_calibrated'):
|
||||||
|
existing_unit.last_calibrated = _parse_date(row.get('last_calibrated'))
|
||||||
|
if row.get('next_calibration_due'):
|
||||||
|
existing_unit.next_calibration_due = _parse_date(row.get('next_calibration_due'))
|
||||||
|
if row.get('deployed_with_modem_id'):
|
||||||
|
existing_unit.deployed_with_modem_id = _get_csv_value(row, 'deployed_with_modem_id')
|
||||||
|
|
||||||
|
# Modem-specific fields
|
||||||
|
if row.get('ip_address'):
|
||||||
|
existing_unit.ip_address = _get_csv_value(row, 'ip_address')
|
||||||
|
if row.get('phone_number'):
|
||||||
|
existing_unit.phone_number = _get_csv_value(row, 'phone_number')
|
||||||
|
if row.get('hardware_model'):
|
||||||
|
existing_unit.hardware_model = _get_csv_value(row, 'hardware_model')
|
||||||
|
if row.get('deployment_type'):
|
||||||
|
existing_unit.deployment_type = _get_csv_value(row, 'deployment_type')
|
||||||
|
if row.get('deployed_with_unit_id'):
|
||||||
|
existing_unit.deployed_with_unit_id = _get_csv_value(row, 'deployed_with_unit_id')
|
||||||
|
|
||||||
|
# SLM-specific fields
|
||||||
|
if row.get('slm_host'):
|
||||||
|
existing_unit.slm_host = _get_csv_value(row, 'slm_host')
|
||||||
|
if row.get('slm_tcp_port'):
|
||||||
|
existing_unit.slm_tcp_port = _parse_int(row.get('slm_tcp_port'))
|
||||||
|
if row.get('slm_ftp_port'):
|
||||||
|
existing_unit.slm_ftp_port = _parse_int(row.get('slm_ftp_port'))
|
||||||
|
if row.get('slm_model'):
|
||||||
|
existing_unit.slm_model = _get_csv_value(row, 'slm_model')
|
||||||
|
if row.get('slm_serial_number'):
|
||||||
|
existing_unit.slm_serial_number = _get_csv_value(row, 'slm_serial_number')
|
||||||
|
if row.get('slm_frequency_weighting'):
|
||||||
|
existing_unit.slm_frequency_weighting = _get_csv_value(row, 'slm_frequency_weighting')
|
||||||
|
if row.get('slm_time_weighting'):
|
||||||
|
existing_unit.slm_time_weighting = _get_csv_value(row, 'slm_time_weighting')
|
||||||
|
if row.get('slm_measurement_range'):
|
||||||
|
existing_unit.slm_measurement_range = _get_csv_value(row, 'slm_measurement_range')
|
||||||
|
|
||||||
results["updated"].append(unit_id)
|
results["updated"].append(unit_id)
|
||||||
else:
|
else:
|
||||||
# Create new unit
|
# Create new unit with all fields
|
||||||
new_unit = RosterUnit(
|
new_unit = RosterUnit(
|
||||||
id=unit_id,
|
id=unit_id,
|
||||||
unit_type=row.get('unit_type', 'series3'),
|
device_type=device_type,
|
||||||
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
unit_type=_get_csv_value(row, 'unit_type', 'series3'),
|
||||||
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
deployed=_parse_bool(row.get('deployed', '')),
|
||||||
note=row.get('note', ''),
|
retired=_parse_bool(row.get('retired', '')),
|
||||||
project_id=row.get('project_id'),
|
note=_get_csv_value(row, 'note', ''),
|
||||||
location=row.get('location'),
|
project_id=_get_csv_value(row, 'project_id'),
|
||||||
address=row.get('address'),
|
location=_get_csv_value(row, 'location'),
|
||||||
coordinates=row.get('coordinates'),
|
address=_get_csv_value(row, 'address'),
|
||||||
last_updated=datetime.utcnow()
|
coordinates=_get_csv_value(row, 'coordinates'),
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
# Seismograph fields
|
||||||
|
last_calibrated=_parse_date(row.get('last_calibrated', '')),
|
||||||
|
next_calibration_due=_parse_date(row.get('next_calibration_due', '')),
|
||||||
|
deployed_with_modem_id=_get_csv_value(row, 'deployed_with_modem_id'),
|
||||||
|
# Modem fields
|
||||||
|
ip_address=_get_csv_value(row, 'ip_address'),
|
||||||
|
phone_number=_get_csv_value(row, 'phone_number'),
|
||||||
|
hardware_model=_get_csv_value(row, 'hardware_model'),
|
||||||
|
deployment_type=_get_csv_value(row, 'deployment_type'),
|
||||||
|
deployed_with_unit_id=_get_csv_value(row, 'deployed_with_unit_id'),
|
||||||
|
# SLM fields
|
||||||
|
slm_host=_get_csv_value(row, 'slm_host'),
|
||||||
|
slm_tcp_port=_parse_int(row.get('slm_tcp_port', '')),
|
||||||
|
slm_ftp_port=_parse_int(row.get('slm_ftp_port', '')),
|
||||||
|
slm_model=_get_csv_value(row, 'slm_model'),
|
||||||
|
slm_serial_number=_get_csv_value(row, 'slm_serial_number'),
|
||||||
|
slm_frequency_weighting=_get_csv_value(row, 'slm_frequency_weighting'),
|
||||||
|
slm_time_weighting=_get_csv_value(row, 'slm_time_weighting'),
|
||||||
|
slm_measurement_range=_get_csv_value(row, 'slm_measurement_range'),
|
||||||
)
|
)
|
||||||
db.add(new_unit)
|
db.add(new_unit)
|
||||||
results["added"].append(unit_id)
|
results["added"].append(unit_id)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Handles scheduled actions for automated recording control.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_, or_
|
from sqlalchemy import and_, or_
|
||||||
@@ -23,9 +22,9 @@ from backend.models import (
|
|||||||
RosterUnit,
|
RosterUnit,
|
||||||
)
|
)
|
||||||
from backend.services.scheduler import get_scheduler
|
from backend.services.scheduler import get_scheduler
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"])
|
router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -5,13 +5,12 @@ Provides endpoints for the seismograph-specific dashboard
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_class=HTMLResponse)
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ Provides API endpoints for the Sound Level Meters dashboard page.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
@@ -18,11 +17,11 @@ import os
|
|||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
# SLMM backend URL - configurable via environment variable
|
# SLMM backend URL - configurable via environment variable
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
|||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import httpx
|
import httpx
|
||||||
@@ -15,11 +14,11 @@ import os
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit
|
from backend.models import RosterUnit
|
||||||
|
from backend.templates_config import templates
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||||
templates = Jinja2Templates(directory="templates")
|
|
||||||
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||||
|
|
||||||
|
|||||||
462
backend/services/alert_service.py
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
"""
|
||||||
|
Alert Service
|
||||||
|
|
||||||
|
Manages in-app alerts for device status changes and system events.
|
||||||
|
Provides foundation for future notification channels (email, webhook).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
|
||||||
|
from backend.models import Alert, RosterUnit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AlertService:
|
||||||
|
"""
|
||||||
|
Service for managing alerts.
|
||||||
|
|
||||||
|
Handles alert lifecycle:
|
||||||
|
- Create alerts from various triggers
|
||||||
|
- Query active alerts
|
||||||
|
- Acknowledge/resolve/dismiss alerts
|
||||||
|
- (Future) Dispatch to notification channels
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_alert(
|
||||||
|
self,
|
||||||
|
alert_type: str,
|
||||||
|
title: str,
|
||||||
|
message: str = None,
|
||||||
|
severity: str = "warning",
|
||||||
|
unit_id: str = None,
|
||||||
|
project_id: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
schedule_id: str = None,
|
||||||
|
metadata: dict = None,
|
||||||
|
expires_hours: int = 24,
|
||||||
|
) -> Alert:
|
||||||
|
"""
|
||||||
|
Create a new alert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert_type: Type of alert (device_offline, device_online, schedule_failed)
|
||||||
|
title: Short alert title
|
||||||
|
message: Detailed description
|
||||||
|
severity: info, warning, or critical
|
||||||
|
unit_id: Related unit ID (optional)
|
||||||
|
project_id: Related project ID (optional)
|
||||||
|
location_id: Related location ID (optional)
|
||||||
|
schedule_id: Related schedule ID (optional)
|
||||||
|
metadata: Additional JSON data
|
||||||
|
expires_hours: Hours until auto-expiry (default 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Alert instance
|
||||||
|
"""
|
||||||
|
alert = Alert(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
alert_type=alert_type,
|
||||||
|
title=title,
|
||||||
|
message=message,
|
||||||
|
severity=severity,
|
||||||
|
unit_id=unit_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
alert_metadata=json.dumps(metadata) if metadata else None,
|
||||||
|
status="active",
|
||||||
|
expires_at=datetime.utcnow() + timedelta(hours=expires_hours),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.db.add(alert)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(alert)
|
||||||
|
|
||||||
|
logger.info(f"Created alert: {alert.title} ({alert.alert_type})")
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def create_device_offline_alert(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
consecutive_failures: int = 0,
|
||||||
|
last_error: str = None,
|
||||||
|
) -> Optional[Alert]:
|
||||||
|
"""
|
||||||
|
Create alert when device becomes unreachable.
|
||||||
|
|
||||||
|
Only creates if no active offline alert exists for this device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: The unit that went offline
|
||||||
|
consecutive_failures: Number of consecutive poll failures
|
||||||
|
last_error: Last error message from polling
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Alert or None if alert already exists
|
||||||
|
"""
|
||||||
|
# Check if active offline alert already exists
|
||||||
|
existing = self.db.query(Alert).filter(
|
||||||
|
and_(
|
||||||
|
Alert.unit_id == unit_id,
|
||||||
|
Alert.alert_type == "device_offline",
|
||||||
|
Alert.status == "active",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
logger.debug(f"Offline alert already exists for {unit_id}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Get unit info for title
|
||||||
|
unit = self.db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
unit_name = unit.id if unit else unit_id
|
||||||
|
|
||||||
|
# Determine severity based on failure count
|
||||||
|
severity = "critical" if consecutive_failures >= 5 else "warning"
|
||||||
|
|
||||||
|
return self.create_alert(
|
||||||
|
alert_type="device_offline",
|
||||||
|
title=f"{unit_name} is offline",
|
||||||
|
message=f"Device has been unreachable after {consecutive_failures} failed connection attempts."
|
||||||
|
+ (f" Last error: {last_error}" if last_error else ""),
|
||||||
|
severity=severity,
|
||||||
|
unit_id=unit_id,
|
||||||
|
metadata={
|
||||||
|
"consecutive_failures": consecutive_failures,
|
||||||
|
"last_error": last_error,
|
||||||
|
},
|
||||||
|
expires_hours=48, # Offline alerts stay longer
|
||||||
|
)
|
||||||
|
|
||||||
|
def resolve_device_offline_alert(self, unit_id: str) -> Optional[Alert]:
|
||||||
|
"""
|
||||||
|
Auto-resolve offline alert when device comes back online.
|
||||||
|
|
||||||
|
Also creates an "device_online" info alert to notify user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: The unit that came back online
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The resolved Alert or None if no alert existed
|
||||||
|
"""
|
||||||
|
# Find active offline alert
|
||||||
|
alert = self.db.query(Alert).filter(
|
||||||
|
and_(
|
||||||
|
Alert.unit_id == unit_id,
|
||||||
|
Alert.alert_type == "device_offline",
|
||||||
|
Alert.status == "active",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not alert:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Resolve the offline alert
|
||||||
|
alert.status = "resolved"
|
||||||
|
alert.resolved_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Resolved offline alert for {unit_id}")
|
||||||
|
|
||||||
|
# Create online notification
|
||||||
|
unit = self.db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
|
unit_name = unit.id if unit else unit_id
|
||||||
|
|
||||||
|
self.create_alert(
|
||||||
|
alert_type="device_online",
|
||||||
|
title=f"{unit_name} is back online",
|
||||||
|
message="Device connection has been restored.",
|
||||||
|
severity="info",
|
||||||
|
unit_id=unit_id,
|
||||||
|
expires_hours=6, # Info alerts expire quickly
|
||||||
|
)
|
||||||
|
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def create_schedule_failed_alert(
|
||||||
|
self,
|
||||||
|
schedule_id: str,
|
||||||
|
action_type: str,
|
||||||
|
unit_id: str = None,
|
||||||
|
error_message: str = None,
|
||||||
|
project_id: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
) -> Alert:
|
||||||
|
"""
|
||||||
|
Create alert when a scheduled action fails.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: The ScheduledAction or RecurringSchedule ID
|
||||||
|
action_type: start, stop, download
|
||||||
|
unit_id: Related unit
|
||||||
|
error_message: Error from execution
|
||||||
|
project_id: Related project
|
||||||
|
location_id: Related location
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Alert
|
||||||
|
"""
|
||||||
|
return self.create_alert(
|
||||||
|
alert_type="schedule_failed",
|
||||||
|
title=f"Scheduled {action_type} failed",
|
||||||
|
message=error_message or f"The scheduled {action_type} action did not complete successfully.",
|
||||||
|
severity="warning",
|
||||||
|
unit_id=unit_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
metadata={"action_type": action_type},
|
||||||
|
expires_hours=24,
|
||||||
|
)
|
||||||
|
|
||||||
|
def create_schedule_completed_alert(
|
||||||
|
self,
|
||||||
|
schedule_id: str,
|
||||||
|
action_type: str,
|
||||||
|
unit_id: str = None,
|
||||||
|
project_id: str = None,
|
||||||
|
location_id: str = None,
|
||||||
|
metadata: dict = None,
|
||||||
|
) -> Alert:
|
||||||
|
"""
|
||||||
|
Create alert when a scheduled action completes successfully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: The ScheduledAction ID
|
||||||
|
action_type: start, stop, download
|
||||||
|
unit_id: Related unit
|
||||||
|
project_id: Related project
|
||||||
|
location_id: Related location
|
||||||
|
metadata: Additional info (e.g., downloaded folder, index numbers)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created Alert
|
||||||
|
"""
|
||||||
|
# Build descriptive message based on action type and metadata
|
||||||
|
if action_type == "stop" and metadata:
|
||||||
|
download_folder = metadata.get("downloaded_folder")
|
||||||
|
download_success = metadata.get("download_success", False)
|
||||||
|
if download_success and download_folder:
|
||||||
|
message = f"Measurement stopped and data downloaded ({download_folder})"
|
||||||
|
elif download_success is False and metadata.get("download_attempted"):
|
||||||
|
message = "Measurement stopped but download failed"
|
||||||
|
else:
|
||||||
|
message = "Measurement stopped successfully"
|
||||||
|
elif action_type == "start" and metadata:
|
||||||
|
new_index = metadata.get("new_index")
|
||||||
|
if new_index is not None:
|
||||||
|
message = f"Measurement started (index {new_index:04d})"
|
||||||
|
else:
|
||||||
|
message = "Measurement started successfully"
|
||||||
|
else:
|
||||||
|
message = f"Scheduled {action_type} completed successfully"
|
||||||
|
|
||||||
|
return self.create_alert(
|
||||||
|
alert_type="schedule_completed",
|
||||||
|
title=f"Scheduled {action_type} completed",
|
||||||
|
message=message,
|
||||||
|
severity="info",
|
||||||
|
unit_id=unit_id,
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
schedule_id=schedule_id,
|
||||||
|
metadata={"action_type": action_type, **(metadata or {})},
|
||||||
|
expires_hours=12, # Info alerts expire quickly
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_active_alerts(
|
||||||
|
self,
|
||||||
|
project_id: str = None,
|
||||||
|
unit_id: str = None,
|
||||||
|
alert_type: str = None,
|
||||||
|
min_severity: str = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> List[Alert]:
|
||||||
|
"""
|
||||||
|
Query active alerts with optional filters.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: Filter by project
|
||||||
|
unit_id: Filter by unit
|
||||||
|
alert_type: Filter by alert type
|
||||||
|
min_severity: Minimum severity (info, warning, critical)
|
||||||
|
limit: Maximum results
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching alerts
|
||||||
|
"""
|
||||||
|
query = self.db.query(Alert).filter(Alert.status == "active")
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Alert.project_id == project_id)
|
||||||
|
|
||||||
|
if unit_id:
|
||||||
|
query = query.filter(Alert.unit_id == unit_id)
|
||||||
|
|
||||||
|
if alert_type:
|
||||||
|
query = query.filter(Alert.alert_type == alert_type)
|
||||||
|
|
||||||
|
if min_severity:
|
||||||
|
# Map severity to numeric for comparison
|
||||||
|
severity_levels = {"info": 1, "warning": 2, "critical": 3}
|
||||||
|
min_level = severity_levels.get(min_severity, 1)
|
||||||
|
|
||||||
|
if min_level == 2:
|
||||||
|
query = query.filter(Alert.severity.in_(["warning", "critical"]))
|
||||||
|
elif min_level == 3:
|
||||||
|
query = query.filter(Alert.severity == "critical")
|
||||||
|
|
||||||
|
return query.order_by(Alert.created_at.desc()).limit(limit).all()
|
||||||
|
|
||||||
|
def get_all_alerts(
|
||||||
|
self,
|
||||||
|
status: str = None,
|
||||||
|
project_id: str = None,
|
||||||
|
unit_id: str = None,
|
||||||
|
alert_type: str = None,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
) -> List[Alert]:
|
||||||
|
"""
|
||||||
|
Query all alerts with optional filters (includes non-active).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: Filter by status (active, acknowledged, resolved, dismissed)
|
||||||
|
project_id: Filter by project
|
||||||
|
unit_id: Filter by unit
|
||||||
|
alert_type: Filter by alert type
|
||||||
|
limit: Maximum results
|
||||||
|
offset: Pagination offset
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of matching alerts
|
||||||
|
"""
|
||||||
|
query = self.db.query(Alert)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(Alert.status == status)
|
||||||
|
|
||||||
|
if project_id:
|
||||||
|
query = query.filter(Alert.project_id == project_id)
|
||||||
|
|
||||||
|
if unit_id:
|
||||||
|
query = query.filter(Alert.unit_id == unit_id)
|
||||||
|
|
||||||
|
if alert_type:
|
||||||
|
query = query.filter(Alert.alert_type == alert_type)
|
||||||
|
|
||||||
|
return (
|
||||||
|
query.order_by(Alert.created_at.desc())
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_active_alert_count(self) -> int:
|
||||||
|
"""Get count of active alerts for badge display."""
|
||||||
|
return self.db.query(Alert).filter(Alert.status == "active").count()
|
||||||
|
|
||||||
|
def acknowledge_alert(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
"""
|
||||||
|
Mark alert as acknowledged.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert_id: Alert to acknowledge
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Alert or None if not found
|
||||||
|
"""
|
||||||
|
alert = self.db.query(Alert).filter_by(id=alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
return None
|
||||||
|
|
||||||
|
alert.status = "acknowledged"
|
||||||
|
alert.acknowledged_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Acknowledged alert: {alert.title}")
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def dismiss_alert(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
"""
|
||||||
|
Dismiss alert (user chose to ignore).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert_id: Alert to dismiss
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Alert or None if not found
|
||||||
|
"""
|
||||||
|
alert = self.db.query(Alert).filter_by(id=alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
return None
|
||||||
|
|
||||||
|
alert.status = "dismissed"
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Dismissed alert: {alert.title}")
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def resolve_alert(self, alert_id: str) -> Optional[Alert]:
|
||||||
|
"""
|
||||||
|
Manually resolve an alert.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
alert_id: Alert to resolve
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated Alert or None if not found
|
||||||
|
"""
|
||||||
|
alert = self.db.query(Alert).filter_by(id=alert_id).first()
|
||||||
|
if not alert:
|
||||||
|
return None
|
||||||
|
|
||||||
|
alert.status = "resolved"
|
||||||
|
alert.resolved_at = datetime.utcnow()
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Resolved alert: {alert.title}")
|
||||||
|
return alert
|
||||||
|
|
||||||
|
def cleanup_expired_alerts(self) -> int:
|
||||||
|
"""
|
||||||
|
Remove alerts past their expiration time.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of alerts cleaned up
|
||||||
|
"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
expired = self.db.query(Alert).filter(
|
||||||
|
and_(
|
||||||
|
Alert.expires_at.isnot(None),
|
||||||
|
Alert.expires_at < now,
|
||||||
|
Alert.status == "active",
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
count = len(expired)
|
||||||
|
for alert in expired:
|
||||||
|
alert.status = "dismissed"
|
||||||
|
|
||||||
|
if count > 0:
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Cleaned up {count} expired alerts")
|
||||||
|
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
def get_alert_service(db: Session) -> AlertService:
|
||||||
|
"""Get an AlertService instance with the given database session."""
|
||||||
|
return AlertService(db)
|
||||||
@@ -333,6 +333,157 @@ class DeviceController:
|
|||||||
else:
|
else:
|
||||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Store/Index Management
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def increment_index(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Increment the store/index number on a device.
|
||||||
|
|
||||||
|
For SLMs, this increments the store name to prevent "overwrite data?" prompts.
|
||||||
|
Should be called before starting a new measurement if auto_increment_index is enabled.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with old_index and new_index
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.increment_index(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
# Seismographs may not have the same concept of store index
|
||||||
|
return {
|
||||||
|
"status": "not_applicable",
|
||||||
|
"message": "Index increment not applicable for seismographs",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
async def get_index_number(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get current store/index number from device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict with current index_number
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.get_index_number(unit_id)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_applicable",
|
||||||
|
"message": "Index number not applicable for seismographs",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Cycle Commands (for scheduled automation)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def start_cycle(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
sync_clock: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute complete start cycle for scheduled automation.
|
||||||
|
|
||||||
|
This handles the full pre-recording workflow:
|
||||||
|
1. Sync device clock to server time
|
||||||
|
2. Find next safe index (with overwrite protection)
|
||||||
|
3. Start measurement
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
sync_clock: Whether to sync device clock to server time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict from device module
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.start_cycle(unit_id, sync_clock)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph start cycle not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
|
async def stop_cycle(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str,
|
||||||
|
download: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute complete stop cycle for scheduled automation.
|
||||||
|
|
||||||
|
This handles the full post-recording workflow:
|
||||||
|
1. Stop measurement
|
||||||
|
2. Enable FTP
|
||||||
|
3. Download measurement folder
|
||||||
|
4. Verify download
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
device_type: "slm" | "seismograph"
|
||||||
|
download: Whether to download measurement data
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Response dict from device module
|
||||||
|
"""
|
||||||
|
if device_type == "slm":
|
||||||
|
try:
|
||||||
|
return await self.slmm_client.stop_cycle(unit_id, download)
|
||||||
|
except SLMMClientError as e:
|
||||||
|
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||||
|
|
||||||
|
elif device_type == "seismograph":
|
||||||
|
return {
|
||||||
|
"status": "not_implemented",
|
||||||
|
"message": "Seismograph stop cycle not yet implemented",
|
||||||
|
"unit_id": unit_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Health Check
|
# Health Check
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
184
backend/services/device_status_monitor.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
"""
|
||||||
|
Device Status Monitor
|
||||||
|
|
||||||
|
Background task that monitors device reachability via SLMM polling status
|
||||||
|
and triggers alerts when devices go offline or come back online.
|
||||||
|
|
||||||
|
This service bridges SLMM's device polling with Terra-View's alert system.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, Dict
|
||||||
|
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.services.slmm_client import get_slmm_client, SLMMClientError
|
||||||
|
from backend.services.alert_service import get_alert_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceStatusMonitor:
|
||||||
|
"""
|
||||||
|
Monitors device reachability via SLMM's polling status endpoint.
|
||||||
|
|
||||||
|
Detects state transitions (online→offline, offline→online) and
|
||||||
|
triggers AlertService to create/resolve alerts.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
monitor = DeviceStatusMonitor()
|
||||||
|
await monitor.start() # Start background monitoring
|
||||||
|
monitor.stop() # Stop monitoring
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, check_interval: int = 60):
|
||||||
|
"""
|
||||||
|
Initialize the monitor.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
check_interval: Seconds between status checks (default: 60)
|
||||||
|
"""
|
||||||
|
self.check_interval = check_interval
|
||||||
|
self.running = False
|
||||||
|
self.task: Optional[asyncio.Task] = None
|
||||||
|
self.slmm_client = get_slmm_client()
|
||||||
|
|
||||||
|
# Track previous device states to detect transitions
|
||||||
|
self._device_states: Dict[str, bool] = {}
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start the monitoring background task."""
|
||||||
|
if self.running:
|
||||||
|
logger.warning("DeviceStatusMonitor is already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
self.task = asyncio.create_task(self._monitor_loop())
|
||||||
|
logger.info(f"DeviceStatusMonitor started (checking every {self.check_interval}s)")
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the monitoring background task."""
|
||||||
|
self.running = False
|
||||||
|
if self.task:
|
||||||
|
self.task.cancel()
|
||||||
|
logger.info("DeviceStatusMonitor stopped")
|
||||||
|
|
||||||
|
async def _monitor_loop(self):
|
||||||
|
"""Main monitoring loop."""
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self._check_all_devices()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in device status monitor: {e}", exc_info=True)
|
||||||
|
|
||||||
|
# Sleep in small intervals for graceful shutdown
|
||||||
|
for _ in range(self.check_interval):
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
logger.info("DeviceStatusMonitor loop exited")
|
||||||
|
|
||||||
|
async def _check_all_devices(self):
|
||||||
|
"""
|
||||||
|
Fetch polling status from SLMM and detect state transitions.
|
||||||
|
|
||||||
|
Uses GET /api/slmm/_polling/status (proxied to SLMM)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Get status from SLMM
|
||||||
|
status_response = await self.slmm_client.get_polling_status()
|
||||||
|
devices = status_response.get("devices", [])
|
||||||
|
|
||||||
|
if not devices:
|
||||||
|
logger.debug("No devices in polling status response")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
unit_id = device.get("unit_id")
|
||||||
|
if not unit_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_reachable = device.get("is_reachable", True)
|
||||||
|
previous_reachable = self._device_states.get(unit_id)
|
||||||
|
|
||||||
|
# Skip if this is the first check (no previous state)
|
||||||
|
if previous_reachable is None:
|
||||||
|
self._device_states[unit_id] = is_reachable
|
||||||
|
logger.debug(f"Initial state for {unit_id}: reachable={is_reachable}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Detect offline transition (was online, now offline)
|
||||||
|
if previous_reachable and not is_reachable:
|
||||||
|
logger.warning(f"Device {unit_id} went OFFLINE")
|
||||||
|
alert_service.create_device_offline_alert(
|
||||||
|
unit_id=unit_id,
|
||||||
|
consecutive_failures=device.get("consecutive_failures", 0),
|
||||||
|
last_error=device.get("last_error"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Detect online transition (was offline, now online)
|
||||||
|
elif not previous_reachable and is_reachable:
|
||||||
|
logger.info(f"Device {unit_id} came back ONLINE")
|
||||||
|
alert_service.resolve_device_offline_alert(unit_id)
|
||||||
|
|
||||||
|
# Update tracked state
|
||||||
|
self._device_states[unit_id] = is_reachable
|
||||||
|
|
||||||
|
# Cleanup expired alerts while we're here
|
||||||
|
alert_service.cleanup_expired_alerts()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
except SLMMClientError as e:
|
||||||
|
logger.warning(f"Could not reach SLMM for status check: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error checking device status: {e}", exc_info=True)
|
||||||
|
|
||||||
|
def get_tracked_devices(self) -> Dict[str, bool]:
|
||||||
|
"""
|
||||||
|
Get the current tracked device states.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict mapping unit_id to is_reachable status
|
||||||
|
"""
|
||||||
|
return dict(self._device_states)
|
||||||
|
|
||||||
|
def clear_tracked_devices(self):
|
||||||
|
"""Clear all tracked device states (useful for testing)."""
|
||||||
|
self._device_states.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# Singleton instance
|
||||||
|
_monitor_instance: Optional[DeviceStatusMonitor] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_device_status_monitor() -> DeviceStatusMonitor:
|
||||||
|
"""
|
||||||
|
Get the device status monitor singleton instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
DeviceStatusMonitor instance
|
||||||
|
"""
|
||||||
|
global _monitor_instance
|
||||||
|
if _monitor_instance is None:
|
||||||
|
_monitor_instance = DeviceStatusMonitor()
|
||||||
|
return _monitor_instance
|
||||||
|
|
||||||
|
|
||||||
|
async def start_device_status_monitor():
|
||||||
|
"""Start the global device status monitor."""
|
||||||
|
monitor = get_device_status_monitor()
|
||||||
|
await monitor.start()
|
||||||
|
|
||||||
|
|
||||||
|
def stop_device_status_monitor():
|
||||||
|
"""Stop the global device status monitor."""
|
||||||
|
monitor = get_device_status_monitor()
|
||||||
|
monitor.stop()
|
||||||
559
backend/services/recurring_schedule_service.py
Normal file
@@ -0,0 +1,559 @@
|
|||||||
|
"""
|
||||||
|
Recurring Schedule Service
|
||||||
|
|
||||||
|
Manages recurring schedule definitions and generates ScheduledAction
|
||||||
|
instances based on patterns (weekly calendar, simple interval).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, date, time
|
||||||
|
from typing import Optional, List, Dict, Any, Tuple
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import and_
|
||||||
|
|
||||||
|
from backend.models import RecurringSchedule, ScheduledAction, MonitoringLocation, UnitAssignment
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Day name mapping
|
||||||
|
DAY_NAMES = ["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]
|
||||||
|
|
||||||
|
|
||||||
|
class RecurringScheduleService:
|
||||||
|
"""
|
||||||
|
Service for managing recurring schedules and generating ScheduledActions.
|
||||||
|
|
||||||
|
Supports two schedule types:
|
||||||
|
- weekly_calendar: Specific days with start/end times
|
||||||
|
- simple_interval: Daily stop/download/restart cycles for 24/7 monitoring
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, db: Session):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
def create_schedule(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
name: str,
|
||||||
|
schedule_type: str,
|
||||||
|
device_type: str = "slm",
|
||||||
|
unit_id: str = None,
|
||||||
|
weekly_pattern: dict = None,
|
||||||
|
interval_type: str = None,
|
||||||
|
cycle_time: str = None,
|
||||||
|
include_download: bool = True,
|
||||||
|
auto_increment_index: bool = True,
|
||||||
|
timezone: str = "America/New_York",
|
||||||
|
) -> RecurringSchedule:
|
||||||
|
"""
|
||||||
|
Create a new recurring schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
project_id: Project ID
|
||||||
|
location_id: Monitoring location ID
|
||||||
|
name: Schedule name
|
||||||
|
schedule_type: "weekly_calendar" or "simple_interval"
|
||||||
|
device_type: "slm" or "seismograph"
|
||||||
|
unit_id: Specific unit (optional, can use assignment)
|
||||||
|
weekly_pattern: Dict of day patterns for weekly_calendar
|
||||||
|
interval_type: "daily" or "hourly" for simple_interval
|
||||||
|
cycle_time: Time string "HH:MM" for cycle
|
||||||
|
include_download: Whether to download data on cycle
|
||||||
|
auto_increment_index: Whether to auto-increment store index before start
|
||||||
|
timezone: Timezone for schedule times
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Created RecurringSchedule
|
||||||
|
"""
|
||||||
|
schedule = RecurringSchedule(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=project_id,
|
||||||
|
location_id=location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
name=name,
|
||||||
|
schedule_type=schedule_type,
|
||||||
|
device_type=device_type,
|
||||||
|
weekly_pattern=json.dumps(weekly_pattern) if weekly_pattern else None,
|
||||||
|
interval_type=interval_type,
|
||||||
|
cycle_time=cycle_time,
|
||||||
|
include_download=include_download,
|
||||||
|
auto_increment_index=auto_increment_index,
|
||||||
|
enabled=True,
|
||||||
|
timezone=timezone,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate next occurrence
|
||||||
|
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
|
||||||
|
|
||||||
|
self.db.add(schedule)
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(schedule)
|
||||||
|
|
||||||
|
logger.info(f"Created recurring schedule: {name} ({schedule_type})")
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
def update_schedule(
|
||||||
|
self,
|
||||||
|
schedule_id: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> Optional[RecurringSchedule]:
|
||||||
|
"""
|
||||||
|
Update a recurring schedule.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule to update
|
||||||
|
**kwargs: Fields to update
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated schedule or None
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(RecurringSchedule).filter_by(id=schedule_id).first()
|
||||||
|
if not schedule:
|
||||||
|
return None
|
||||||
|
|
||||||
|
for key, value in kwargs.items():
|
||||||
|
if hasattr(schedule, key):
|
||||||
|
if key == "weekly_pattern" and isinstance(value, dict):
|
||||||
|
value = json.dumps(value)
|
||||||
|
setattr(schedule, key, value)
|
||||||
|
|
||||||
|
# Recalculate next occurrence
|
||||||
|
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
self.db.refresh(schedule)
|
||||||
|
|
||||||
|
logger.info(f"Updated recurring schedule: {schedule.name}")
|
||||||
|
return schedule
|
||||||
|
|
||||||
|
def delete_schedule(self, schedule_id: str) -> bool:
|
||||||
|
"""
|
||||||
|
Delete a recurring schedule and its pending generated actions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule_id: Schedule to delete
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if deleted, False if not found
|
||||||
|
"""
|
||||||
|
schedule = self.db.query(RecurringSchedule).filter_by(id=schedule_id).first()
|
||||||
|
if not schedule:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Delete pending generated actions for this schedule
|
||||||
|
# The schedule_id is stored in the notes field as JSON
|
||||||
|
pending_actions = self.db.query(ScheduledAction).filter(
|
||||||
|
and_(
|
||||||
|
ScheduledAction.execution_status == "pending",
|
||||||
|
ScheduledAction.notes.like(f'%"schedule_id": "{schedule_id}"%'),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
deleted_count = len(pending_actions)
|
||||||
|
for action in pending_actions:
|
||||||
|
self.db.delete(action)
|
||||||
|
|
||||||
|
self.db.delete(schedule)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Deleted recurring schedule: {schedule.name} (and {deleted_count} pending actions)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def enable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
|
||||||
|
"""Enable a disabled schedule."""
|
||||||
|
return self.update_schedule(schedule_id, enabled=True)
|
||||||
|
|
||||||
|
def disable_schedule(self, schedule_id: str) -> Optional[RecurringSchedule]:
|
||||||
|
"""Disable a schedule."""
|
||||||
|
return self.update_schedule(schedule_id, enabled=False)
|
||||||
|
|
||||||
|
def generate_actions_for_schedule(
|
||||||
|
self,
|
||||||
|
schedule: RecurringSchedule,
|
||||||
|
horizon_days: int = 7,
|
||||||
|
preview_only: bool = False,
|
||||||
|
) -> List[ScheduledAction]:
|
||||||
|
"""
|
||||||
|
Generate ScheduledAction entries for the next N days based on pattern.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
schedule: The recurring schedule
|
||||||
|
horizon_days: Days ahead to generate
|
||||||
|
preview_only: If True, don't save to DB (for preview)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of generated ScheduledAction instances
|
||||||
|
"""
|
||||||
|
if not schedule.enabled:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if schedule.schedule_type == "weekly_calendar":
|
||||||
|
actions = self._generate_weekly_calendar_actions(schedule, horizon_days)
|
||||||
|
elif schedule.schedule_type == "simple_interval":
|
||||||
|
actions = self._generate_interval_actions(schedule, horizon_days)
|
||||||
|
else:
|
||||||
|
logger.warning(f"Unknown schedule type: {schedule.schedule_type}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
if not preview_only and actions:
|
||||||
|
for action in actions:
|
||||||
|
self.db.add(action)
|
||||||
|
|
||||||
|
schedule.last_generated_at = datetime.utcnow()
|
||||||
|
schedule.next_occurrence = self._calculate_next_occurrence(schedule)
|
||||||
|
|
||||||
|
self.db.commit()
|
||||||
|
logger.info(f"Generated {len(actions)} actions for schedule: {schedule.name}")
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def _generate_weekly_calendar_actions(
|
||||||
|
self,
|
||||||
|
schedule: RecurringSchedule,
|
||||||
|
horizon_days: int,
|
||||||
|
) -> List[ScheduledAction]:
|
||||||
|
"""
|
||||||
|
Generate actions from weekly calendar pattern.
|
||||||
|
|
||||||
|
Pattern format:
|
||||||
|
{
|
||||||
|
"monday": {"enabled": true, "start": "19:00", "end": "07:00"},
|
||||||
|
"tuesday": {"enabled": false},
|
||||||
|
...
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
if not schedule.weekly_pattern:
|
||||||
|
return []
|
||||||
|
|
||||||
|
try:
|
||||||
|
pattern = json.loads(schedule.weekly_pattern)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
logger.error(f"Invalid weekly_pattern JSON for schedule {schedule.id}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
tz = ZoneInfo(schedule.timezone)
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
|
||||||
|
|
||||||
|
# Get unit_id (from schedule or assignment)
|
||||||
|
unit_id = self._resolve_unit_id(schedule)
|
||||||
|
|
||||||
|
for day_offset in range(horizon_days):
|
||||||
|
check_date = now_local.date() + timedelta(days=day_offset)
|
||||||
|
day_name = DAY_NAMES[check_date.weekday()]
|
||||||
|
day_config = pattern.get(day_name, {})
|
||||||
|
|
||||||
|
if not day_config.get("enabled", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
start_time_str = day_config.get("start")
|
||||||
|
end_time_str = day_config.get("end")
|
||||||
|
|
||||||
|
if not start_time_str or not end_time_str:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse times
|
||||||
|
start_time = self._parse_time(start_time_str)
|
||||||
|
end_time = self._parse_time(end_time_str)
|
||||||
|
|
||||||
|
if not start_time or not end_time:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Create start datetime in local timezone
|
||||||
|
start_local = datetime.combine(check_date, start_time, tzinfo=tz)
|
||||||
|
start_utc = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Handle overnight schedules (end time is next day)
|
||||||
|
if end_time <= start_time:
|
||||||
|
end_date = check_date + timedelta(days=1)
|
||||||
|
else:
|
||||||
|
end_date = check_date
|
||||||
|
|
||||||
|
end_local = datetime.combine(end_date, end_time, tzinfo=tz)
|
||||||
|
end_utc = end_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Skip if start time has already passed
|
||||||
|
if start_utc <= now_utc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if action already exists
|
||||||
|
if self._action_exists(schedule.project_id, schedule.location_id, "start", start_utc):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build notes with automation metadata
|
||||||
|
start_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"auto_increment_index": schedule.auto_increment_index,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create START action
|
||||||
|
start_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="start",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=start_utc,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=start_notes,
|
||||||
|
)
|
||||||
|
actions.append(start_action)
|
||||||
|
|
||||||
|
# Create STOP action
|
||||||
|
stop_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
})
|
||||||
|
stop_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="stop",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=end_utc,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=stop_notes,
|
||||||
|
)
|
||||||
|
actions.append(stop_action)
|
||||||
|
|
||||||
|
# Create DOWNLOAD action if enabled (1 minute after stop)
|
||||||
|
if schedule.include_download:
|
||||||
|
download_time = end_utc + timedelta(minutes=1)
|
||||||
|
download_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"schedule_type": "weekly_calendar",
|
||||||
|
})
|
||||||
|
download_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="download",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=download_time,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=download_notes,
|
||||||
|
)
|
||||||
|
actions.append(download_action)
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def _generate_interval_actions(
|
||||||
|
self,
|
||||||
|
schedule: RecurringSchedule,
|
||||||
|
horizon_days: int,
|
||||||
|
) -> List[ScheduledAction]:
|
||||||
|
"""
|
||||||
|
Generate actions from simple interval pattern.
|
||||||
|
|
||||||
|
For daily cycles: stop, download (optional), start at cycle_time each day.
|
||||||
|
"""
|
||||||
|
if not schedule.cycle_time:
|
||||||
|
return []
|
||||||
|
|
||||||
|
cycle_time = self._parse_time(schedule.cycle_time)
|
||||||
|
if not cycle_time:
|
||||||
|
return []
|
||||||
|
|
||||||
|
actions = []
|
||||||
|
tz = ZoneInfo(schedule.timezone)
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
|
||||||
|
|
||||||
|
# Get unit_id
|
||||||
|
unit_id = self._resolve_unit_id(schedule)
|
||||||
|
|
||||||
|
for day_offset in range(horizon_days):
|
||||||
|
check_date = now_local.date() + timedelta(days=day_offset)
|
||||||
|
|
||||||
|
# Create cycle datetime in local timezone
|
||||||
|
cycle_local = datetime.combine(check_date, cycle_time, tzinfo=tz)
|
||||||
|
cycle_utc = cycle_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
# Skip if time has passed
|
||||||
|
if cycle_utc <= now_utc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if action already exists
|
||||||
|
if self._action_exists(schedule.project_id, schedule.location_id, "stop", cycle_utc):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Build notes with metadata
|
||||||
|
stop_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"cycle_type": "daily",
|
||||||
|
})
|
||||||
|
|
||||||
|
# Create STOP action
|
||||||
|
stop_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="stop",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=cycle_utc,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=stop_notes,
|
||||||
|
)
|
||||||
|
actions.append(stop_action)
|
||||||
|
|
||||||
|
# Create DOWNLOAD action if enabled (1 minute after stop)
|
||||||
|
if schedule.include_download:
|
||||||
|
download_time = cycle_utc + timedelta(minutes=1)
|
||||||
|
download_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"cycle_type": "daily",
|
||||||
|
})
|
||||||
|
download_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="download",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=download_time,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=download_notes,
|
||||||
|
)
|
||||||
|
actions.append(download_action)
|
||||||
|
|
||||||
|
# Create START action (2 minutes after stop, or 1 minute after download)
|
||||||
|
start_offset = 2 if schedule.include_download else 1
|
||||||
|
start_time = cycle_utc + timedelta(minutes=start_offset)
|
||||||
|
start_notes = json.dumps({
|
||||||
|
"schedule_name": schedule.name,
|
||||||
|
"schedule_id": schedule.id,
|
||||||
|
"cycle_type": "daily",
|
||||||
|
"auto_increment_index": schedule.auto_increment_index,
|
||||||
|
})
|
||||||
|
start_action = ScheduledAction(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
project_id=schedule.project_id,
|
||||||
|
location_id=schedule.location_id,
|
||||||
|
unit_id=unit_id,
|
||||||
|
action_type="start",
|
||||||
|
device_type=schedule.device_type,
|
||||||
|
scheduled_time=start_time,
|
||||||
|
execution_status="pending",
|
||||||
|
notes=start_notes,
|
||||||
|
)
|
||||||
|
actions.append(start_action)
|
||||||
|
|
||||||
|
return actions
|
||||||
|
|
||||||
|
def _calculate_next_occurrence(self, schedule: RecurringSchedule) -> Optional[datetime]:
|
||||||
|
"""Calculate when the next action should occur."""
|
||||||
|
if not schedule.enabled:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tz = ZoneInfo(schedule.timezone)
|
||||||
|
now_utc = datetime.utcnow()
|
||||||
|
now_local = now_utc.replace(tzinfo=ZoneInfo("UTC")).astimezone(tz)
|
||||||
|
|
||||||
|
if schedule.schedule_type == "weekly_calendar" and schedule.weekly_pattern:
|
||||||
|
try:
|
||||||
|
pattern = json.loads(schedule.weekly_pattern)
|
||||||
|
except:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Find next enabled day
|
||||||
|
for day_offset in range(8): # Check up to a week ahead
|
||||||
|
check_date = now_local.date() + timedelta(days=day_offset)
|
||||||
|
day_name = DAY_NAMES[check_date.weekday()]
|
||||||
|
day_config = pattern.get(day_name, {})
|
||||||
|
|
||||||
|
if day_config.get("enabled") and day_config.get("start"):
|
||||||
|
start_time = self._parse_time(day_config["start"])
|
||||||
|
if start_time:
|
||||||
|
start_local = datetime.combine(check_date, start_time, tzinfo=tz)
|
||||||
|
start_utc = start_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
if start_utc > now_utc:
|
||||||
|
return start_utc
|
||||||
|
|
||||||
|
elif schedule.schedule_type == "simple_interval" and schedule.cycle_time:
|
||||||
|
cycle_time = self._parse_time(schedule.cycle_time)
|
||||||
|
if cycle_time:
|
||||||
|
# Find next cycle time
|
||||||
|
for day_offset in range(2):
|
||||||
|
check_date = now_local.date() + timedelta(days=day_offset)
|
||||||
|
cycle_local = datetime.combine(check_date, cycle_time, tzinfo=tz)
|
||||||
|
cycle_utc = cycle_local.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
if cycle_utc > now_utc:
|
||||||
|
return cycle_utc
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _resolve_unit_id(self, schedule: RecurringSchedule) -> Optional[str]:
|
||||||
|
"""Get unit_id from schedule or active assignment."""
|
||||||
|
if schedule.unit_id:
|
||||||
|
return schedule.unit_id
|
||||||
|
|
||||||
|
# Try to get from active assignment
|
||||||
|
assignment = self.db.query(UnitAssignment).filter(
|
||||||
|
and_(
|
||||||
|
UnitAssignment.location_id == schedule.location_id,
|
||||||
|
UnitAssignment.status == "active",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return assignment.unit_id if assignment else None
|
||||||
|
|
||||||
|
def _action_exists(
|
||||||
|
self,
|
||||||
|
project_id: str,
|
||||||
|
location_id: str,
|
||||||
|
action_type: str,
|
||||||
|
scheduled_time: datetime,
|
||||||
|
) -> bool:
|
||||||
|
"""Check if an action already exists for this time slot."""
|
||||||
|
# Allow 5-minute window for duplicate detection
|
||||||
|
time_window_start = scheduled_time - timedelta(minutes=5)
|
||||||
|
time_window_end = scheduled_time + timedelta(minutes=5)
|
||||||
|
|
||||||
|
exists = self.db.query(ScheduledAction).filter(
|
||||||
|
and_(
|
||||||
|
ScheduledAction.project_id == project_id,
|
||||||
|
ScheduledAction.location_id == location_id,
|
||||||
|
ScheduledAction.action_type == action_type,
|
||||||
|
ScheduledAction.scheduled_time >= time_window_start,
|
||||||
|
ScheduledAction.scheduled_time <= time_window_end,
|
||||||
|
ScheduledAction.execution_status == "pending",
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return exists is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_time(time_str: str) -> Optional[time]:
|
||||||
|
"""Parse time string "HH:MM" to time object."""
|
||||||
|
try:
|
||||||
|
parts = time_str.split(":")
|
||||||
|
return time(int(parts[0]), int(parts[1]))
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_schedules_for_project(self, project_id: str) -> List[RecurringSchedule]:
|
||||||
|
"""Get all recurring schedules for a project."""
|
||||||
|
return self.db.query(RecurringSchedule).filter_by(project_id=project_id).all()
|
||||||
|
|
||||||
|
def get_enabled_schedules(self) -> List[RecurringSchedule]:
|
||||||
|
"""Get all enabled recurring schedules."""
|
||||||
|
return self.db.query(RecurringSchedule).filter_by(enabled=True).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_recurring_schedule_service(db: Session) -> RecurringScheduleService:
|
||||||
|
"""Get a RecurringScheduleService instance."""
|
||||||
|
return RecurringScheduleService(db)
|
||||||
@@ -4,22 +4,30 @@ Scheduler Service
|
|||||||
Executes scheduled actions for Projects system.
|
Executes scheduled actions for Projects system.
|
||||||
Monitors pending scheduled actions and executes them by calling device modules (SLMM/SFM).
|
Monitors pending scheduled actions and executes them by calling device modules (SLMM/SFM).
|
||||||
|
|
||||||
|
Extended to support recurring schedules:
|
||||||
|
- Generates ScheduledActions from RecurringSchedule patterns
|
||||||
|
- Cleans up old completed/failed actions
|
||||||
|
|
||||||
This service runs as a background task in FastAPI, checking for pending actions
|
This service runs as a background task in FastAPI, checking for pending actions
|
||||||
every minute and executing them when their scheduled time arrives.
|
every minute and executing them when their scheduled time arrives.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import and_
|
from sqlalchemy import and_
|
||||||
|
|
||||||
from backend.database import SessionLocal
|
from backend.database import SessionLocal
|
||||||
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project
|
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
||||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
|
from backend.services.alert_service import get_alert_service
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SchedulerService:
|
class SchedulerService:
|
||||||
"""
|
"""
|
||||||
@@ -62,11 +70,26 @@ class SchedulerService:
|
|||||||
|
|
||||||
async def _run_loop(self):
|
async def _run_loop(self):
|
||||||
"""Main scheduler loop."""
|
"""Main scheduler loop."""
|
||||||
|
# Track when we last generated recurring actions (do this once per hour)
|
||||||
|
last_generation_check = datetime.utcnow() - timedelta(hours=1)
|
||||||
|
|
||||||
while self.running:
|
while self.running:
|
||||||
try:
|
try:
|
||||||
|
# Execute pending actions
|
||||||
await self.execute_pending_actions()
|
await self.execute_pending_actions()
|
||||||
|
|
||||||
|
# Generate actions from recurring schedules (every hour)
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if (now - last_generation_check).total_seconds() >= 3600:
|
||||||
|
await self.generate_recurring_actions()
|
||||||
|
last_generation_check = now
|
||||||
|
|
||||||
|
# Cleanup old actions (also every hour, during generation cycle)
|
||||||
|
if (now - last_generation_check).total_seconds() < 60:
|
||||||
|
await self.cleanup_old_actions()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Scheduler error: {e}")
|
logger.error(f"Scheduler error: {e}", exc_info=True)
|
||||||
# Continue running even if there's an error
|
# Continue running even if there's an error
|
||||||
|
|
||||||
await asyncio.sleep(self.check_interval)
|
await asyncio.sleep(self.check_interval)
|
||||||
@@ -175,6 +198,21 @@ class SchedulerService:
|
|||||||
|
|
||||||
print(f"✓ Action {action.id} completed successfully")
|
print(f"✓ Action {action.id} completed successfully")
|
||||||
|
|
||||||
|
# Create success alert
|
||||||
|
try:
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alert_metadata = response.get("cycle_response", {}) if isinstance(response, dict) else {}
|
||||||
|
alert_service.create_schedule_completed_alert(
|
||||||
|
schedule_id=action.id,
|
||||||
|
action_type=action.action_type,
|
||||||
|
unit_id=unit_id,
|
||||||
|
project_id=action.project_id,
|
||||||
|
location_id=action.location_id,
|
||||||
|
metadata=alert_metadata,
|
||||||
|
)
|
||||||
|
except Exception as alert_err:
|
||||||
|
logger.warning(f"Failed to create success alert: {alert_err}")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Mark action as failed
|
# Mark action as failed
|
||||||
action.execution_status = "failed"
|
action.execution_status = "failed"
|
||||||
@@ -185,6 +223,20 @@ class SchedulerService:
|
|||||||
|
|
||||||
print(f"✗ Action {action.id} failed: {e}")
|
print(f"✗ Action {action.id} failed: {e}")
|
||||||
|
|
||||||
|
# Create failure alert
|
||||||
|
try:
|
||||||
|
alert_service = get_alert_service(db)
|
||||||
|
alert_service.create_schedule_failed_alert(
|
||||||
|
schedule_id=action.id,
|
||||||
|
action_type=action.action_type,
|
||||||
|
unit_id=unit_id if 'unit_id' in dir() else action.unit_id,
|
||||||
|
error_message=str(e),
|
||||||
|
project_id=action.project_id,
|
||||||
|
location_id=action.location_id,
|
||||||
|
)
|
||||||
|
except Exception as alert_err:
|
||||||
|
logger.warning(f"Failed to create failure alert: {alert_err}")
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
async def _execute_start(
|
async def _execute_start(
|
||||||
@@ -193,12 +245,19 @@ class SchedulerService:
|
|||||||
unit_id: str,
|
unit_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a 'start' action."""
|
"""Execute a 'start' action using the start_cycle command.
|
||||||
# Start recording via device controller
|
|
||||||
response = await self.device_controller.start_recording(
|
start_cycle handles:
|
||||||
|
1. Sync device clock to server time
|
||||||
|
2. Find next safe index (with overwrite protection)
|
||||||
|
3. Start measurement
|
||||||
|
"""
|
||||||
|
# Execute the full start cycle via device controller
|
||||||
|
# SLMM handles clock sync, index increment, and start
|
||||||
|
cycle_response = await self.device_controller.start_cycle(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
config={}, # TODO: Load config from action.notes or metadata
|
sync_clock=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create recording session
|
# Create recording session
|
||||||
@@ -210,14 +269,17 @@ class SchedulerService:
|
|||||||
session_type="sound" if action.device_type == "slm" else "vibration",
|
session_type="sound" if action.device_type == "slm" else "vibration",
|
||||||
started_at=datetime.utcnow(),
|
started_at=datetime.utcnow(),
|
||||||
status="recording",
|
status="recording",
|
||||||
session_metadata=json.dumps({"scheduled_action_id": action.id}),
|
session_metadata=json.dumps({
|
||||||
|
"scheduled_action_id": action.id,
|
||||||
|
"cycle_response": cycle_response,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
db.add(session)
|
db.add(session)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "started",
|
"status": "started",
|
||||||
"session_id": session.id,
|
"session_id": session.id,
|
||||||
"device_response": response,
|
"cycle_response": cycle_response,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _execute_stop(
|
async def _execute_stop(
|
||||||
@@ -226,11 +288,29 @@ class SchedulerService:
|
|||||||
unit_id: str,
|
unit_id: str,
|
||||||
db: Session,
|
db: Session,
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Execute a 'stop' action."""
|
"""Execute a 'stop' action using the stop_cycle command.
|
||||||
# Stop recording via device controller
|
|
||||||
response = await self.device_controller.stop_recording(
|
stop_cycle handles:
|
||||||
|
1. Stop measurement
|
||||||
|
2. Enable FTP
|
||||||
|
3. Download measurement folder
|
||||||
|
4. Verify download
|
||||||
|
"""
|
||||||
|
# Parse notes for download preference
|
||||||
|
include_download = True
|
||||||
|
try:
|
||||||
|
if action.notes:
|
||||||
|
notes_data = json.loads(action.notes)
|
||||||
|
include_download = notes_data.get("include_download", True)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # Notes is plain text, not JSON
|
||||||
|
|
||||||
|
# Execute the full stop cycle via device controller
|
||||||
|
# SLMM handles stop, FTP enable, and download
|
||||||
|
cycle_response = await self.device_controller.stop_cycle(
|
||||||
unit_id,
|
unit_id,
|
||||||
action.device_type,
|
action.device_type,
|
||||||
|
download=include_download,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Find and update the active recording session
|
# Find and update the active recording session
|
||||||
@@ -248,11 +328,20 @@ class SchedulerService:
|
|||||||
active_session.duration_seconds = int(
|
active_session.duration_seconds = int(
|
||||||
(active_session.stopped_at - active_session.started_at).total_seconds()
|
(active_session.stopped_at - active_session.started_at).total_seconds()
|
||||||
)
|
)
|
||||||
|
# Store download info in session metadata
|
||||||
|
if cycle_response.get("download_success"):
|
||||||
|
try:
|
||||||
|
metadata = json.loads(active_session.session_metadata or "{}")
|
||||||
|
metadata["downloaded_folder"] = cycle_response.get("downloaded_folder")
|
||||||
|
metadata["local_path"] = cycle_response.get("local_path")
|
||||||
|
active_session.session_metadata = json.dumps(metadata)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "stopped",
|
"status": "stopped",
|
||||||
"session_id": active_session.id if active_session else None,
|
"session_id": active_session.id if active_session else None,
|
||||||
"device_response": response,
|
"cycle_response": cycle_response,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def _execute_download(
|
async def _execute_download(
|
||||||
@@ -295,6 +384,90 @@ class SchedulerService:
|
|||||||
"device_response": response,
|
"device_response": response,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Recurring Schedule Generation
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def generate_recurring_actions(self) -> int:
|
||||||
|
"""
|
||||||
|
Generate ScheduledActions from all enabled recurring schedules.
|
||||||
|
|
||||||
|
Runs once per hour to generate actions for the next 7 days.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Total number of actions generated
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
total_generated = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
from backend.services.recurring_schedule_service import get_recurring_schedule_service
|
||||||
|
|
||||||
|
service = get_recurring_schedule_service(db)
|
||||||
|
schedules = service.get_enabled_schedules()
|
||||||
|
|
||||||
|
if not schedules:
|
||||||
|
logger.debug("No enabled recurring schedules found")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
logger.info(f"Generating actions for {len(schedules)} recurring schedule(s)")
|
||||||
|
|
||||||
|
for schedule in schedules:
|
||||||
|
try:
|
||||||
|
actions = service.generate_actions_for_schedule(schedule, horizon_days=7)
|
||||||
|
total_generated += len(actions)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error generating actions for schedule {schedule.id}: {e}")
|
||||||
|
|
||||||
|
if total_generated > 0:
|
||||||
|
logger.info(f"Generated {total_generated} scheduled actions from recurring schedules")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in generate_recurring_actions: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return total_generated
|
||||||
|
|
||||||
|
async def cleanup_old_actions(self, retention_days: int = 30) -> int:
|
||||||
|
"""
|
||||||
|
Remove old completed/failed actions to prevent database bloat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
retention_days: Keep actions newer than this many days
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of actions cleaned up
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
cleaned = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
cutoff = datetime.utcnow() - timedelta(days=retention_days)
|
||||||
|
|
||||||
|
old_actions = db.query(ScheduledAction).filter(
|
||||||
|
and_(
|
||||||
|
ScheduledAction.execution_status.in_(["completed", "failed", "cancelled"]),
|
||||||
|
ScheduledAction.executed_at < cutoff,
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
|
||||||
|
cleaned = len(old_actions)
|
||||||
|
for action in old_actions:
|
||||||
|
db.delete(action)
|
||||||
|
|
||||||
|
if cleaned > 0:
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Cleaned up {cleaned} old scheduled actions (>{retention_days} days)")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error cleaning up old actions: {e}")
|
||||||
|
db.rollback()
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
return cleaned
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Manual Execution (for testing/debugging)
|
# Manual Execution (for testing/debugging)
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
@@ -9,13 +9,14 @@ that handles TCP/FTP communication with Rion NL-43/NL-53 devices.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import os
|
||||||
from typing import Optional, Dict, Any, List
|
from typing import Optional, Dict, Any, List
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
|
||||||
# SLMM backend base URLs
|
# SLMM backend base URLs - use environment variable if set (for Docker)
|
||||||
SLMM_BASE_URL = "http://localhost:8100"
|
SLMM_BASE_URL = os.environ.get("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
|
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
|
||||||
|
|
||||||
|
|
||||||
@@ -276,6 +277,124 @@ class SLMMClient:
|
|||||||
"""
|
"""
|
||||||
return await self._request("POST", f"/{unit_id}/reset")
|
return await self._request("POST", f"/{unit_id}/reset")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Store/Index Management
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def get_index_number(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get current store/index number from device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with current index_number (store name)
|
||||||
|
"""
|
||||||
|
return await self._request("GET", f"/{unit_id}/index-number")
|
||||||
|
|
||||||
|
async def set_index_number(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
index_number: int,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Set store/index number on device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
index_number: New index number to set
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Confirmation response
|
||||||
|
"""
|
||||||
|
return await self._request(
|
||||||
|
"PUT",
|
||||||
|
f"/{unit_id}/index-number",
|
||||||
|
data={"index_number": index_number},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def check_overwrite_status(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check if data exists at the current store index.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with:
|
||||||
|
- overwrite_status: "None" (safe) or "Exist" (would overwrite)
|
||||||
|
- will_overwrite: bool
|
||||||
|
- safe_to_store: bool
|
||||||
|
"""
|
||||||
|
return await self._request("GET", f"/{unit_id}/overwrite-check")
|
||||||
|
|
||||||
|
async def increment_index(self, unit_id: str, max_attempts: int = 100) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Find and set the next available (unused) store/index number.
|
||||||
|
|
||||||
|
Checks the current index - if it would overwrite existing data,
|
||||||
|
increments until finding an unused index number.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
max_attempts: Maximum number of indices to try before giving up
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with old_index, new_index, and attempts_made
|
||||||
|
"""
|
||||||
|
# Get current index
|
||||||
|
current = await self.get_index_number(unit_id)
|
||||||
|
old_index = current.get("index_number", 0)
|
||||||
|
|
||||||
|
# Check if current index is safe
|
||||||
|
overwrite_check = await self.check_overwrite_status(unit_id)
|
||||||
|
if overwrite_check.get("safe_to_store", False):
|
||||||
|
# Current index is safe, no need to increment
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"old_index": old_index,
|
||||||
|
"new_index": old_index,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"already_safe": True,
|
||||||
|
"attempts_made": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Need to find an unused index
|
||||||
|
attempts = 0
|
||||||
|
test_index = old_index + 1
|
||||||
|
|
||||||
|
while attempts < max_attempts:
|
||||||
|
# Set the new index
|
||||||
|
await self.set_index_number(unit_id, test_index)
|
||||||
|
|
||||||
|
# Check if this index is safe
|
||||||
|
overwrite_check = await self.check_overwrite_status(unit_id)
|
||||||
|
attempts += 1
|
||||||
|
|
||||||
|
if overwrite_check.get("safe_to_store", False):
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"old_index": old_index,
|
||||||
|
"new_index": test_index,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"already_safe": False,
|
||||||
|
"attempts_made": attempts,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Try next index (wrap around at 9999)
|
||||||
|
test_index = (test_index + 1) % 10000
|
||||||
|
|
||||||
|
# Avoid infinite loops if we've wrapped around
|
||||||
|
if test_index == old_index:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Could not find a safe index
|
||||||
|
raise SLMMDeviceError(
|
||||||
|
f"Could not find unused store index for {unit_id} after {attempts} attempts. "
|
||||||
|
f"Consider downloading and clearing data from the device."
|
||||||
|
)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Device Settings
|
# Device Settings
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
@@ -387,6 +506,135 @@ class SLMMClient:
|
|||||||
}
|
}
|
||||||
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
return await self._request("POST", f"/{unit_id}/ftp/download", data=data)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Cycle Commands (for scheduled automation)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def start_cycle(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
sync_clock: bool = True,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute complete start cycle on device via SLMM.
|
||||||
|
|
||||||
|
This handles the full pre-recording workflow:
|
||||||
|
1. Sync device clock to server time
|
||||||
|
2. Find next safe index (with overwrite protection)
|
||||||
|
3. Start measurement
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
sync_clock: Whether to sync device clock to server time
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with clock_synced, old_index, new_index, started, etc.
|
||||||
|
"""
|
||||||
|
return await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/{unit_id}/start-cycle",
|
||||||
|
data={"sync_clock": sync_clock},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def stop_cycle(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
download: bool = True,
|
||||||
|
download_path: Optional[str] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Execute complete stop cycle on device via SLMM.
|
||||||
|
|
||||||
|
This handles the full post-recording workflow:
|
||||||
|
1. Stop measurement
|
||||||
|
2. Enable FTP
|
||||||
|
3. Download measurement folder (if download=True)
|
||||||
|
4. Verify download
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
download: Whether to download measurement data
|
||||||
|
download_path: Custom path for downloaded ZIP (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with stopped, ftp_enabled, download_success, local_path, etc.
|
||||||
|
"""
|
||||||
|
data = {"download": download}
|
||||||
|
if download_path:
|
||||||
|
data["download_path"] = download_path
|
||||||
|
return await self._request(
|
||||||
|
"POST",
|
||||||
|
f"/{unit_id}/stop-cycle",
|
||||||
|
data=data,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# Polling Status (for device monitoring/alerts)
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
async def get_polling_status(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get global polling status from SLMM.
|
||||||
|
|
||||||
|
Returns device reachability information for all polled devices.
|
||||||
|
Used by DeviceStatusMonitor to detect offline/online transitions.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with devices list containing:
|
||||||
|
- unit_id
|
||||||
|
- is_reachable
|
||||||
|
- consecutive_failures
|
||||||
|
- last_poll_attempt
|
||||||
|
- last_success
|
||||||
|
- last_error
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||||
|
response = await client.get(f"{self.base_url}/api/nl43/_polling/status")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except httpx.ConnectError:
|
||||||
|
raise SLMMConnectionError("Cannot connect to SLMM for polling status")
|
||||||
|
except Exception as e:
|
||||||
|
raise SLMMClientError(f"Failed to get polling status: {str(e)}")
|
||||||
|
|
||||||
|
async def get_device_polling_config(self, unit_id: str) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Get polling configuration for a specific device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with poll_enabled and poll_interval_seconds
|
||||||
|
"""
|
||||||
|
return await self._request("GET", f"/{unit_id}/polling/config")
|
||||||
|
|
||||||
|
async def update_device_polling_config(
|
||||||
|
self,
|
||||||
|
unit_id: str,
|
||||||
|
poll_enabled: Optional[bool] = None,
|
||||||
|
poll_interval_seconds: Optional[int] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Update polling configuration for a device.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unit identifier
|
||||||
|
poll_enabled: Enable/disable polling
|
||||||
|
poll_interval_seconds: Polling interval (10-3600)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Updated config
|
||||||
|
"""
|
||||||
|
config = {}
|
||||||
|
if poll_enabled is not None:
|
||||||
|
config["poll_enabled"] = poll_enabled
|
||||||
|
if poll_interval_seconds is not None:
|
||||||
|
config["poll_interval_seconds"] = poll_interval_seconds
|
||||||
|
|
||||||
|
return await self._request("PUT", f"/{unit_id}/polling/config", data=config)
|
||||||
|
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
# Health Check
|
# Health Check
|
||||||
# ========================================================================
|
# ========================================================================
|
||||||
|
|||||||
BIN
backend/static/icons/favicon-16.png
Normal file
|
After Width: | Height: | Size: 424 B |
BIN
backend/static/icons/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 68 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 5.0 KiB |
BIN
backend/static/terra-view-logo-dark.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
backend/static/terra-view-logo-dark@2x.png
Normal file
|
After Width: | Height: | Size: 57 KiB |
BIN
backend/static/terra-view-logo-light.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
backend/static/terra-view-logo-light@2x.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
39
backend/templates_config.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
"""
|
||||||
|
Shared Jinja2 templates configuration.
|
||||||
|
|
||||||
|
All routers should import `templates` from this module to get consistent
|
||||||
|
filter and global function registration.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
# Import timezone utilities
|
||||||
|
from backend.utils.timezone import (
|
||||||
|
format_local_datetime, format_local_time,
|
||||||
|
get_user_timezone, get_timezone_abbreviation
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_local_datetime(dt, fmt="%Y-%m-%d %H:%M"):
|
||||||
|
"""Jinja filter to convert UTC datetime to local timezone."""
|
||||||
|
return format_local_datetime(dt, fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_local_time(dt):
|
||||||
|
"""Jinja filter to format time in local timezone."""
|
||||||
|
return format_local_time(dt)
|
||||||
|
|
||||||
|
|
||||||
|
def jinja_timezone_abbr():
|
||||||
|
"""Jinja global to get current timezone abbreviation."""
|
||||||
|
return get_timezone_abbreviation()
|
||||||
|
|
||||||
|
|
||||||
|
# Create templates instance
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
# Register Jinja filters and globals
|
||||||
|
templates.env.filters["local_datetime"] = jinja_local_datetime
|
||||||
|
templates.env.filters["local_time"] = jinja_local_time
|
||||||
|
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
|
||||||
|
templates.env.globals["get_user_timezone"] = get_user_timezone
|
||||||
1
backend/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# Utils package
|
||||||
173
backend/utils/timezone.py
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
"""
|
||||||
|
Timezone utilities for Terra-View.
|
||||||
|
|
||||||
|
Provides consistent timezone handling throughout the application.
|
||||||
|
All database times are stored in UTC; this module converts for display.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from backend.database import SessionLocal
|
||||||
|
from backend.models import UserPreferences
|
||||||
|
|
||||||
|
|
||||||
|
# Default timezone if none set
|
||||||
|
DEFAULT_TIMEZONE = "America/New_York"
|
||||||
|
|
||||||
|
|
||||||
|
def get_user_timezone() -> str:
|
||||||
|
"""
|
||||||
|
Get the user's configured timezone from preferences.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timezone string (e.g., "America/New_York")
|
||||||
|
"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
|
if prefs and prefs.timezone:
|
||||||
|
return prefs.timezone
|
||||||
|
return DEFAULT_TIMEZONE
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone_info(tz_name: str = None) -> ZoneInfo:
|
||||||
|
"""
|
||||||
|
Get ZoneInfo object for the specified or user's timezone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tz_name: Timezone name, or None to use user preference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ZoneInfo object
|
||||||
|
"""
|
||||||
|
if tz_name is None:
|
||||||
|
tz_name = get_user_timezone()
|
||||||
|
try:
|
||||||
|
return ZoneInfo(tz_name)
|
||||||
|
except Exception:
|
||||||
|
return ZoneInfo(DEFAULT_TIMEZONE)
|
||||||
|
|
||||||
|
|
||||||
|
def utc_to_local(dt: datetime, tz_name: str = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Convert a UTC datetime to local timezone.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime in UTC (naive or aware)
|
||||||
|
tz_name: Target timezone, or None to use user preference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Datetime in local timezone
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tz = get_timezone_info(tz_name)
|
||||||
|
|
||||||
|
# Assume naive datetime is UTC
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=ZoneInfo("UTC"))
|
||||||
|
|
||||||
|
return dt.astimezone(tz)
|
||||||
|
|
||||||
|
|
||||||
|
def local_to_utc(dt: datetime, tz_name: str = None) -> datetime:
|
||||||
|
"""
|
||||||
|
Convert a local datetime to UTC.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime in local timezone (naive or aware)
|
||||||
|
tz_name: Source timezone, or None to use user preference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Datetime in UTC (naive, for database storage)
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tz = get_timezone_info(tz_name)
|
||||||
|
|
||||||
|
# Assume naive datetime is in local timezone
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=tz)
|
||||||
|
|
||||||
|
# Convert to UTC and strip tzinfo for database storage
|
||||||
|
return dt.astimezone(ZoneInfo("UTC")).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def format_local_datetime(dt: datetime, fmt: str = "%Y-%m-%d %H:%M", tz_name: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Format a UTC datetime as local time string.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime in UTC
|
||||||
|
fmt: strftime format string
|
||||||
|
tz_name: Target timezone, or None to use user preference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Formatted datetime string in local time
|
||||||
|
"""
|
||||||
|
if dt is None:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
local_dt = utc_to_local(dt, tz_name)
|
||||||
|
return local_dt.strftime(fmt)
|
||||||
|
|
||||||
|
|
||||||
|
def format_local_time(dt: datetime, tz_name: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Format a UTC datetime as local time (HH:MM format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime in UTC
|
||||||
|
tz_name: Target timezone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Time string in HH:MM format
|
||||||
|
"""
|
||||||
|
return format_local_datetime(dt, "%H:%M", tz_name)
|
||||||
|
|
||||||
|
|
||||||
|
def format_local_date(dt: datetime, tz_name: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Format a UTC datetime as local date (YYYY-MM-DD format).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dt: Datetime in UTC
|
||||||
|
tz_name: Target timezone
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Date string
|
||||||
|
"""
|
||||||
|
return format_local_datetime(dt, "%Y-%m-%d", tz_name)
|
||||||
|
|
||||||
|
|
||||||
|
def get_timezone_abbreviation(tz_name: str = None) -> str:
|
||||||
|
"""
|
||||||
|
Get the abbreviation for a timezone (e.g., EST, EDT, PST).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tz_name: Timezone name, or None to use user preference
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Timezone abbreviation
|
||||||
|
"""
|
||||||
|
tz = get_timezone_info(tz_name)
|
||||||
|
now = datetime.now(tz)
|
||||||
|
return now.strftime("%Z")
|
||||||
|
|
||||||
|
|
||||||
|
# Common US timezone choices for settings dropdown
|
||||||
|
TIMEZONE_CHOICES = [
|
||||||
|
("America/New_York", "Eastern Time (ET)"),
|
||||||
|
("America/Chicago", "Central Time (CT)"),
|
||||||
|
("America/Denver", "Mountain Time (MT)"),
|
||||||
|
("America/Los_Angeles", "Pacific Time (PT)"),
|
||||||
|
("America/Anchorage", "Alaska Time (AKT)"),
|
||||||
|
("Pacific/Honolulu", "Hawaii Time (HT)"),
|
||||||
|
("UTC", "UTC"),
|
||||||
|
]
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
services:
|
services:
|
||||||
|
|
||||||
# --- TERRA-VIEW PRODUCTION ---
|
# --- TERRA-VIEW PRODUCTION ---
|
||||||
terra-view-prod:
|
terra-view:
|
||||||
build: .
|
build: .
|
||||||
container_name: terra-view
|
container_name: terra-view
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ jinja2==3.1.2
|
|||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
httpx==0.25.2
|
httpx==0.25.2
|
||||||
|
openpyxl==3.1.2
|
||||||
|
|||||||
@@ -1,6 +1,23 @@
|
|||||||
unit_id,unit_type,deployed,retired,note,project_id,location
|
unit_id,device_type,unit_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model,slm_host,slm_tcp_port,slm_ftp_port,slm_model,slm_serial_number,slm_frequency_weighting,slm_time_weighting,slm_measurement_range
|
||||||
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
|
# ============================================
|
||||||
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
|
# SEISMOGRAPHS (device_type=seismograph)
|
||||||
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
|
# ============================================
|
||||||
BE3456,series3,true,false,,PROJ-003,New York NY
|
BE1234,seismograph,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,2025-06-15,2026-06-15,MDM001,,,,,,,,,,,
|
||||||
BE7890,series3,false,true,Decommissioned 2024,,Storage
|
BE5678,seismograph,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,2025-03-01,2026-03-01,MDM002,,,,,,,,,,,
|
||||||
|
BE9012,seismograph,series4,false,false,In maintenance - needs calibration,PROJ-002,Workshop,789 Industrial Way,,,,,,,,,,,,,,
|
||||||
|
BE3456,seismograph,series3,true,false,,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,2025-01-10,2026-01-10,,,,,,,,,,,
|
||||||
|
BE7890,seismograph,series3,false,true,Decommissioned 2024,,Storage,Warehouse B,,,,,,,,,,,,,,,
|
||||||
|
# ============================================
|
||||||
|
# MODEMS (device_type=modem)
|
||||||
|
# ============================================
|
||||||
|
MDM001,modem,,true,false,Cradlepoint at SF site,PROJ-001,San Francisco CA,123 Market St,37.7749;-122.4194,,,,,192.168.1.100,+1-555-0101,IBR900,,,,,,,
|
||||||
|
MDM002,modem,,true,false,Sierra Wireless at LA site,PROJ-001,Los Angeles CA,456 Sunset Blvd,34.0522;-118.2437,,,,,10.0.0.50,+1-555-0102,RV55,,,,,,,
|
||||||
|
MDM003,modem,,false,false,Spare modem in storage,,,Storage,Warehouse A,,,,,,+1-555-0103,IBR600,,,,,,,
|
||||||
|
MDM004,modem,,true,false,NYC backup modem,PROJ-003,New York NY,101 Broadway,40.7128;-74.0060,,,,,172.16.0.25,+1-555-0104,IBR1700,,,,,,,
|
||||||
|
# ============================================
|
||||||
|
# SOUND LEVEL METERS (device_type=slm)
|
||||||
|
# ============================================
|
||||||
|
SLM001,slm,,true,false,NL-43 at construction site A,PROJ-004,Downtown Site,500 Main St,40.7589;-73.9851,,,,,,,,192.168.10.101,2255,21,NL-43,12345678,A,F,30-130 dB
|
||||||
|
SLM002,slm,,true,false,NL-43 at construction site B,PROJ-004,Midtown Site,600 Park Ave,40.7614;-73.9776,,,MDM004,,,,,192.168.10.102,2255,21,NL-43,12345679,A,S,30-130 dB
|
||||||
|
SLM003,slm,,false,false,NL-53 spare unit,,,Storage,Warehouse A,,,,,,,,,,,NL-53,98765432,C,F,25-138 dB
|
||||||
|
SLM004,slm,,true,false,NL-43 nighttime monitoring,PROJ-005,Residential Area,200 Quiet Lane,40.7484;-73.9857,,,,,,,,10.0.5.50,2255,21,NL-43,11112222,A,S,30-130 dB
|
||||||
|
|||||||
|
@@ -20,6 +20,9 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
|
||||||
|
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
|
||||||
<meta name="theme-color" content="#f48b1c">
|
<meta name="theme-color" content="#f48b1c">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -68,7 +71,7 @@
|
|||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
<!-- Offline Indicator -->
|
<!-- Offline Indicator -->
|
||||||
<div id="offlineIndicator" class="offline-indicator">
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
@@ -85,10 +88,10 @@
|
|||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
<a href="/" class="block">
|
||||||
Seismo<br>
|
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
|
||||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
|
||||||
</h1>
|
</a>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
{% if environment == 'development' %}
|
{% if environment == 'development' %}
|
||||||
@@ -127,6 +130,13 @@
|
|||||||
Sound Level Meters
|
Sound Level Meters
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Modems
|
||||||
|
</a>
|
||||||
|
|
||||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
@@ -374,10 +384,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.4.3"></script>
|
<script src="/static/offline-db.js?v=0.5.1"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.4.3"></script>
|
<script src="/static/mobile.js?v=0.5.1"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -27,10 +27,10 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="updateDashboard(event)">
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Fleet Summary Card -->
|
<!-- Fleet Summary Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Alerts Card -->
|
<!-- Recent Alerts Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recently Called In Units Card -->
|
<!-- Recently Called In Units Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -162,10 +162,33 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Today's Scheduled Actions Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
||||||
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-content" id="todays-actions-content"
|
||||||
|
hx-get="/dashboard/todays-actions"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Map -->
|
<!-- Fleet Map -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -181,7 +204,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Photos Section -->
|
<!-- Recent Photos Section -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -201,7 +224,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
@@ -316,7 +339,7 @@ function toggleCard(cardName) {
|
|||||||
// Restore card states from localStorage on page load
|
// Restore card states from localStorage on page load
|
||||||
function restoreCardStates() {
|
function restoreCardStates() {
|
||||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
cardNames.forEach(cardName => {
|
cardNames.forEach(cardName => {
|
||||||
const content = document.getElementById(`${cardName}-content`);
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
|||||||
108
templates/modems.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Field Modems - Terra-View{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Field Modems
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage network connectivity devices for field equipment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
|
hx-get="/api/modem-dashboard/stats"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Stats will be loaded here -->
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem List -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Modems</h2>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text"
|
||||||
|
id="modem-search"
|
||||||
|
placeholder="Search modems..."
|
||||||
|
class="pl-9 pr-4 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
|
||||||
|
hx-get="/api/modem-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#modem-list"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
name="search">
|
||||||
|
<svg class="w-4 h-4 absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<a href="/roster?device_type=modem" class="text-sm text-seismo-orange hover:underline">
|
||||||
|
Add modem in roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="modem-list"
|
||||||
|
hx-get="/api/modem-dashboard/units"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-40 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Ping a modem and show result
|
||||||
|
async function pingModem(modemId) {
|
||||||
|
const btn = document.getElementById(`ping-btn-${modemId}`);
|
||||||
|
const resultDiv = document.getElementById(`ping-result-${modemId}`);
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = 'Pinging...';
|
||||||
|
btn.disabled = true;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
resultDiv.className = 'mt-2 text-xs text-gray-500';
|
||||||
|
resultDiv.textContent = 'Testing connection...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/modem-dashboard/${modemId}/ping`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
resultDiv.className = 'mt-2 text-xs text-green-600 dark:text-green-400';
|
||||||
|
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
|
||||||
|
resultDiv.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'mt-2 text-xs text-red-600 dark:text-red-400';
|
||||||
|
resultDiv.textContent = 'Error: ' + error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore button
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
|
||||||
|
// Hide result after 10 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
}, 10000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
|
||||||
<div class="text-gray-900 dark:text-white">{{ location.created_at.strftime('%Y-%m-%d %H:%M') if location.created_at else 'N/A' }}</div>
|
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
{% if assignment %}
|
{% if assignment %}
|
||||||
<div>
|
<div>
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
||||||
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at.strftime('%Y-%m-%d %H:%M') if assignment.assigned_at else 'N/A' }}</div>
|
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% if assignment.notes %}
|
{% if assignment.notes %}
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
87
templates/partials/alerts/alert_dropdown.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<!-- Alert Dropdown Content -->
|
||||||
|
<!-- Loaded via HTMX into the alert dropdown in the navbar -->
|
||||||
|
|
||||||
|
<div class="max-h-96 overflow-y-auto">
|
||||||
|
{% if alerts %}
|
||||||
|
{% for item in alerts %}
|
||||||
|
<div class="p-3 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors
|
||||||
|
{% if item.alert.severity == 'critical' %}bg-red-50 dark:bg-red-900/20{% endif %}">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Severity icon -->
|
||||||
|
{% if item.alert.severity == 'critical' %}
|
||||||
|
<span class="text-red-500 flex-shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% elif item.alert.severity == 'warning' %}
|
||||||
|
<span class="text-yellow-500 flex-shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-blue-500 flex-shrink-0 mt-0.5">
|
||||||
|
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{{ item.alert.title }}
|
||||||
|
</p>
|
||||||
|
{% if item.alert.message %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-0.5">
|
||||||
|
{{ item.alert.message }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||||
|
{{ item.time_ago }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<button hx-post="/api/alerts/{{ item.alert.id }}/acknowledge"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#alert-dropdown-content', 'refresh')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded transition-colors"
|
||||||
|
title="Acknowledge">
|
||||||
|
<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="M5 13l4 4L19 7"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button hx-post="/api/alerts/{{ item.alert.id }}/dismiss"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#alert-dropdown-content', 'refresh')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded transition-colors"
|
||||||
|
title="Dismiss">
|
||||||
|
<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="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="p-8 text-center">
|
||||||
|
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">No active alerts</p>
|
||||||
|
<p class="text-gray-400 dark:text-gray-500 text-xs mt-1">All systems operational</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View all link -->
|
||||||
|
{% if total_count > 0 %}
|
||||||
|
<div class="p-3 border-t border-gray-200 dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<a href="/alerts" class="text-sm text-seismo-orange hover:text-seismo-navy dark:hover:text-orange-300 font-medium">
|
||||||
|
View all {{ total_count }} alert{{ 's' if total_count != 1 else '' }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
125
templates/partials/alerts/alert_list.html
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
<!-- Alert List Partial -->
|
||||||
|
<!-- Full list of alerts for the alerts page -->
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% if alerts %}
|
||||||
|
{% for item in alerts %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
||||||
|
{% if item.alert.severity == 'critical' and item.alert.status == 'active' %}border-l-4 border-l-red-500{% endif %}
|
||||||
|
{% if item.alert.severity == 'warning' and item.alert.status == 'active' %}border-l-4 border-l-yellow-500{% endif %}
|
||||||
|
{% if item.alert.status != 'active' %}opacity-60{% endif %}">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Severity icon -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if item.alert.severity == 'critical' %}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif item.alert.severity == 'warning' %}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-yellow-100 dark:bg-yellow-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="w-10 h-10 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ item.alert.title }}
|
||||||
|
</h3>
|
||||||
|
<!-- Status badge -->
|
||||||
|
{% if item.alert.status == 'active' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{% elif item.alert.status == 'acknowledged' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-300">
|
||||||
|
Acknowledged
|
||||||
|
</span>
|
||||||
|
{% elif item.alert.status == 'resolved' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Resolved
|
||||||
|
</span>
|
||||||
|
{% elif item.alert.status == 'dismissed' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
Dismissed
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if item.alert.message %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 mb-2">
|
||||||
|
{{ item.alert.message }}
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>{{ item.time_ago }}</span>
|
||||||
|
{% if item.alert.unit_id %}
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<svg class="w-3.5 h-3.5" 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"/>
|
||||||
|
</svg>
|
||||||
|
{{ item.alert.unit_id }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="capitalize">{{ item.alert.alert_type | replace('_', ' ') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
{% if item.alert.status == 'active' %}
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
<button hx-post="/api/alerts/{{ item.alert.id }}/acknowledge"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
|
||||||
|
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Acknowledge
|
||||||
|
</button>
|
||||||
|
<button hx-post="/api/alerts/{{ item.alert.id }}/resolve"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
|
||||||
|
class="px-3 py-1.5 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors">
|
||||||
|
Resolve
|
||||||
|
</button>
|
||||||
|
<button hx-post="/api/alerts/{{ item.alert.id }}/dismiss"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#alert-list', 'refresh')"
|
||||||
|
class="px-3 py-1.5 text-sm text-gray-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Dismiss">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
<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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No alerts</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">
|
||||||
|
{% if status_filter %}
|
||||||
|
No {{ status_filter }} alerts found.
|
||||||
|
{% else %}
|
||||||
|
All systems are operating normally.
|
||||||
|
{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
131
templates/partials/dashboard/todays_actions.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<!-- Today's Scheduled Actions - Dashboard Card Content -->
|
||||||
|
|
||||||
|
<!-- Summary stats -->
|
||||||
|
<div class="flex items-center gap-4 mb-4 text-sm">
|
||||||
|
{% if pending_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-yellow-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ pending_count }} pending</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if completed_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-green-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ completed_count }} completed</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if failed_count > 0 %}
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="w-2 h-2 bg-red-400 rounded-full"></span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">{{ failed_count }} failed</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if total_count == 0 %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">No actions scheduled for today</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions list -->
|
||||||
|
{% if actions %}
|
||||||
|
<div class="space-y-2 max-h-64 overflow-y-auto">
|
||||||
|
{% for item in actions %}
|
||||||
|
<div class="flex items-center gap-3 p-2 rounded-lg
|
||||||
|
{% if item.action.execution_status == 'pending' %}bg-yellow-50 dark:bg-yellow-900/20
|
||||||
|
{% elif item.action.execution_status == 'completed' %}bg-green-50 dark:bg-green-900/20
|
||||||
|
{% elif item.action.execution_status == 'failed' %}bg-red-50 dark:bg-red-900/20
|
||||||
|
{% else %}bg-gray-50 dark:bg-gray-700/50{% endif %}">
|
||||||
|
|
||||||
|
<!-- Action type icon -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if item.action.action_type == 'start' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-green-600 dark:text-green-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif item.action.action_type == 'stop' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-red-600 dark:text-red-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% elif item.action.action_type == 'download' %}
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center">
|
||||||
|
<svg class="w-4 h-4 text-blue-600 dark:text-blue-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action details -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-sm text-gray-900 dark:text-white capitalize">{{ item.action.action_type }}</span>
|
||||||
|
|
||||||
|
<!-- Status indicator -->
|
||||||
|
{% if item.action.execution_status == 'pending' %}
|
||||||
|
<span class="text-xs text-yellow-600 dark:text-yellow-400">
|
||||||
|
{{ item.action.scheduled_time|local_datetime('%H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% elif item.action.execution_status == 'completed' %}
|
||||||
|
<svg class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% elif item.action.execution_status == 'failed' %}
|
||||||
|
<svg class="w-4 h-4 text-red-500" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location/Project info -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{% if item.location %}
|
||||||
|
<a href="/projects/{{ item.action.project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="hover:text-seismo-orange">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
{% elif item.project %}
|
||||||
|
<a href="/projects/{{ item.project.id }}" class="hover:text-seismo-orange">
|
||||||
|
{{ item.project.name }}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Result details for completed/failed -->
|
||||||
|
{% if item.action.execution_status == 'completed' and item.result %}
|
||||||
|
{% if item.result.cycle_response and item.result.cycle_response.downloaded_folder %}
|
||||||
|
<div class="text-xs text-green-600 dark:text-green-400">
|
||||||
|
{{ item.result.cycle_response.downloaded_folder }}
|
||||||
|
{% if item.result.cycle_response.download_success %}downloaded{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.action.execution_status == 'failed' and item.action.error_message %}
|
||||||
|
<div class="text-xs text-red-600 dark:text-red-400 truncate" title="{{ item.action.error_message }}">
|
||||||
|
{{ item.action.error_message[:50] }}{% if item.action.error_message|length > 50 %}...{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="flex-shrink-0 text-right">
|
||||||
|
{% if item.action.execution_status == 'pending' %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">Scheduled</span>
|
||||||
|
{% elif item.action.executed_at %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{{ item.action.executed_at|local_datetime('%H:%M') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-6 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-10 h-10 mx-auto mb-2 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="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>
|
||||||
|
<p class="text-sm">No scheduled actions for today</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
89
templates/partials/modem_list.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
<!-- Modem List -->
|
||||||
|
{% if modems %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{% for modem in modems %}
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ modem.id }}" class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
{{ modem.id }}
|
||||||
|
</a>
|
||||||
|
{% if modem.hardware_model %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{{ modem.hardware_model }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if modem.ip_address %}
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-mono mt-1">{{ modem.ip_address }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-red-500 mt-1">No IP configured</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if modem.phone_number %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ modem.phone_number }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Badge -->
|
||||||
|
{% if modem.status == "retired" %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
||||||
|
{% elif modem.status == "benched" %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
||||||
|
{% elif modem.status == "in_use" %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">In Use</span>
|
||||||
|
{% elif modem.status == "spare" %}
|
||||||
|
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Spare</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Paired Device -->
|
||||||
|
{% if modem.paired_device %}
|
||||||
|
<div class="mt-3 pt-3 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Paired with:</p>
|
||||||
|
<a href="/unit/{{ modem.paired_device.id }}" class="text-sm text-seismo-orange hover:underline">
|
||||||
|
{{ modem.paired_device.id }}
|
||||||
|
<span class="text-gray-500">({{ modem.paired_device.device_type }})</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Location if available -->
|
||||||
|
{% if modem.location or modem.project_id %}
|
||||||
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{% if modem.project_id %}
|
||||||
|
<span class="bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">{{ modem.project_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if modem.location %}
|
||||||
|
{{ modem.location }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-3 flex gap-2">
|
||||||
|
<button onclick="pingModem('{{ modem.id }}')"
|
||||||
|
id="ping-btn-{{ modem.id }}"
|
||||||
|
class="text-xs px-3 py-1.5 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded transition-colors">
|
||||||
|
Ping
|
||||||
|
</button>
|
||||||
|
<a href="/unit/{{ modem.id }}"
|
||||||
|
class="text-xs px-3 py-1.5 bg-gray-100 hover:bg-gray-200 text-gray-700 dark:bg-gray-700 dark:hover:bg-gray-600 dark:text-gray-300 rounded transition-colors">
|
||||||
|
Details
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ping Result (hidden by default) -->
|
||||||
|
<div id="ping-result-{{ modem.id }}" class="mt-2 text-xs hidden"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No modems found</p>
|
||||||
|
<p class="text-sm mt-1">Add modems from the <a href="/roster" class="text-seismo-orange hover:underline">Fleet Roster</a></p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
51
templates/partials/modem_paired_device.html
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<!-- Paired Device Info for Modem Detail Page -->
|
||||||
|
{% if device %}
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-green-50 dark:bg-green-900/20 rounded-lg">
|
||||||
|
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||||
|
{% if device.device_type == "slm" %}
|
||||||
|
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{% else %}
|
||||||
|
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 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>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Currently paired with</p>
|
||||||
|
<a href="/unit/{{ device.id }}" class="text-lg font-semibold text-green-700 dark:text-green-400 hover:underline">
|
||||||
|
{{ device.id }}
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-2 mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span class="capitalize">{{ device.device_type }}</span>
|
||||||
|
{% if device.project_id %}
|
||||||
|
<span class="text-gray-400">|</span>
|
||||||
|
<span>{{ device.project_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if device.deployed %}
|
||||||
|
<span class="px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 text-xs rounded">Deployed</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-1.5 py-0.5 bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 text-xs rounded">Benched</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a href="/unit/{{ device.id }}" class="text-gray-400 hover:text-seismo-orange transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex items-center gap-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||||
|
<svg class="w-6 h-6 text-gray-500 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1">
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">No device currently paired</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-500">This modem is available for assignment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
128
templates/partials/modem_picker.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{#
|
||||||
|
Modem Picker Component
|
||||||
|
A reusable HTMX-based autocomplete for selecting modems.
|
||||||
|
|
||||||
|
Usage: include "partials/modem_picker.html" with context
|
||||||
|
|
||||||
|
Variables available in context:
|
||||||
|
- selected_modem_id: Pre-selected modem ID (optional)
|
||||||
|
- selected_modem_display: Display text for pre-selected modem (optional)
|
||||||
|
- input_name: Name attribute for the hidden input (default: "deployed_with_modem_id")
|
||||||
|
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set picker_id = picker_id|default("") %}
|
||||||
|
{% set input_name = input_name|default("deployed_with_modem_id") %}
|
||||||
|
{% set selected_modem_id = selected_modem_id|default("") %}
|
||||||
|
{% set selected_modem_display = selected_modem_display|default("") %}
|
||||||
|
|
||||||
|
<div class="modem-picker relative" id="modem-picker-container{{ picker_id }}">
|
||||||
|
<!-- Hidden input for form submission (stores modem ID) -->
|
||||||
|
<input type="hidden"
|
||||||
|
name="{{ input_name }}"
|
||||||
|
id="modem-picker-value{{ picker_id }}"
|
||||||
|
value="{{ selected_modem_id }}">
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text"
|
||||||
|
id="modem-picker-search{{ picker_id }}"
|
||||||
|
placeholder="Search by modem ID, IP, or note..."
|
||||||
|
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{ selected_modem_display }}"
|
||||||
|
hx-get="/api/roster/search/modems"
|
||||||
|
hx-trigger="keyup changed delay:300ms, focus"
|
||||||
|
hx-target="#modem-picker-dropdown{{ picker_id }}"
|
||||||
|
hx-vals='{"picker_id": "{{ picker_id }}"}'
|
||||||
|
name="q"
|
||||||
|
onfocus="showModemDropdown('{{ picker_id }}')"
|
||||||
|
oninput="handleModemSearchInput('{{ picker_id }}', this.value)">
|
||||||
|
|
||||||
|
<!-- Search icon -->
|
||||||
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button (shown when modem is selected) -->
|
||||||
|
<button type="button"
|
||||||
|
id="modem-picker-clear{{ picker_id }}"
|
||||||
|
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_modem_id else '' }}"
|
||||||
|
onclick="clearModemSelection('{{ picker_id }}')"
|
||||||
|
title="Clear selection">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown Results Container -->
|
||||||
|
<div id="modem-picker-dropdown{{ picker_id }}"
|
||||||
|
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
<!-- Results loaded via HTMX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{# Modem picker functions - defined once, work for any picker_id #}
|
||||||
|
if (typeof selectModem === 'undefined') {
|
||||||
|
function selectModem(modemId, displayText, pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||||
|
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = modemId;
|
||||||
|
if (searchInput) searchInput.value = displayText;
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModemSelection(pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModemDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModemDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('modem-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleModemSearchInput(pickerId, value) {
|
||||||
|
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
// If user clears the search box, also clear the hidden value
|
||||||
|
if (!value.trim()) {
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const pickers = document.querySelectorAll('.modem-picker');
|
||||||
|
pickers.forEach(picker => {
|
||||||
|
if (!picker.contains(event.target)) {
|
||||||
|
const dropdown = picker.querySelector('[id^="modem-picker-dropdown"]');
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
61
templates/partials/modem_search_results.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{#
|
||||||
|
Modem Search Results Partial
|
||||||
|
Rendered by /api/roster/search/modems endpoint for HTMX dropdown.
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- modems: List of modem dicts with id, ip_address, phone_number, note, deployed, display
|
||||||
|
- query: The search query string
|
||||||
|
- show_empty: Boolean - show "no results" message
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||||
|
|
||||||
|
{% if modems %}
|
||||||
|
{% for modem in modems %}
|
||||||
|
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||||
|
onclick="selectModem('{{ modem.id }}', '{{ modem.display|e }}', '{{ picker_id }}')">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
<span class="text-seismo-orange font-semibold">{{ modem.id }}</span>
|
||||||
|
{% if modem.ip_address %}
|
||||||
|
<span class="text-gray-400 mx-1">-</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400 font-mono text-sm">{{ modem.ip_address }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if modem.note %}
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{{ modem.note }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if not modem.deployed %}
|
||||||
|
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_empty %}
|
||||||
|
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No modems found matching "{{ query }}"</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not modems and not show_empty %}
|
||||||
|
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Start typing to search modems...</p>
|
||||||
|
<p class="text-xs mt-1">Search by modem ID, IP address, or note</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
63
templates/partials/modem_stats.html
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
<!-- Modem summary stat cards -->
|
||||||
|
|
||||||
|
<!-- Total Modems Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Modems</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- In Use Card (paired with device) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">In Use</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ in_use_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Paired with a device</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spare Card (deployed but not paired) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Spare</p>
|
||||||
|
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ spare_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Available for assignment</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benched Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" 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>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
233
templates/partials/project_create_modal.html
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
{#
|
||||||
|
Quick Create Project Modal
|
||||||
|
Allows inline creation of a new project from the project picker.
|
||||||
|
|
||||||
|
Include this modal in pages that use the project picker.
|
||||||
|
#}
|
||||||
|
|
||||||
|
<div id="quickCreateProjectModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||||
|
<button type="button" onclick="closeCreateProjectModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="quickCreateProjectForm" class="p-6 space-y-4">
|
||||||
|
<!-- Hidden field to track which picker opened this modal -->
|
||||||
|
<input type="hidden" id="qcp-picker-id" value="">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Project Number
|
||||||
|
<span class="text-gray-400 font-normal">(xxxx-YY)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="project_number"
|
||||||
|
id="qcp-project-number"
|
||||||
|
pattern="\d{4}-\d{2}"
|
||||||
|
placeholder="2567-23"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">TMI internal project number (optional)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Client Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="client_name"
|
||||||
|
id="qcp-client-name"
|
||||||
|
required
|
||||||
|
placeholder="PJ Dick, Turner Construction, etc."
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Project Name <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<input type="text"
|
||||||
|
name="name"
|
||||||
|
id="qcp-project-name"
|
||||||
|
required
|
||||||
|
placeholder="RKM Hall, CMU Campus, Building 7, etc."
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Site or building name</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Project Type <span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<select name="project_type_id"
|
||||||
|
id="qcp-project-type"
|
||||||
|
required
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="vibration_monitoring">Vibration Monitoring</option>
|
||||||
|
<option value="sound_monitoring">Sound Monitoring</option>
|
||||||
|
<option value="combined">Combined (Vibration + Sound)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="qcp-error" class="hidden p-3 bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 rounded-lg text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
<button type="submit"
|
||||||
|
id="qcp-submit-btn"
|
||||||
|
class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors flex items-center justify-center gap-2">
|
||||||
|
<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="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
Create & Select
|
||||||
|
</button>
|
||||||
|
<button type="button"
|
||||||
|
onclick="closeCreateProjectModal()"
|
||||||
|
class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Quick create project modal functions
|
||||||
|
if (typeof openCreateProjectModal === 'undefined') {
|
||||||
|
function openCreateProjectModal(searchQuery, pickerId = '') {
|
||||||
|
const modal = document.getElementById('quickCreateProjectModal');
|
||||||
|
const pickerIdInput = document.getElementById('qcp-picker-id');
|
||||||
|
const projectNumInput = document.getElementById('qcp-project-number');
|
||||||
|
const clientNameInput = document.getElementById('qcp-client-name');
|
||||||
|
const projectNameInput = document.getElementById('qcp-project-name');
|
||||||
|
const errorDiv = document.getElementById('qcp-error');
|
||||||
|
|
||||||
|
// Store which picker opened this
|
||||||
|
if (pickerIdInput) pickerIdInput.value = pickerId;
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('quickCreateProjectForm').reset();
|
||||||
|
if (errorDiv) errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Try to parse the search query intelligently
|
||||||
|
if (searchQuery) {
|
||||||
|
// Check if it looks like a project number (xxxx-YY pattern)
|
||||||
|
const projectNumMatch = searchQuery.match(/(\d{4}-\d{2})/);
|
||||||
|
if (projectNumMatch) {
|
||||||
|
if (projectNumInput) projectNumInput.value = projectNumMatch[1];
|
||||||
|
// If there's more after the number, use it as client name
|
||||||
|
const remainder = searchQuery.replace(projectNumMatch[1], '').replace(/^[\s\-]+/, '').trim();
|
||||||
|
if (remainder && clientNameInput) clientNameInput.value = remainder;
|
||||||
|
} else {
|
||||||
|
// Not a project number - assume it's client or project name
|
||||||
|
// If short (likely a name fragment), put it in client name
|
||||||
|
if (clientNameInput) clientNameInput.value = searchQuery;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
if (modal) modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Focus the first empty required field
|
||||||
|
if (clientNameInput && !clientNameInput.value) {
|
||||||
|
clientNameInput.focus();
|
||||||
|
} else if (projectNameInput) {
|
||||||
|
projectNameInput.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCreateProjectModal() {
|
||||||
|
const modal = document.getElementById('quickCreateProjectModal');
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle quick create form submission
|
||||||
|
document.getElementById('quickCreateProjectForm')?.addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const submitBtn = document.getElementById('qcp-submit-btn');
|
||||||
|
const errorDiv = document.getElementById('qcp-error');
|
||||||
|
const pickerId = document.getElementById('qcp-picker-id')?.value || '';
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const originalBtnText = submitBtn.innerHTML;
|
||||||
|
submitBtn.disabled = true;
|
||||||
|
submitBtn.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Creating...
|
||||||
|
`;
|
||||||
|
if (errorDiv) errorDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects/create', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && result.success) {
|
||||||
|
// Build display text from form values
|
||||||
|
const parts = [];
|
||||||
|
const projectNumber = formData.get('project_number');
|
||||||
|
const clientName = formData.get('client_name');
|
||||||
|
const projectName = formData.get('name');
|
||||||
|
|
||||||
|
if (projectNumber) parts.push(projectNumber);
|
||||||
|
if (clientName) parts.push(clientName);
|
||||||
|
if (projectName) parts.push(projectName);
|
||||||
|
|
||||||
|
const displayText = parts.join(' - ');
|
||||||
|
|
||||||
|
// Select the newly created project in the picker
|
||||||
|
selectProject(result.project_id, displayText, pickerId);
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
closeCreateProjectModal();
|
||||||
|
} else {
|
||||||
|
// Show error
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.textContent = result.detail || result.message || 'Failed to create project';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (errorDiv) {
|
||||||
|
errorDiv.textContent = `Error: ${error.message}`;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// Restore button
|
||||||
|
submitBtn.disabled = false;
|
||||||
|
submitBtn.innerHTML = originalBtnText;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on backdrop click
|
||||||
|
document.getElementById('quickCreateProjectModal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeCreateProjectModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal on Escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
const modal = document.getElementById('quickCreateProjectModal');
|
||||||
|
if (modal && !modal.classList.contains('hidden')) {
|
||||||
|
closeCreateProjectModal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
128
templates/partials/project_picker.html
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
{#
|
||||||
|
Project Picker Component
|
||||||
|
A reusable HTMX-based autocomplete for selecting projects.
|
||||||
|
|
||||||
|
Usage: include "partials/project_picker.html" with context
|
||||||
|
|
||||||
|
Variables available in context:
|
||||||
|
- selected_project_id: Pre-selected project UUID (optional)
|
||||||
|
- selected_project_display: Display text for pre-selected project (optional)
|
||||||
|
- input_name: Name attribute for the hidden input (default: "project_id")
|
||||||
|
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set picker_id = picker_id|default("") %}
|
||||||
|
{% set input_name = input_name|default("project_id") %}
|
||||||
|
{% set selected_project_id = selected_project_id|default("") %}
|
||||||
|
{% set selected_project_display = selected_project_display|default("") %}
|
||||||
|
|
||||||
|
<div class="project-picker relative" id="project-picker-container{{ picker_id }}">
|
||||||
|
<!-- Hidden input for form submission (stores project UUID) -->
|
||||||
|
<input type="hidden"
|
||||||
|
name="{{ input_name }}"
|
||||||
|
id="project-picker-value{{ picker_id }}"
|
||||||
|
value="{{ selected_project_id }}">
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text"
|
||||||
|
id="project-picker-search{{ picker_id }}"
|
||||||
|
placeholder="Search by project number, client, or name..."
|
||||||
|
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{ selected_project_display }}"
|
||||||
|
hx-get="/api/projects/search"
|
||||||
|
hx-trigger="keyup changed delay:300ms, focus"
|
||||||
|
hx-target="#project-picker-dropdown{{ picker_id }}"
|
||||||
|
hx-vals='{"picker_id": "{{ picker_id }}"}'
|
||||||
|
name="q"
|
||||||
|
onfocus="showProjectDropdown('{{ picker_id }}')"
|
||||||
|
oninput="handleProjectSearchInput('{{ picker_id }}', this.value)">
|
||||||
|
|
||||||
|
<!-- Search icon -->
|
||||||
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button (shown when project is selected) -->
|
||||||
|
<button type="button"
|
||||||
|
id="project-picker-clear{{ picker_id }}"
|
||||||
|
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_project_id else '' }}"
|
||||||
|
onclick="clearProjectSelection('{{ picker_id }}')"
|
||||||
|
title="Clear selection">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown Results Container -->
|
||||||
|
<div id="project-picker-dropdown{{ picker_id }}"
|
||||||
|
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
<!-- Results loaded via HTMX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Project picker functions - defined once, work for any picker_id
|
||||||
|
if (typeof selectProject === 'undefined') {
|
||||||
|
function selectProject(projectId, displayText, pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('project-picker-search' + pickerId);
|
||||||
|
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = projectId;
|
||||||
|
if (searchInput) searchInput.value = displayText;
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProjectSelection(pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('project-picker-search' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showProjectDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideProjectDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('project-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleProjectSearchInput(pickerId, value) {
|
||||||
|
const valueInput = document.getElementById('project-picker-value' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('project-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
// If user clears the search box, also clear the hidden value
|
||||||
|
if (!value.trim()) {
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const pickers = document.querySelectorAll('.project-picker');
|
||||||
|
pickers.forEach(picker => {
|
||||||
|
if (!picker.contains(event.target)) {
|
||||||
|
const dropdown = picker.querySelector('[id^="project-picker-dropdown"]');
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
69
templates/partials/project_search_results.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<!--
|
||||||
|
Project Search Results Partial
|
||||||
|
Rendered by /api/projects/search endpoint for HTMX dropdown.
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- projects: List of project dicts with id, project_number, client_name, name, display, status
|
||||||
|
- query: The search query string
|
||||||
|
- show_create: Boolean - show "Create new project" option when no matches
|
||||||
|
-->
|
||||||
|
|
||||||
|
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||||
|
|
||||||
|
{% if projects %}
|
||||||
|
{% for project in projects %}
|
||||||
|
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||||
|
onclick="selectProject('{{ project.id }}', '{{ project.display|e }}', '{{ picker_id }}')">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
{% if project.project_number %}
|
||||||
|
<span class="text-seismo-orange font-semibold">{{ project.project_number }}</span>
|
||||||
|
{% if project.client_name or project.name %}
|
||||||
|
<span class="text-gray-400 mx-1">-</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
{% if project.client_name %}
|
||||||
|
<span>{{ project.client_name }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if project.name %}
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{{ project.name }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if project.status == 'completed' %}
|
||||||
|
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||||
|
Completed
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_create %}
|
||||||
|
<div class="px-4 py-3 hover:bg-green-50 dark:hover:bg-green-900/30 cursor-pointer border-t border-gray-200 dark:border-gray-600 transition-colors"
|
||||||
|
onclick="openCreateProjectModal('{{ query|e }}', '{{ picker_id }}')">
|
||||||
|
<div class="flex items-center gap-2 text-green-600 dark:text-green-400">
|
||||||
|
<svg class="w-5 h-5 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium">Create new project "{{ query }}"</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-7">
|
||||||
|
No matching projects found. Click to create a new one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not projects and not show_create %}
|
||||||
|
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Start typing to search projects...</p>
|
||||||
|
<p class="text-xs mt-1">Search by project number, client name, or project name</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}{% else %}Unknown{% endif %}
|
Assigned: {% if item.assignment.assigned_at %}{{ item.assignment.assigned_at|local_datetime }}{% else %}Unknown{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||||
|
|||||||
@@ -22,6 +22,16 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<button onclick="showFTPSettings('{{ unit_item.unit.id }}')"
|
||||||
|
id="settings-ftp-{{ unit_item.unit.id }}"
|
||||||
|
class="px-3 py-1 text-xs bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Configure FTP credentials">
|
||||||
|
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
|
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
|
||||||
id="enable-ftp-{{ unit_item.unit.id }}"
|
id="enable-ftp-{{ unit_item.unit.id }}"
|
||||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
||||||
@@ -605,3 +615,6 @@ setTimeout(function() {
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
}, 100);
|
}, 100);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Include the unified SLM Settings Modal -->
|
||||||
|
{% include 'partials/slm_settings_modal.html' %}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
{% for action in upcoming_actions %}
|
{% for action in upcoming_actions %}
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-3">
|
||||||
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
<p class="font-medium text-gray-900 dark:text-white">{{ action.action_type }}</p>
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time|local_datetime }} {{ timezone_abbr() }}</p>
|
||||||
{% if action.description %}
|
{% if action.description %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||||
@@ -11,4 +13,18 @@
|
|||||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Project Actions -->
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||||
|
<a href="/api/projects/{{ project.id }}/generate-combined-report"
|
||||||
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2 text-sm">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Generate Combined Report
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
151
templates/partials/projects/recurring_schedule_list.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!-- Recurring Schedule List -->
|
||||||
|
<!-- Displays all recurring schedules for a project -->
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if schedules %}
|
||||||
|
{% for item in schedules %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4
|
||||||
|
{% if not item.schedule.enabled %}opacity-60{% endif %}">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<h4 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||||
|
{{ item.schedule.name }}
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Type badge -->
|
||||||
|
{% if item.schedule.schedule_type == 'weekly_calendar' %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
Weekly
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||||
|
24/7 Cycle
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Status badge -->
|
||||||
|
{% if item.schedule.enabled %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 text-xs font-medium rounded-full bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
Disabled
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location info -->
|
||||||
|
{% if item.location %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
<span class="text-gray-500">Location:</span>
|
||||||
|
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||||
|
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||||
|
{{ item.location.name }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Schedule details -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 space-y-1">
|
||||||
|
{% if item.schedule.schedule_type == 'weekly_calendar' and item.pattern %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% set days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] %}
|
||||||
|
{% set day_abbr = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] %}
|
||||||
|
{% for day in days %}
|
||||||
|
{% if item.pattern.get(day, {}).get('enabled') %}
|
||||||
|
<span class="px-2 py-0.5 text-xs bg-gray-100 dark:bg-gray-700 rounded">
|
||||||
|
{{ day_abbr[loop.index0] }}
|
||||||
|
{{ item.pattern[day].get('start', '') }}-{{ item.pattern[day].get('end', '') }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% elif item.schedule.schedule_type == 'simple_interval' %}
|
||||||
|
<div>
|
||||||
|
Cycle at {{ item.schedule.cycle_time or '00:00' }} daily
|
||||||
|
{% if item.schedule.include_download %}
|
||||||
|
(with download)
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if item.schedule.next_occurrence %}
|
||||||
|
<div class="text-xs">
|
||||||
|
<span class="text-gray-400">Next:</span>
|
||||||
|
{{ item.schedule.next_occurrence|local_datetime }} {{ timezone_abbr() }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
|
{% if item.schedule.enabled %}
|
||||||
|
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/disable"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
|
||||||
|
class="px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Disable
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button hx-post="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}/enable"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
|
||||||
|
class="px-3 py-1.5 text-sm bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 rounded-lg hover:bg-green-200 dark:hover:bg-green-900/50 transition-colors">
|
||||||
|
Enable
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button onclick="editSchedule('{{ item.schedule.id }}')"
|
||||||
|
class="px-3 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-delete="/api/projects/{{ project_id }}/recurring-schedules/{{ item.schedule.id }}"
|
||||||
|
hx-confirm="Delete this recurring schedule?"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#recurring-schedule-list', 'refresh')"
|
||||||
|
class="p-1.5 text-gray-400 hover:text-red-600 dark:hover:text-red-400 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-12 text-center">
|
||||||
|
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">No recurring schedules</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-4">
|
||||||
|
Create a schedule to automate monitoring start/stop times.
|
||||||
|
</p>
|
||||||
|
<button onclick="showCreateScheduleModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
Create Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function editSchedule(scheduleId) {
|
||||||
|
// For now, redirect to a future edit page or show details
|
||||||
|
// The edit modal will be implemented later
|
||||||
|
alert('Edit schedule: ' + scheduleId + '\n\nNote: Full edit functionality coming soon. For now, you can delete and recreate the schedule.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateScheduleModal() {
|
||||||
|
// Call the parent page's openScheduleModal function
|
||||||
|
if (typeof openScheduleModal === 'function') {
|
||||||
|
openScheduleModal();
|
||||||
|
} else {
|
||||||
|
alert('Please use the "Create Schedule" button in the Schedules tab.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
231
templates/partials/projects/schedule_calendar.html
Normal file
@@ -0,0 +1,231 @@
|
|||||||
|
<!-- Weekly Calendar Schedule Editor -->
|
||||||
|
<!-- Used in modals/forms for creating/editing weekly_calendar type schedules -->
|
||||||
|
|
||||||
|
<div id="schedule-calendar-editor" class="space-y-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Weekly Schedule</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Select which days to monitor and set start/end times for each day.
|
||||||
|
For overnight monitoring (e.g., 7pm to 7am), the end time will be on the following day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Day rows -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% set days = [
|
||||||
|
('monday', 'Monday'),
|
||||||
|
('tuesday', 'Tuesday'),
|
||||||
|
('wednesday', 'Wednesday'),
|
||||||
|
('thursday', 'Thursday'),
|
||||||
|
('friday', 'Friday'),
|
||||||
|
('saturday', 'Saturday'),
|
||||||
|
('sunday', 'Sunday')
|
||||||
|
] %}
|
||||||
|
|
||||||
|
{% for day_key, day_name in days %}
|
||||||
|
<div class="flex items-center gap-4 p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||||
|
<!-- Day toggle -->
|
||||||
|
<label class="flex items-center gap-2 w-28 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
id="day-{{ day_key }}"
|
||||||
|
name="weekly_pattern[{{ day_key }}][enabled]"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange"
|
||||||
|
onchange="toggleDayTimes('{{ day_key }}', this.checked)"
|
||||||
|
{% if pattern and pattern.get(day_key, {}).get('enabled') %}checked{% endif %}>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ day_name }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Time inputs -->
|
||||||
|
<div class="flex items-center gap-2 day-times flex-1" id="times-{{ day_key }}"
|
||||||
|
{% if not pattern or not pattern.get(day_key, {}).get('enabled') %}style="opacity: 0.4; pointer-events: none;"{% endif %}>
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">Start:</label>
|
||||||
|
<input type="time"
|
||||||
|
name="weekly_pattern[{{ day_key }}][start]"
|
||||||
|
value="{{ pattern.get(day_key, {}).get('start', '19:00') if pattern else '19:00' }}"
|
||||||
|
class="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
|
||||||
|
|
||||||
|
<span class="text-gray-400 mx-1">to</span>
|
||||||
|
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">End:</label>
|
||||||
|
<input type="time"
|
||||||
|
name="weekly_pattern[{{ day_key }}][end]"
|
||||||
|
value="{{ pattern.get(day_key, {}).get('end', '07:00') if pattern else '07:00' }}"
|
||||||
|
class="px-2 py-1 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
|
||||||
|
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2" id="overnight-hint-{{ day_key }}"
|
||||||
|
style="display: none;">
|
||||||
|
(next day)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick select buttons -->
|
||||||
|
<div class="flex flex-wrap gap-2 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400 mr-2">Quick select:</span>
|
||||||
|
<button type="button" onclick="selectWeekdays()"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Weekdays
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="selectWeekends()"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Weekends
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="selectAllDays()"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
All Days
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="clearAllDays()"
|
||||||
|
class="px-2 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Automation Options -->
|
||||||
|
<div class="pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Automation Options</h5>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<!-- Download data option -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="include_download"
|
||||||
|
id="include_download_calendar"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
|
||||||
|
{% if include_download is not defined or include_download %}checked{% endif %}>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Download data after each monitoring period
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, measurement data will be downloaded via FTP after each stop.
|
||||||
|
Disable if you prefer to download manually or if FTP is not configured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-increment index option -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="auto_increment_index"
|
||||||
|
id="auto_increment_index_calendar"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
|
||||||
|
{% if auto_increment_index is not defined or auto_increment_index %}checked{% endif %}>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Auto-increment store index before each start
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, the system will find an unused store/index number before starting.
|
||||||
|
This prevents "overwrite existing data?" prompts on the device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
|
||||||
|
const weekdays = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday'];
|
||||||
|
const weekends = ['saturday', 'sunday'];
|
||||||
|
|
||||||
|
function toggleDayTimes(day, enabled) {
|
||||||
|
const timesDiv = document.getElementById('times-' + day);
|
||||||
|
if (enabled) {
|
||||||
|
timesDiv.style.opacity = '1';
|
||||||
|
timesDiv.style.pointerEvents = 'auto';
|
||||||
|
} else {
|
||||||
|
timesDiv.style.opacity = '0.4';
|
||||||
|
timesDiv.style.pointerEvents = 'none';
|
||||||
|
}
|
||||||
|
updateOvernightHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDayEnabled(day, enabled) {
|
||||||
|
const checkbox = document.getElementById('day-' + day);
|
||||||
|
checkbox.checked = enabled;
|
||||||
|
toggleDayTimes(day, enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWeekdays() {
|
||||||
|
days.forEach(day => setDayEnabled(day, weekdays.includes(day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectWeekends() {
|
||||||
|
days.forEach(day => setDayEnabled(day, weekends.includes(day)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllDays() {
|
||||||
|
days.forEach(day => setDayEnabled(day, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllDays() {
|
||||||
|
days.forEach(day => setDayEnabled(day, false));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateOvernightHints() {
|
||||||
|
days.forEach(day => {
|
||||||
|
const startInput = document.querySelector(`input[name="weekly_pattern[${day}][start]"]`);
|
||||||
|
const endInput = document.querySelector(`input[name="weekly_pattern[${day}][end]"]`);
|
||||||
|
const hint = document.getElementById('overnight-hint-' + day);
|
||||||
|
|
||||||
|
if (startInput && endInput && hint) {
|
||||||
|
const start = startInput.value;
|
||||||
|
const end = endInput.value;
|
||||||
|
hint.style.display = (end && start && end <= start) ? 'inline' : 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update hints on time change
|
||||||
|
document.querySelectorAll('input[type="time"]').forEach(input => {
|
||||||
|
input.addEventListener('change', updateOvernightHints);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial update
|
||||||
|
updateOvernightHints();
|
||||||
|
|
||||||
|
// Function to collect form data as JSON
|
||||||
|
function getWeeklyPatternData() {
|
||||||
|
const pattern = {};
|
||||||
|
days.forEach(day => {
|
||||||
|
const checkbox = document.getElementById('day-' + day);
|
||||||
|
const startInput = document.querySelector(`input[name="weekly_pattern[${day}][start]"]`);
|
||||||
|
const endInput = document.querySelector(`input[name="weekly_pattern[${day}][end]"]`);
|
||||||
|
|
||||||
|
pattern[day] = {
|
||||||
|
enabled: checkbox.checked,
|
||||||
|
start: startInput.value,
|
||||||
|
end: endInput.value
|
||||||
|
};
|
||||||
|
});
|
||||||
|
return pattern;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get auto-increment setting for calendar mode
|
||||||
|
function getCalendarAutoIncrement() {
|
||||||
|
const checkbox = document.getElementById('auto_increment_index_calendar');
|
||||||
|
return checkbox ? checkbox.checked : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get include_download setting for calendar mode
|
||||||
|
function getCalendarIncludeDownload() {
|
||||||
|
const checkbox = document.getElementById('include_download_calendar');
|
||||||
|
return checkbox ? checkbox.checked : true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to get all calendar options as object
|
||||||
|
function getCalendarOptions() {
|
||||||
|
return {
|
||||||
|
weekly_pattern: getWeeklyPatternData(),
|
||||||
|
auto_increment_index: getCalendarAutoIncrement(),
|
||||||
|
include_download: getCalendarIncludeDownload()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
158
templates/partials/projects/schedule_interval.html
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<!-- Simple Interval Schedule Editor -->
|
||||||
|
<!-- Used for 24/7 continuous monitoring with daily stop/download/restart cycles -->
|
||||||
|
|
||||||
|
<div id="schedule-interval-editor" class="space-y-4">
|
||||||
|
<div class="mb-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Continuous Monitoring (24/7)</h4>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
For uninterrupted monitoring. The device will automatically stop, download data,
|
||||||
|
and restart at the configured cycle time each day.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info box -->
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<svg class="w-5 h-5 text-blue-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
<div class="text-sm text-blue-700 dark:text-blue-300">
|
||||||
|
<p class="font-medium mb-1">How it works:</p>
|
||||||
|
<ol class="list-decimal list-inside space-y-1 text-xs">
|
||||||
|
<li>At the cycle time, the measurement will <strong>stop</strong></li>
|
||||||
|
<li>If enabled, data will be <strong>downloaded</strong> via FTP</li>
|
||||||
|
<li>The measurement will <strong>restart</strong> automatically</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Cycle time -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Daily Cycle Time
|
||||||
|
</label>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input type="time"
|
||||||
|
name="cycle_time"
|
||||||
|
id="cycle_time"
|
||||||
|
value="{{ cycle_time or '00:00' }}"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-seismo-orange focus:border-seismo-orange">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Time when stop/download/restart cycle runs
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-2">
|
||||||
|
Recommended: midnight (00:00) to minimize disruption to data collection
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download option -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="include_download"
|
||||||
|
id="include_download"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
|
||||||
|
{% if include_download is not defined or include_download %}checked{% endif %}>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Download data before restart
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, measurement data will be downloaded via FTP during the cycle.
|
||||||
|
Disable if you prefer to download manually or if FTP is not configured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-increment index option -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="auto_increment_index"
|
||||||
|
id="auto_increment_index_interval"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange mt-0.5"
|
||||||
|
{% if auto_increment_index is not defined or auto_increment_index %}checked{% endif %}>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Auto-increment store index before restart
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
When enabled, the store/index number is incremented before starting a new measurement.
|
||||||
|
This prevents "overwrite existing data?" prompts on the device.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Interval type (hidden for now, default to daily) -->
|
||||||
|
<input type="hidden" name="interval_type" value="daily">
|
||||||
|
|
||||||
|
<!-- Cycle preview -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-700/50 rounded-lg p-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Cycle Sequence Preview</h5>
|
||||||
|
<div class="flex items-center gap-2 text-sm">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center text-xs text-red-700 dark:text-red-300">1</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Stop</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2" id="download-step">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-xs text-blue-700 dark:text-blue-300">2</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Download</span>
|
||||||
|
</div>
|
||||||
|
<svg class="w-4 h-4 text-gray-400" id="download-arrow" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||||
|
</svg>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-6 h-6 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center text-xs text-green-700 dark:text-green-300" id="start-step-num">3</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Start</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-3" id="cycle-timing">
|
||||||
|
At <span id="preview-time">00:00</span>: Stop → Download (1 min) → Start (2 min)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update preview when download checkbox changes
|
||||||
|
document.getElementById('include_download').addEventListener('change', function() {
|
||||||
|
const downloadStep = document.getElementById('download-step');
|
||||||
|
const downloadArrow = document.getElementById('download-arrow');
|
||||||
|
const startStepNum = document.getElementById('start-step-num');
|
||||||
|
const cycleTiming = document.getElementById('cycle-timing');
|
||||||
|
const timeValue = document.getElementById('cycle_time').value || '00:00';
|
||||||
|
|
||||||
|
if (this.checked) {
|
||||||
|
downloadStep.style.display = 'flex';
|
||||||
|
downloadArrow.style.display = 'block';
|
||||||
|
startStepNum.textContent = '3';
|
||||||
|
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Download (1 min) → Start (2 min)`;
|
||||||
|
} else {
|
||||||
|
downloadStep.style.display = 'none';
|
||||||
|
downloadArrow.style.display = 'none';
|
||||||
|
startStepNum.textContent = '2';
|
||||||
|
cycleTiming.innerHTML = `At <span id="preview-time">${timeValue}</span>: Stop → Start (1 min)`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preview time when cycle time changes
|
||||||
|
document.getElementById('cycle_time').addEventListener('change', function() {
|
||||||
|
document.getElementById('preview-time').textContent = this.value || '00:00';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to get interval data as object
|
||||||
|
function getIntervalData() {
|
||||||
|
return {
|
||||||
|
interval_type: 'daily',
|
||||||
|
cycle_time: document.getElementById('cycle_time').value,
|
||||||
|
include_download: document.getElementById('include_download').checked,
|
||||||
|
auto_increment_index: document.getElementById('auto_increment_index_interval').checked
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,98 +1,189 @@
|
|||||||
<!-- Scheduled Actions List -->
|
<!-- Scheduled Actions List - Grouped by Date -->
|
||||||
{% if schedules %}
|
{% if schedules_by_date %}
|
||||||
<div class="space-y-4">
|
<div class="space-y-6">
|
||||||
{% for item in schedules %}
|
{% for date_key, date_group in schedules_by_date.items() %}
|
||||||
<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>
|
||||||
|
<!-- Date Header -->
|
||||||
|
<div class="flex items-center gap-3 mb-3">
|
||||||
|
<div class="flex-shrink-0 w-10 h-10 bg-seismo-orange/10 dark:bg-seismo-orange/20 rounded-lg flex items-center justify-center">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ date_group.date_display }}</h3>
|
||||||
|
<p class="text-xs text-gray-500">{{ date_group.actions|length }} action{{ 's' if date_group.actions|length != 1 else '' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions for this date -->
|
||||||
|
<div class="space-y-3 ml-13 pl-3 border-l-2 border-gray-200 dark:border-gray-700">
|
||||||
|
{% for item in date_group.actions %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:shadow-md transition-shadow">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<div class="flex items-center gap-3 mb-2">
|
<div class="flex items-center gap-3 mb-2">
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
<!-- Action type with icon -->
|
||||||
{{ item.schedule.action_type }}
|
{% if item.schedule.action_type == 'start' %}
|
||||||
</h4>
|
<span class="flex items-center gap-1.5 text-green-600 dark:text-green-400 font-semibold">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM9.555 7.168A1 1 0 008 8v4a1 1 0 001.555.832l3-2a1 1 0 000-1.664l-3-2z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.action_type == 'stop' %}
|
||||||
|
<span class="flex items-center gap-1.5 text-red-600 dark:text-red-400 font-semibold">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</span>
|
||||||
|
{% elif item.schedule.action_type == 'download' %}
|
||||||
|
<span class="flex items-center gap-1.5 text-blue-600 dark:text-blue-400 font-semibold">
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
Download
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white">{{ item.schedule.action_type }}</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Status badge -->
|
||||||
{% if item.schedule.execution_status == 'pending' %}
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
<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">
|
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||||
Pending
|
Pending
|
||||||
</span>
|
</span>
|
||||||
{% elif item.schedule.execution_status == 'completed' %}
|
{% elif item.schedule.execution_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">
|
<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
|
Completed
|
||||||
</span>
|
</span>
|
||||||
{% elif item.schedule.execution_status == 'failed' %}
|
{% elif item.schedule.execution_status == 'failed' %}
|
||||||
<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">
|
<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">
|
||||||
Failed
|
Failed
|
||||||
</span>
|
</span>
|
||||||
{% elif item.schedule.execution_status == 'cancelled' %}
|
{% elif item.schedule.execution_status == 'cancelled' %}
|
||||||
<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">
|
<span class="px-2 py-0.5 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||||
Cancelled
|
Cancelled
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
<div class="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<!-- Time -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
|
<span>{{ item.schedule.scheduled_time|local_datetime('%H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
{% if item.location %}
|
{% if item.location %}
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Location:</span>
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
|
</svg>
|
||||||
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
||||||
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
class="text-seismo-orange hover:text-seismo-navy font-medium">
|
||||||
{{ item.location.name }}
|
{{ item.location.name }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Scheduled:</span>
|
|
||||||
<span class="ml-1">{{ item.schedule.scheduled_time.strftime('%Y-%m-%d %H:%M') if item.schedule.scheduled_time else 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.schedule.executed_at %}
|
{% if item.schedule.executed_at %}
|
||||||
<div>
|
<div class="flex items-center gap-1">
|
||||||
<span class="text-xs text-gray-500">Executed:</span>
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
</div>
|
</svg>
|
||||||
{% endif %}
|
<span>Executed {{ item.schedule.executed_at|local_datetime('%H:%M') }}</span>
|
||||||
|
|
||||||
{% if item.schedule.created_at %}
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Created:</span>
|
|
||||||
<span class="ml-1">{{ item.schedule.created_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.schedule.description %}
|
{% if item.schedule.error_message %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
<div class="mt-2 p-2 bg-red-50 dark:bg-red-900/20 rounded text-xs">
|
||||||
{{ item.schedule.description }}
|
<span class="text-red-600 dark:text-red-400 font-medium">Error:</span>
|
||||||
</p>
|
<span class="ml-1 text-red-700 dark:text-red-300">{{ item.schedule.error_message }}</span>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.schedule.result_message %}
|
|
||||||
<div class="mt-2 text-xs">
|
|
||||||
<span class="text-gray-500">Result:</span>
|
|
||||||
<span class="ml-1 text-gray-700 dark:text-gray-300">{{ item.schedule.result_message }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
|
||||||
|
|
||||||
|
<!-- Execution result details for completed/failed actions -->
|
||||||
|
{% if item.result and item.schedule.execution_status in ['completed', 'failed'] %}
|
||||||
|
<div class="mt-2 p-2 bg-gray-50 dark:bg-gray-700/50 rounded text-xs space-y-1">
|
||||||
|
{% if item.result.cycle_response %}
|
||||||
|
{% set cycle = item.result.cycle_response %}
|
||||||
|
{% if cycle.new_index is defined and cycle.new_index is not none %}
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Index:</span>
|
||||||
|
<span class="font-mono text-gray-700 dark:text-gray-300">{{ '%04d'|format(cycle.new_index) }}</span>
|
||||||
|
{% if cycle.old_index is defined and cycle.old_index is not none %}
|
||||||
|
<span class="text-gray-400">(was {{ '%04d'|format(cycle.old_index) }})</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cycle.downloaded_folder %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Folder:</span>
|
||||||
|
<span class="font-mono text-gray-700 dark:text-gray-300">{{ cycle.downloaded_folder }}</span>
|
||||||
|
{% if cycle.download_success %}
|
||||||
|
<span class="text-green-600 dark:text-green-400">Downloaded</span>
|
||||||
|
{% elif cycle.download_attempted %}
|
||||||
|
<span class="text-red-600 dark:text-red-400">Download failed</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if cycle.clock_synced %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Clock synced:</span>
|
||||||
|
<span class="text-green-600 dark:text-green-400">Yes</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% elif item.result.device_response %}
|
||||||
|
{% set dev = item.result.device_response %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Device:</span>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ dev.message or dev.status }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if item.result.session_id %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">Session:</span>
|
||||||
|
<span class="font-mono text-xs text-gray-600 dark:text-gray-400">{{ item.result.session_id[:8] }}...</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
{% if item.schedule.execution_status == 'pending' %}
|
{% if item.schedule.execution_status == 'pending' %}
|
||||||
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
<button onclick="executeSchedule('{{ item.schedule.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="p-2 text-green-600 hover:bg-green-100 dark:hover:bg-green-900/30 rounded-lg transition-colors"
|
||||||
Execute Now
|
title="Execute Now">
|
||||||
|
<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="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"/>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<button onclick="cancelSchedule('{{ item.schedule.id }}')"
|
<button onclick="cancelSchedule('{{ item.schedule.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300 rounded-lg hover:bg-red-200 dark:hover:bg-red-900/50 transition-colors">
|
class="p-2 text-red-600 hover:bg-red-100 dark:hover:bg-red-900/30 rounded-lg transition-colors"
|
||||||
Cancel
|
title="Cancel">
|
||||||
|
<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="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button onclick="viewScheduleDetails('{{ item.schedule.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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="text-center py-12">
|
<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-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -141,9 +232,4 @@ function cancelSchedule(scheduleId) {
|
|||||||
alert('Error cancelling schedule: ' + error);
|
alert('Error cancelling schedule: ' + error);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function viewScheduleDetails(scheduleId) {
|
|
||||||
// TODO: Implement schedule details modal
|
|
||||||
alert('Schedule details coming soon: ' + scheduleId);
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -41,13 +41,13 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs text-gray-500">Started:</span>
|
<span class="text-xs text-gray-500">Started:</span>
|
||||||
<span class="ml-1">{{ item.session.started_at.strftime('%Y-%m-%d %H:%M') if item.session.started_at else 'N/A' }}</span>
|
<span class="ml-1">{{ item.session.started_at|local_datetime if item.session.started_at else 'N/A' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if item.session.stopped_at %}
|
{% if item.session.stopped_at %}
|
||||||
<div>
|
<div>
|
||||||
<span class="text-xs text-gray-500">Ended:</span>
|
<span class="text-xs text-gray-500">Ended:</span>
|
||||||
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
<span class="ml-1">{{ item.session.stopped_at|local_datetime }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div>
|
<div>
|
||||||
<div class="font-semibold text-gray-900 dark:text-white">
|
<div class="font-semibold text-gray-900 dark:text-white">
|
||||||
{{ session.started_at.strftime('%Y-%m-%d %H:%M') if session.started_at else 'Unknown Date' }}
|
{{ session.started_at|local_datetime if session.started_at else 'Unknown Date' }}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
||||||
@@ -42,6 +42,24 @@
|
|||||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||||
{{ session.status or 'unknown' }}
|
{{ session.status or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
<!-- Download All Files in Session -->
|
||||||
|
<button onclick="event.stopPropagation(); downloadSessionFiles('{{ session.id }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-1"
|
||||||
|
title="Download all files in this session">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download All
|
||||||
|
</button>
|
||||||
|
<!-- Delete Session -->
|
||||||
|
<button onclick="event.stopPropagation(); confirmDeleteSession('{{ session.id }}', '{{ files|length }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center gap-1"
|
||||||
|
title="Delete session and all files">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,6 +90,10 @@
|
|||||||
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
{% elif file.file_type == 'measurement' %}
|
||||||
|
<svg class="w-6 h-6 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
{% else %}
|
{% else %}
|
||||||
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
@@ -95,6 +117,7 @@
|
|||||||
<span class="px-1.5 py-0.5 rounded font-medium
|
<span class="px-1.5 py-0.5 rounded font-medium
|
||||||
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
{% if file.file_type == 'audio' %}bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300
|
||||||
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
{% elif file.file_type == 'data' %}bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300
|
||||||
|
{% elif file.file_type == 'measurement' %}bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300
|
||||||
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
{% elif file.file_type == 'log' %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300
|
||||||
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
{% elif file.file_type == 'archive' %}bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300
|
||||||
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
{% elif file.file_type == 'image' %}bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300
|
||||||
@@ -102,6 +125,17 @@
|
|||||||
{{ file.file_type or 'unknown' }}
|
{{ file.file_type or 'unknown' }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{# Leq vs Lp badge for RND files #}
|
||||||
|
{% if file.file_path and '_Leq_' in file.file_path %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded font-medium bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||||
|
Leq (15-min avg)
|
||||||
|
</span>
|
||||||
|
{% elif file.file_path and '_Lp' in file.file_path and file.file_path.endswith('.rnd') %}
|
||||||
|
<span class="px-1.5 py-0.5 rounded font-medium bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300">
|
||||||
|
Lp (instant)
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- File Size -->
|
<!-- File Size -->
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
{% if file.file_size_bytes %}
|
{% if file.file_size_bytes %}
|
||||||
@@ -121,7 +155,7 @@
|
|||||||
<!-- Download Time -->
|
<!-- Download Time -->
|
||||||
{% if file.downloaded_at %}
|
{% if file.downloaded_at %}
|
||||||
<span class="mx-1">•</span>
|
<span class="mx-1">•</span>
|
||||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
{{ file.downloaded_at|local_datetime }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- Source Info from Metadata -->
|
<!-- Source Info from Metadata -->
|
||||||
@@ -141,9 +175,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Download Button -->
|
<!-- Action Buttons -->
|
||||||
{% if exists %}
|
{% if exists %}
|
||||||
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-2">
|
||||||
|
{% if file.file_type == 'measurement' or file.file_path.endswith('.rnd') %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/view-rnd"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
class="px-3 py-1 text-xs bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
View
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
||||||
|
{% if '_Leq_' in file.file_path %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file.id }}/generate-report"
|
||||||
|
onclick="event.stopPropagation();"
|
||||||
|
class="px-3 py-1 text-xs bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors flex items-center"
|
||||||
|
title="Generate Excel Report">
|
||||||
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Report
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
<button onclick="event.stopPropagation(); downloadFile('{{ file.id }}')"
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -151,6 +207,13 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Download
|
Download
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="event.stopPropagation(); confirmDeleteFile('{{ file.id }}', '{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}')"
|
||||||
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||||
|
title="Delete this file">
|
||||||
|
<svg class="w-4 h-4 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
@@ -197,4 +260,56 @@ function toggleSession(sessionId, headerElement) {
|
|||||||
function downloadFile(fileId) {
|
function downloadFile(fileId) {
|
||||||
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function downloadSessionFiles(sessionId) {
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/sessions/${sessionId}/download-all`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteFile(fileId, fileName) {
|
||||||
|
if (confirm(`Are you sure you want to delete "${fileName}"?\n\nThis action cannot be undone.`)) {
|
||||||
|
deleteFile(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteFile(fileId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/files/${fileId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the files list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to delete file: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error deleting file: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmDeleteSession(sessionId, fileCount) {
|
||||||
|
if (confirm(`Are you sure you want to delete this session and all ${fileCount} file(s)?\n\nThis action cannot be undone.`)) {
|
||||||
|
deleteSession(sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSession(sessionId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/{{ project_id }}/sessions/${sessionId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload the files list
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(`Failed to delete session: ${data.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error deleting session: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -60,7 +60,7 @@
|
|||||||
{% if item.assignment.assigned_at %}
|
{% if item.assignment.assigned_at %}
|
||||||
<div class="col-span-2">
|
<div class="col-span-2">
|
||||||
<span class="text-xs text-gray-500">Assigned:</span>
|
<span class="text-xs text-gray-500">Assigned:</span>
|
||||||
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
<span class="ml-1">{{ item.assignment.assigned_at|local_datetime }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{% if unit.slm_last_check %}
|
{% if unit.slm_last_check %}
|
||||||
Last check: {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M') }}
|
Last check: {{ unit.slm_last_check|local_datetime }}
|
||||||
{% else %}
|
{% else %}
|
||||||
No recent check-in
|
No recent check-in
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -1307,123 +1307,6 @@ window.addEventListener('beforeunload', function() {
|
|||||||
// Timer will resume on next page load if measurement is still active
|
// Timer will resume on next page load if measurement is still active
|
||||||
stopMeasurementTimer();
|
stopMeasurementTimer();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ========================================
|
|
||||||
// Settings Modal
|
|
||||||
// ========================================
|
|
||||||
async function openSettingsModal(unitId) {
|
|
||||||
const modal = document.getElementById('settings-modal');
|
|
||||||
const errorDiv = document.getElementById('settings-error');
|
|
||||||
const successDiv = document.getElementById('settings-success');
|
|
||||||
|
|
||||||
// Clear previous messages
|
|
||||||
errorDiv.classList.add('hidden');
|
|
||||||
successDiv.classList.add('hidden');
|
|
||||||
|
|
||||||
// Store unit ID
|
|
||||||
document.getElementById('settings-unit-id').value = unitId;
|
|
||||||
|
|
||||||
// Load current SLMM config
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/config`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
const config = result.data || {};
|
|
||||||
|
|
||||||
// Populate form fields
|
|
||||||
document.getElementById('settings-host').value = config.host || '';
|
|
||||||
document.getElementById('settings-tcp-port').value = config.tcp_port || 2255;
|
|
||||||
document.getElementById('settings-ftp-port').value = config.ftp_port || 21;
|
|
||||||
document.getElementById('settings-ftp-username').value = config.ftp_username || '';
|
|
||||||
document.getElementById('settings-ftp-password').value = config.ftp_password || '';
|
|
||||||
document.getElementById('settings-tcp-enabled').checked = config.tcp_enabled !== false;
|
|
||||||
document.getElementById('settings-ftp-enabled').checked = config.ftp_enabled === true;
|
|
||||||
document.getElementById('settings-web-enabled').checked = config.web_enabled === true;
|
|
||||||
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load SLMM config:', error);
|
|
||||||
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSettingsModal() {
|
|
||||||
document.getElementById('settings-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('settings-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const unitId = document.getElementById('settings-unit-id').value;
|
|
||||||
const errorDiv = document.getElementById('settings-error');
|
|
||||||
const successDiv = document.getElementById('settings-success');
|
|
||||||
|
|
||||||
errorDiv.classList.add('hidden');
|
|
||||||
successDiv.classList.add('hidden');
|
|
||||||
|
|
||||||
// Gather form data
|
|
||||||
const configData = {
|
|
||||||
host: document.getElementById('settings-host').value.trim(),
|
|
||||||
tcp_port: parseInt(document.getElementById('settings-tcp-port').value),
|
|
||||||
ftp_port: parseInt(document.getElementById('settings-ftp-port').value),
|
|
||||||
ftp_username: document.getElementById('settings-ftp-username').value.trim() || null,
|
|
||||||
ftp_password: document.getElementById('settings-ftp-password').value || null,
|
|
||||||
tcp_enabled: document.getElementById('settings-tcp-enabled').checked,
|
|
||||||
ftp_enabled: document.getElementById('settings-ftp-enabled').checked,
|
|
||||||
web_enabled: document.getElementById('settings-web-enabled').checked
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validation
|
|
||||||
if (!configData.host) {
|
|
||||||
errorDiv.textContent = 'Host/IP address is required';
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configData.tcp_port < 1 || configData.tcp_port > 65535) {
|
|
||||||
errorDiv.textContent = 'TCP port must be between 1 and 65535';
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configData.ftp_port < 1 || configData.ftp_port > 65535) {
|
|
||||||
errorDiv.textContent = 'FTP port must be between 1 and 65535';
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/config`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(configData)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to update configuration');
|
|
||||||
}
|
|
||||||
|
|
||||||
successDiv.textContent = 'Configuration saved successfully!';
|
|
||||||
successDiv.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Close modal after 1.5 seconds
|
|
||||||
setTimeout(() => {
|
|
||||||
closeSettingsModal();
|
|
||||||
// Optionally reload the page to reflect changes
|
|
||||||
// window.location.reload();
|
|
||||||
}, 1500);
|
|
||||||
} catch (error) {
|
|
||||||
errorDiv.textContent = error.message;
|
|
||||||
errorDiv.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// FTP Browser Modal
|
// FTP Browser Modal
|
||||||
// ========================================
|
// ========================================
|
||||||
@@ -2201,125 +2084,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Settings Modal -->
|
|
||||||
<div id="settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
|
|
||||||
<button onclick="closeSettingsModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="settings-form" class="p-6 space-y-6">
|
|
||||||
<input type="hidden" id="settings-unit-id">
|
|
||||||
|
|
||||||
<!-- Network Configuration -->
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Network Configuration</h4>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="col-span-2">
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host / IP Address</label>
|
|
||||||
<input type="text" id="settings-host"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="e.g., 192.168.1.100" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
|
||||||
<input type="number" id="settings-tcp-port"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="2255" min="1" max="65535" required>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Default: 2255 for NL-43/NL-53</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
|
||||||
<input type="number" id="settings-ftp-port"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="21" min="1" max="65535" required>
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Standard FTP port (default: 21)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FTP Credentials -->
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">FTP Credentials</h4>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
|
|
||||||
<input type="text" id="settings-ftp-username"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="anonymous">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
|
|
||||||
<input type="password" id="settings-ftp-password"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
|
||||||
placeholder="••••••••">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Protocol Toggles -->
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
|
||||||
<h4 class="font-semibold text-gray-900 dark:text-white mb-4">Protocol Settings</h4>
|
|
||||||
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white">TCP Communication</span>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable TCP control commands</p>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" id="settings-tcp-enabled"
|
|
||||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable FTP file browsing and downloads</p>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" id="settings-ftp-enabled"
|
|
||||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
<div>
|
|
||||||
<span class="font-medium text-gray-900 dark:text-white">Web Interface</span>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Enable web UI access (future feature)</p>
|
|
||||||
</div>
|
|
||||||
<input type="checkbox" id="settings-web-enabled"
|
|
||||||
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
|
|
||||||
<div id="settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
|
||||||
<button type="button" onclick="closeSettingsModal()"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
|
||||||
Save Configuration
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FTP Browser Modal -->
|
<!-- FTP Browser Modal -->
|
||||||
<div id="ftp-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="ftp-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden m-4 flex flex-col">
|
||||||
@@ -2407,3 +2171,6 @@ document.getElementById('preview-modal')?.addEventListener('click', function(e)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Unified SLM Settings Modal -->
|
||||||
|
{% include 'partials/slm_settings_modal.html' %}
|
||||||
|
|||||||
534
templates/partials/slm_settings_modal.html
Normal file
@@ -0,0 +1,534 @@
|
|||||||
|
<!-- Unified SLM Settings Modal - Include this partial where SLM settings are needed -->
|
||||||
|
<!-- Usage: include 'partials/slm_settings_modal.html' (with Jinja braces) -->
|
||||||
|
<!-- Then call: openSLMSettingsModal(unitId) from JavaScript -->
|
||||||
|
|
||||||
|
<div id="slm-settings-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center overflow-y-auto">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl m-4 my-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-8 h-8 text-seismo-orange" 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>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">SLM Configuration</h3>
|
||||||
|
<p id="slm-settings-unit-display" class="text-sm text-gray-500 dark:text-gray-400"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeSLMSettingsModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="slm-settings-form" onsubmit="saveSLMSettings(event)" class="p-6 space-y-6">
|
||||||
|
<input type="hidden" id="slm-settings-unit-id">
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||||
|
</svg>
|
||||||
|
Network Configuration
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<!-- Modem Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Connected via Modem</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select id="slm-settings-modem" class="flex-1 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select a modem...</option>
|
||||||
|
<!-- Modems loaded dynamically -->
|
||||||
|
</select>
|
||||||
|
<button type="button" onclick="testModemConnection()" id="slm-settings-test-modem-btn"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
disabled title="Test modem connectivity">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem this SLM is connected through</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port Configuration -->
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" id="slm-settings-tcp-port"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="2255" min="1" max="65535" value="2255">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Control port (default: 2255)</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||||
|
<input type="number" id="slm-settings-ftp-port"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="21" min="1" max="65535" value="21">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">File transfer (default: 21)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Credentials -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
FTP Credentials
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Username</label>
|
||||||
|
<input type="text" id="slm-settings-ftp-username"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="anonymous">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank for anonymous</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Password</label>
|
||||||
|
<input type="password" id="slm-settings-ftp-password"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="Leave blank to keep existing">
|
||||||
|
<p class="text-xs text-gray-500 mt-1">Leave blank to keep existing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<h4 class="font-semibold text-gray-900 dark:text-white mb-4 flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5 text-purple-500" 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>
|
||||||
|
Device Information
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||||
|
<select id="slm-settings-model" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select model...</option>
|
||||||
|
<option value="NL-43">NL-43</option>
|
||||||
|
<option value="NL-53">NL-53</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" id="slm-settings-serial"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="e.g., SN123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-3 gap-4 mt-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select id="slm-settings-freq-weighting" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A">A-weighting</option>
|
||||||
|
<option value="C">C-weighting</option>
|
||||||
|
<option value="Z">Z-weighting (Linear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select id="slm-settings-time-weighting" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Fast">Fast (125ms)</option>
|
||||||
|
<option value="Slow">Slow (1s)</option>
|
||||||
|
<option value="Impulse">Impulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||||
|
<select id="slm-settings-range" class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="30-130">30-130 dB</option>
|
||||||
|
<option value="40-140">40-140 dB</option>
|
||||||
|
<option value="50-140">50-140 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FTP Enable Toggle -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||||
|
<label class="flex items-center justify-between cursor-pointer">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">FTP File Transfer</span>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Enable FTP for file browsing and downloads</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="slm-settings-ftp-enabled"
|
||||||
|
class="w-5 h-5 text-seismo-orange rounded border-gray-300 dark:border-gray-600 focus:ring-seismo-orange">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Messages -->
|
||||||
|
<div id="slm-settings-error" class="hidden text-sm p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg"></div>
|
||||||
|
<div id="slm-settings-success" class="hidden text-sm p-3 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded-lg"></div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3 pt-2 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<button type="button" onclick="closeSLMSettingsModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="testSLMConnection()"
|
||||||
|
class="px-6 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg">
|
||||||
|
Test SLM Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="slm-settings-save-btn"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// ========================================
|
||||||
|
// Unified SLM Settings Modal JavaScript
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
let slmSettingsModems = []; // Cache modems list
|
||||||
|
|
||||||
|
// Open the SLM Settings Modal
|
||||||
|
async function openSLMSettingsModal(unitId) {
|
||||||
|
const modal = document.getElementById('slm-settings-modal');
|
||||||
|
const errorDiv = document.getElementById('slm-settings-error');
|
||||||
|
const successDiv = document.getElementById('slm-settings-success');
|
||||||
|
|
||||||
|
// Clear previous messages
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Store unit ID
|
||||||
|
document.getElementById('slm-settings-unit-id').value = unitId;
|
||||||
|
document.getElementById('slm-settings-unit-display').textContent = unitId;
|
||||||
|
|
||||||
|
// Load modems list if not cached
|
||||||
|
if (slmSettingsModems.length === 0) {
|
||||||
|
await loadModemsForSLMSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load current config from both Terra-View and SLMM
|
||||||
|
try {
|
||||||
|
// Fetch Terra-View unit data
|
||||||
|
const unitResponse = await fetch(`/api/roster/${unitId}`);
|
||||||
|
const unitData = unitResponse.ok ? await unitResponse.json() : {};
|
||||||
|
|
||||||
|
// Fetch SLMM config
|
||||||
|
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`);
|
||||||
|
const slmmResult = slmmResponse.ok ? await slmmResponse.json() : {};
|
||||||
|
const slmmData = slmmResult.data || slmmResult || {};
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
// Modem selection
|
||||||
|
const modemSelect = document.getElementById('slm-settings-modem');
|
||||||
|
modemSelect.value = unitData.deployed_with_modem_id || '';
|
||||||
|
updateTestModemButton();
|
||||||
|
|
||||||
|
// Ports
|
||||||
|
document.getElementById('slm-settings-tcp-port').value = unitData.slm_tcp_port || slmmData.tcp_port || 2255;
|
||||||
|
document.getElementById('slm-settings-ftp-port').value = unitData.slm_ftp_port || slmmData.ftp_port || 21;
|
||||||
|
|
||||||
|
// FTP credentials from SLMM
|
||||||
|
document.getElementById('slm-settings-ftp-username').value = slmmData.ftp_username || '';
|
||||||
|
document.getElementById('slm-settings-ftp-password').value = ''; // Don't pre-fill
|
||||||
|
|
||||||
|
// Device info from Terra-View
|
||||||
|
document.getElementById('slm-settings-model').value = unitData.slm_model || '';
|
||||||
|
document.getElementById('slm-settings-serial').value = unitData.slm_serial_number || '';
|
||||||
|
document.getElementById('slm-settings-freq-weighting').value = unitData.slm_frequency_weighting || '';
|
||||||
|
document.getElementById('slm-settings-time-weighting').value = unitData.slm_time_weighting || '';
|
||||||
|
document.getElementById('slm-settings-range').value = unitData.slm_measurement_range || '';
|
||||||
|
|
||||||
|
// FTP enabled from SLMM
|
||||||
|
document.getElementById('slm-settings-ftp-enabled').checked = slmmData.ftp_enabled === true;
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load SLM settings:', error);
|
||||||
|
errorDiv.textContent = 'Failed to load configuration: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the modal
|
||||||
|
function closeSLMSettingsModal() {
|
||||||
|
document.getElementById('slm-settings-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for backwards compatibility with existing code
|
||||||
|
function showFTPSettings(unitId) {
|
||||||
|
openSLMSettingsModal(unitId);
|
||||||
|
}
|
||||||
|
function closeFTPSettings() {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
|
function openSettingsModal(unitId) {
|
||||||
|
openSLMSettingsModal(unitId);
|
||||||
|
}
|
||||||
|
function closeSettingsModal() {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
|
function openConfigModal(unitId) {
|
||||||
|
openSLMSettingsModal(unitId);
|
||||||
|
}
|
||||||
|
function closeConfigModal() {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
|
function openDeviceConfigModal(unitId) {
|
||||||
|
openSLMSettingsModal(unitId);
|
||||||
|
}
|
||||||
|
function closeDeviceConfigModal() {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modems for dropdown
|
||||||
|
async function loadModemsForSLMSettings() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
slmSettingsModems = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('slm-settings-modem');
|
||||||
|
// Clear existing options except first
|
||||||
|
select.innerHTML = '<option value="">Select a modem...</option>';
|
||||||
|
|
||||||
|
slmSettingsModems.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
const ipText = modem.ip_address ? ` (${modem.ip_address})` : '';
|
||||||
|
const deployedText = modem.deployed ? '' : ' [Benched]';
|
||||||
|
option.textContent = modem.id + ipText + deployedText;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load modems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update test modem button state based on selection
|
||||||
|
function updateTestModemButton() {
|
||||||
|
const modemSelect = document.getElementById('slm-settings-modem');
|
||||||
|
const testBtn = document.getElementById('slm-settings-test-modem-btn');
|
||||||
|
testBtn.disabled = !modemSelect.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for modem selection changes
|
||||||
|
document.getElementById('slm-settings-modem')?.addEventListener('change', updateTestModemButton);
|
||||||
|
|
||||||
|
// Test modem connection
|
||||||
|
async function testModemConnection() {
|
||||||
|
const modemId = document.getElementById('slm-settings-modem').value;
|
||||||
|
if (!modemId) return;
|
||||||
|
|
||||||
|
const errorDiv = document.getElementById('slm-settings-error');
|
||||||
|
const successDiv = document.getElementById('slm-settings-success');
|
||||||
|
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.textContent = 'Pinging modem...';
|
||||||
|
successDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slm-dashboard/test-modem/${modemId}`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.status === 'success') {
|
||||||
|
const ipAddr = data.ip_address || modemId;
|
||||||
|
const respTime = data.response_time || 'N/A';
|
||||||
|
successDiv.textContent = `✓ Modem responding! ${ipAddr} - ${respTime}ms`;
|
||||||
|
} else {
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
errorDiv.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
errorDiv.textContent = 'Failed to ping modem: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test SLM connection
|
||||||
|
async function testSLMConnection() {
|
||||||
|
const unitId = document.getElementById('slm-settings-unit-id').value;
|
||||||
|
const errorDiv = document.getElementById('slm-settings-error');
|
||||||
|
const successDiv = document.getElementById('slm-settings-success');
|
||||||
|
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.textContent = 'Testing SLM connection...';
|
||||||
|
successDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (response.ok && data.status === 'online') {
|
||||||
|
successDiv.textContent = '✓ SLM connection successful! Device is responding.';
|
||||||
|
} else {
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
errorDiv.textContent = '⚠ SLM not responding or offline. Check network settings.';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
errorDiv.textContent = 'Connection test failed: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save SLM settings
|
||||||
|
async function saveSLMSettings(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const unitId = document.getElementById('slm-settings-unit-id').value;
|
||||||
|
const saveBtn = document.getElementById('slm-settings-save-btn');
|
||||||
|
const errorDiv = document.getElementById('slm-settings-error');
|
||||||
|
const successDiv = document.getElementById('slm-settings-success');
|
||||||
|
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Saving...';
|
||||||
|
errorDiv.classList.add('hidden');
|
||||||
|
successDiv.classList.add('hidden');
|
||||||
|
|
||||||
|
// Get selected modem and resolve its IP
|
||||||
|
const modemId = document.getElementById('slm-settings-modem').value;
|
||||||
|
let modemIp = '';
|
||||||
|
if (modemId) {
|
||||||
|
const modem = slmSettingsModems.find(m => m.id === modemId);
|
||||||
|
modemIp = modem?.ip_address || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validation
|
||||||
|
if (!modemId) {
|
||||||
|
errorDiv.textContent = 'Please select a modem';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Configuration';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modemIp) {
|
||||||
|
errorDiv.textContent = 'Selected modem has no IP address configured';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Configuration';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tcpPort = parseInt(document.getElementById('slm-settings-tcp-port').value) || 2255;
|
||||||
|
const ftpPort = parseInt(document.getElementById('slm-settings-ftp-port').value) || 21;
|
||||||
|
|
||||||
|
if (tcpPort < 1 || tcpPort > 65535 || ftpPort < 1 || ftpPort > 65535) {
|
||||||
|
errorDiv.textContent = 'Port values must be between 1 and 65535';
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Configuration';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Update Terra-View database (device info + modem assignment)
|
||||||
|
const terraViewData = {
|
||||||
|
deployed_with_modem_id: modemId,
|
||||||
|
slm_model: document.getElementById('slm-settings-model').value || null,
|
||||||
|
slm_serial_number: document.getElementById('slm-settings-serial').value || null,
|
||||||
|
slm_frequency_weighting: document.getElementById('slm-settings-freq-weighting').value || null,
|
||||||
|
slm_time_weighting: document.getElementById('slm-settings-time-weighting').value || null,
|
||||||
|
slm_measurement_range: document.getElementById('slm-settings-range').value || null,
|
||||||
|
slm_tcp_port: tcpPort,
|
||||||
|
slm_ftp_port: ftpPort
|
||||||
|
};
|
||||||
|
|
||||||
|
const terraResponse = await fetch(`/api/slm-dashboard/config/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams(terraViewData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!terraResponse.ok) {
|
||||||
|
throw new Error('Failed to save Terra-View configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Update SLMM config (network + FTP credentials)
|
||||||
|
const slmmData = {
|
||||||
|
host: modemIp,
|
||||||
|
tcp_port: tcpPort,
|
||||||
|
ftp_port: ftpPort,
|
||||||
|
ftp_username: document.getElementById('slm-settings-ftp-username').value.trim() || null,
|
||||||
|
ftp_enabled: document.getElementById('slm-settings-ftp-enabled').checked
|
||||||
|
};
|
||||||
|
|
||||||
|
// Only include password if entered
|
||||||
|
const password = document.getElementById('slm-settings-ftp-password').value;
|
||||||
|
if (password) {
|
||||||
|
slmmData.ftp_password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
const slmmResponse = await fetch(`/api/slmm/${unitId}/config`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(slmmData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!slmmResponse.ok) {
|
||||||
|
const errData = await slmmResponse.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.detail || 'Failed to save SLMM configuration');
|
||||||
|
}
|
||||||
|
|
||||||
|
successDiv.textContent = 'Configuration saved successfully!';
|
||||||
|
successDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Close modal after delay and refresh if needed
|
||||||
|
setTimeout(() => {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
// Try to refresh any FTP status or unit lists on the page
|
||||||
|
if (typeof checkFTPStatus === 'function') {
|
||||||
|
checkFTPStatus(unitId);
|
||||||
|
}
|
||||||
|
if (typeof htmx !== 'undefined') {
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}
|
||||||
|
}, 1500);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
errorDiv.textContent = 'Error: ' + error.message;
|
||||||
|
errorDiv.classList.remove('hidden');
|
||||||
|
} finally {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Save Configuration';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alias for backwards compatibility
|
||||||
|
async function saveFTPSettings(event) {
|
||||||
|
return saveSLMSettings(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on background click
|
||||||
|
document.getElementById('slm-settings-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
132
templates/partials/unit_picker.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{#
|
||||||
|
Unit Picker Component
|
||||||
|
A reusable HTMX-based autocomplete for selecting seismographs/SLMs.
|
||||||
|
|
||||||
|
Usage: include "partials/unit_picker.html" with context
|
||||||
|
|
||||||
|
Variables available in context:
|
||||||
|
- selected_unit_id: Pre-selected unit ID (optional)
|
||||||
|
- selected_unit_display: Display text for pre-selected unit (optional)
|
||||||
|
- input_name: Name attribute for the hidden input (default: "deployed_with_unit_id")
|
||||||
|
- picker_id: Unique ID suffix for multiple pickers on same page (default: "")
|
||||||
|
- device_type_filter: Filter by device type: "seismograph", "slm", or empty for all (default: "")
|
||||||
|
- deployed_only: Only show deployed units (default: false)
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set picker_id = picker_id|default("") %}
|
||||||
|
{% set input_name = input_name|default("deployed_with_unit_id") %}
|
||||||
|
{% set selected_unit_id = selected_unit_id|default("") %}
|
||||||
|
{% set selected_unit_display = selected_unit_display|default("") %}
|
||||||
|
{% set device_type_filter = device_type_filter|default("") %}
|
||||||
|
{% set deployed_only = deployed_only|default(false) %}
|
||||||
|
|
||||||
|
<div class="unit-picker relative" id="unit-picker-container{{ picker_id }}">
|
||||||
|
<!-- Hidden input for form submission (stores unit ID) -->
|
||||||
|
<input type="hidden"
|
||||||
|
name="{{ input_name }}"
|
||||||
|
id="unit-picker-value{{ picker_id }}"
|
||||||
|
value="{{ selected_unit_id }}">
|
||||||
|
|
||||||
|
<!-- Search Input -->
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text"
|
||||||
|
id="unit-picker-search{{ picker_id }}"
|
||||||
|
placeholder="Search by unit ID or note..."
|
||||||
|
class="w-full px-4 py-2 pr-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange"
|
||||||
|
autocomplete="off"
|
||||||
|
value="{{ selected_unit_display }}"
|
||||||
|
hx-get="/api/roster/search/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms, focus"
|
||||||
|
hx-target="#unit-picker-dropdown{{ picker_id }}"
|
||||||
|
hx-vals='{"picker_id": "{{ picker_id }}", "device_type": "{{ device_type_filter }}", "deployed_only": "{{ deployed_only|lower }}"}'
|
||||||
|
name="q"
|
||||||
|
onfocus="showUnitDropdown('{{ picker_id }}')"
|
||||||
|
oninput="handleUnitSearchInput('{{ picker_id }}', this.value)">
|
||||||
|
|
||||||
|
<!-- Search icon -->
|
||||||
|
<div class="absolute inset-y-0 right-0 flex items-center pr-3 pointer-events-none">
|
||||||
|
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Clear button (shown when unit is selected) -->
|
||||||
|
<button type="button"
|
||||||
|
id="unit-picker-clear{{ picker_id }}"
|
||||||
|
class="absolute inset-y-0 right-8 flex items-center pr-1 {{ 'hidden' if not selected_unit_id else '' }}"
|
||||||
|
onclick="clearUnitSelection('{{ picker_id }}')"
|
||||||
|
title="Clear selection">
|
||||||
|
<svg class="w-4 h-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dropdown Results Container -->
|
||||||
|
<div id="unit-picker-dropdown{{ picker_id }}"
|
||||||
|
class="hidden absolute z-50 w-full mt-1 bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded-lg shadow-lg max-h-60 overflow-y-auto">
|
||||||
|
<!-- Results loaded via HTMX -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
{# Unit picker functions - defined once, work for any picker_id #}
|
||||||
|
if (typeof selectUnit === 'undefined') {
|
||||||
|
function selectUnit(unitId, displayText, pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('unit-picker-search' + pickerId);
|
||||||
|
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = unitId;
|
||||||
|
if (searchInput) searchInput.value = displayText;
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
if (clearBtn) clearBtn.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUnitSelection(pickerId = '') {
|
||||||
|
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||||
|
const searchInput = document.getElementById('unit-picker-search' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
searchInput.focus();
|
||||||
|
}
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showUnitDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideUnitDropdown(pickerId = '') {
|
||||||
|
const dropdown = document.getElementById('unit-picker-dropdown' + pickerId);
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnitSearchInput(pickerId, value) {
|
||||||
|
const valueInput = document.getElementById('unit-picker-value' + pickerId);
|
||||||
|
const clearBtn = document.getElementById('unit-picker-clear' + pickerId);
|
||||||
|
|
||||||
|
// If user clears the search box, also clear the hidden value
|
||||||
|
if (!value.trim()) {
|
||||||
|
if (valueInput) valueInput.value = '';
|
||||||
|
if (clearBtn) clearBtn.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
const pickers = document.querySelectorAll('.unit-picker');
|
||||||
|
pickers.forEach(picker => {
|
||||||
|
if (!picker.contains(event.target)) {
|
||||||
|
const dropdown = picker.querySelector('[id^="unit-picker-dropdown"]');
|
||||||
|
if (dropdown) dropdown.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
66
templates/partials/unit_search_results.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{#
|
||||||
|
Unit Search Results Partial
|
||||||
|
Rendered by /api/roster/search/units endpoint for HTMX dropdown.
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
- units: List of unit dicts with id, device_type, note, deployed, display
|
||||||
|
- query: The search query string
|
||||||
|
- show_empty: Boolean - show "no results" message
|
||||||
|
#}
|
||||||
|
|
||||||
|
{% set picker_id = request.query_params.get('picker_id', '') %}
|
||||||
|
|
||||||
|
{% if units %}
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="px-4 py-3 hover:bg-gray-100 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0 transition-colors"
|
||||||
|
onclick="selectUnit('{{ unit.id }}', '{{ unit.display|e }}', '{{ picker_id }}')">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||||
|
<span class="text-seismo-orange font-semibold">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{{ unit.note }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.device_type == 'seismograph' %}
|
||||||
|
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-300 rounded">
|
||||||
|
Seismo
|
||||||
|
</span>
|
||||||
|
{% elif unit.device_type == 'slm' %}
|
||||||
|
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-600 dark:text-purple-300 rounded">
|
||||||
|
SLM
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if not unit.deployed %}
|
||||||
|
<span class="flex-shrink-0 text-xs px-2 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-600 dark:text-gray-300 rounded">
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if show_empty %}
|
||||||
|
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">No units found matching "{{ query }}"</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if not units and not show_empty %}
|
||||||
|
<div class="px-4 py-6 text-center text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-8 h-8 mx-auto mb-2 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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-sm">Start typing to search units...</p>
|
||||||
|
<p class="text-xs mt-1">Search by unit ID or note</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -132,23 +132,55 @@
|
|||||||
|
|
||||||
<!-- Schedules Tab -->
|
<!-- Schedules Tab -->
|
||||||
<div id="schedules-tab" class="tab-panel hidden">
|
<div id="schedules-tab" class="tab-panel hidden">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<!-- Recurring Schedules Section -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Scheduled Actions</h2>
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recurring Schedules</h2>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Automated patterns that generate scheduled actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<button onclick="openScheduleModal()"
|
<button onclick="openScheduleModal()"
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Schedule Action
|
Create Schedule
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="project-schedules"
|
<div id="recurring-schedule-list"
|
||||||
hx-get="/api/projects/{{ project_id }}/schedules"
|
hx-get="/api/projects/{{ project_id }}/recurring-schedules/partials/list"
|
||||||
hx-trigger="load, every 30s"
|
hx-trigger="load, refresh from:#recurring-schedule-list"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="text-center py-8 text-gray-500">Loading schedules...</div>
|
<div class="text-center py-8 text-gray-500">Loading recurring schedules...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scheduled Actions Section -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 id="schedules-title" class="text-xl font-semibold text-gray-900 dark:text-white">Upcoming Actions</h2>
|
||||||
|
<p id="schedules-subtitle" class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Scheduled start/stop/download actions
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<select id="schedules-filter" onchange="filterScheduledActions()"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-schedules"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/schedules?status=pending"
|
||||||
|
hx-trigger="load, every 30s, refresh from:#project-schedules"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading scheduled actions...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,6 +410,122 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Modal -->
|
||||||
|
<div id="schedule-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create Recurring Schedule</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Set up automated monitoring schedules</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeScheduleModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="schedule-form" class="p-6 space-y-6">
|
||||||
|
<!-- Schedule Name -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Schedule Name</label>
|
||||||
|
<input type="text" name="schedule_name" id="schedule-name"
|
||||||
|
placeholder="e.g., Weeknight Monitoring"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Selection (Multiple) -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Locations
|
||||||
|
<span class="text-xs font-normal text-gray-500 ml-2">(select one or more)</span>
|
||||||
|
</label>
|
||||||
|
<div id="schedule-locations-container"
|
||||||
|
class="max-h-48 overflow-y-auto border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 p-2 space-y-1">
|
||||||
|
<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>
|
||||||
|
</div>
|
||||||
|
<p id="schedule-location-empty" class="hidden text-xs text-gray-500 mt-2">
|
||||||
|
No locations available. Create a location first.
|
||||||
|
</p>
|
||||||
|
<p id="schedule-location-error" class="hidden text-xs text-red-500 mt-2">
|
||||||
|
Please select at least one location.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedule Type Selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Schedule Type</label>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<label class="relative cursor-pointer">
|
||||||
|
<input type="radio" name="schedule_type" value="weekly_calendar" class="peer sr-only" checked onchange="toggleScheduleType('weekly_calendar')">
|
||||||
|
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">Weekly Calendar</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Select specific days with start/end times. Ideal for weeknight monitoring (Mon-Fri 7pm-7am).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
<label class="relative cursor-pointer">
|
||||||
|
<input type="radio" name="schedule_type" value="simple_interval" class="peer sr-only" onchange="toggleScheduleType('simple_interval')">
|
||||||
|
<div class="p-4 border-2 border-gray-200 dark:border-gray-600 rounded-lg peer-checked:border-seismo-orange peer-checked:bg-orange-50 dark:peer-checked:bg-orange-900/20 transition-colors">
|
||||||
|
<div class="flex items-center gap-3 mb-2">
|
||||||
|
<svg class="w-6 h-6 text-gray-500 peer-checked:text-seismo-orange" 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"/>
|
||||||
|
</svg>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white">24/7 Continuous</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
Continuous monitoring with daily stop/download/restart cycle at a set time.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Weekly Calendar Editor -->
|
||||||
|
<div id="schedule-weekly-wrapper">
|
||||||
|
{% include "partials/projects/schedule_calendar.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Simple Interval Editor -->
|
||||||
|
<div id="schedule-interval-wrapper" class="hidden">
|
||||||
|
{% include "partials/projects/schedule_interval.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Timezone -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Timezone</label>
|
||||||
|
<select name="timezone" id="schedule-timezone"
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="America/New_York">Eastern (America/New_York)</option>
|
||||||
|
<option value="America/Chicago">Central (America/Chicago)</option>
|
||||||
|
<option value="America/Denver">Mountain (America/Denver)</option>
|
||||||
|
<option value="America/Los_Angeles">Pacific (America/Los_Angeles)</option>
|
||||||
|
<option value="UTC">UTC</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="schedule-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeScheduleModal()"
|
||||||
|
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||||
|
Create Schedule
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
<!-- Assign Unit Modal -->
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
@@ -809,11 +957,34 @@ function filterFiles() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Utility functions
|
function filterScheduledActions() {
|
||||||
function openScheduleModal() {
|
const filter = document.getElementById('schedules-filter').value;
|
||||||
alert('Schedule modal coming soon');
|
const url = filter === 'all'
|
||||||
|
? `/api/projects/${projectId}/schedules`
|
||||||
|
: `/api/projects/${projectId}/schedules?status=${filter}`;
|
||||||
|
|
||||||
|
// Update section title based on filter
|
||||||
|
const titleEl = document.getElementById('schedules-title');
|
||||||
|
const subtitleEl = document.getElementById('schedules-subtitle');
|
||||||
|
|
||||||
|
const titles = {
|
||||||
|
'pending': { title: 'Upcoming Actions', subtitle: 'Scheduled start/stop/download actions' },
|
||||||
|
'completed': { title: 'Completed Actions', subtitle: 'Successfully executed actions' },
|
||||||
|
'failed': { title: 'Failed Actions', subtitle: 'Actions that encountered errors' },
|
||||||
|
'all': { title: 'All Actions', subtitle: 'Complete action history' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = titles[filter] || titles['all'];
|
||||||
|
titleEl.textContent = config.title;
|
||||||
|
subtitleEl.textContent = config.subtitle;
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {
|
||||||
|
target: '#project-schedules',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
function exportProjectData() {
|
function exportProjectData() {
|
||||||
window.location.href = `/api/projects/${projectId}/export`;
|
window.location.href = `/api/projects/${projectId}/export`;
|
||||||
}
|
}
|
||||||
@@ -825,11 +996,239 @@ function archiveProject() {
|
|||||||
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Schedule Modal Functions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function openScheduleModal() {
|
||||||
|
// Reset form
|
||||||
|
document.getElementById('schedule-name').value = '';
|
||||||
|
document.getElementById('schedule-locations-container').innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">Loading locations...</div>';
|
||||||
|
document.getElementById('schedule-location-empty').classList.add('hidden');
|
||||||
|
document.getElementById('schedule-location-error').classList.add('hidden');
|
||||||
|
document.getElementById('schedule-error').classList.add('hidden');
|
||||||
|
|
||||||
|
// Reset to weekly calendar type
|
||||||
|
document.querySelector('input[name="schedule_type"][value="weekly_calendar"]').checked = true;
|
||||||
|
toggleScheduleType('weekly_calendar');
|
||||||
|
|
||||||
|
// Reset calendar checkboxes
|
||||||
|
if (typeof clearAllDays === 'function') {
|
||||||
|
clearAllDays();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show modal
|
||||||
|
document.getElementById('schedule-modal').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load locations
|
||||||
|
await loadScheduleLocations();
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeScheduleModal() {
|
||||||
|
document.getElementById('schedule-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadScheduleLocations() {
|
||||||
|
const container = document.getElementById('schedule-locations-container');
|
||||||
|
const emptyMsg = document.getElementById('schedule-location-empty');
|
||||||
|
const errorMsg = document.getElementById('schedule-location-error');
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
emptyMsg.classList.add('hidden');
|
||||||
|
errorMsg.classList.add('hidden');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations-json`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load locations');
|
||||||
|
}
|
||||||
|
const locations = await response.json();
|
||||||
|
|
||||||
|
if (!locations.length) {
|
||||||
|
container.innerHTML = '<div class="text-gray-500 text-sm py-2 text-center">No locations available</div>';
|
||||||
|
emptyMsg.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build checkboxes for each location
|
||||||
|
container.innerHTML = locations.map(loc => `
|
||||||
|
<label class="flex items-center gap-3 p-2 rounded hover:bg-gray-50 dark:hover:bg-gray-600 cursor-pointer">
|
||||||
|
<input type="checkbox"
|
||||||
|
name="schedule_locations"
|
||||||
|
value="${loc.id}"
|
||||||
|
data-name="${loc.name}"
|
||||||
|
data-type="${loc.location_type}"
|
||||||
|
class="rounded text-seismo-orange focus:ring-seismo-orange">
|
||||||
|
<span class="text-sm text-gray-900 dark:text-white">${loc.name}</span>
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">(${loc.location_type})</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Add select all / clear all buttons if more than one location
|
||||||
|
if (locations.length > 1) {
|
||||||
|
container.insertAdjacentHTML('afterbegin', `
|
||||||
|
<div class="flex gap-2 pb-2 mb-2 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<button type="button" onclick="selectAllLocations()" class="text-xs text-seismo-orange hover:underline">Select All</button>
|
||||||
|
<span class="text-gray-400">|</span>
|
||||||
|
<button type="button" onclick="clearAllLocations()" class="text-xs text-gray-500 hover:underline">Clear All</button>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load locations:', err);
|
||||||
|
container.innerHTML = '<div class="text-red-500 text-sm py-2 text-center">Error loading locations</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllLocations() {
|
||||||
|
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllLocations() {
|
||||||
|
document.querySelectorAll('input[name="schedule_locations"]').forEach(cb => cb.checked = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedLocationIds() {
|
||||||
|
const checkboxes = document.querySelectorAll('input[name="schedule_locations"]:checked');
|
||||||
|
return Array.from(checkboxes).map(cb => cb.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleScheduleType(type) {
|
||||||
|
const weeklyEditor = document.getElementById('schedule-weekly-wrapper');
|
||||||
|
const intervalEditor = document.getElementById('schedule-interval-wrapper');
|
||||||
|
|
||||||
|
if (type === 'weekly_calendar') {
|
||||||
|
weeklyEditor.classList.remove('hidden');
|
||||||
|
intervalEditor.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
weeklyEditor.classList.add('hidden');
|
||||||
|
intervalEditor.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule form submission
|
||||||
|
document.getElementById('schedule-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = document.getElementById('schedule-name').value.trim();
|
||||||
|
const locationIds = getSelectedLocationIds();
|
||||||
|
const scheduleType = document.querySelector('input[name="schedule_type"]:checked').value;
|
||||||
|
const timezone = document.getElementById('schedule-timezone').value;
|
||||||
|
|
||||||
|
// Hide previous errors
|
||||||
|
document.getElementById('schedule-location-error').classList.add('hidden');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
showScheduleError('Please enter a schedule name.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!locationIds.length) {
|
||||||
|
document.getElementById('schedule-location-error').classList.remove('hidden');
|
||||||
|
showScheduleError('Please select at least one location.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build payload based on schedule type
|
||||||
|
const payload = {
|
||||||
|
name: name,
|
||||||
|
location_ids: locationIds, // Array of location IDs
|
||||||
|
schedule_type: scheduleType,
|
||||||
|
timezone: timezone,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (scheduleType === 'weekly_calendar') {
|
||||||
|
// Get weekly pattern from the calendar editor
|
||||||
|
if (typeof getWeeklyPatternData === 'function') {
|
||||||
|
payload.weekly_pattern = getWeeklyPatternData();
|
||||||
|
} else {
|
||||||
|
showScheduleError('Calendar editor not loaded properly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate at least one day is selected
|
||||||
|
const hasEnabledDay = Object.values(payload.weekly_pattern).some(day => day.enabled);
|
||||||
|
if (!hasEnabledDay) {
|
||||||
|
showScheduleError('Please select at least one day for monitoring.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get auto-increment setting for calendar mode
|
||||||
|
if (typeof getCalendarAutoIncrement === 'function') {
|
||||||
|
payload.auto_increment_index = getCalendarAutoIncrement();
|
||||||
|
} else {
|
||||||
|
payload.auto_increment_index = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get include_download setting for calendar mode
|
||||||
|
if (typeof getCalendarIncludeDownload === 'function') {
|
||||||
|
payload.include_download = getCalendarIncludeDownload();
|
||||||
|
} else {
|
||||||
|
payload.include_download = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Get interval data
|
||||||
|
if (typeof getIntervalData === 'function') {
|
||||||
|
const intervalData = getIntervalData();
|
||||||
|
payload.interval_type = intervalData.interval_type;
|
||||||
|
payload.cycle_time = intervalData.cycle_time;
|
||||||
|
payload.include_download = intervalData.include_download;
|
||||||
|
payload.auto_increment_index = intervalData.auto_increment_index;
|
||||||
|
} else {
|
||||||
|
showScheduleError('Interval editor not loaded properly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/recurring-schedules/`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to create schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Close modal and refresh schedules list
|
||||||
|
closeScheduleModal();
|
||||||
|
|
||||||
|
// Refresh both the recurring schedules list and scheduled actions
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/recurring-schedules/partials/list`, {
|
||||||
|
target: '#recurring-schedule-list',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/schedules?status=pending`, {
|
||||||
|
target: '#project-schedules',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
console.log('Schedule(s) created:', result.message);
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
showScheduleError(err.message || 'Failed to create schedule.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function showScheduleError(message) {
|
||||||
|
const errorEl = document.getElementById('schedule-error');
|
||||||
|
errorEl.textContent = message;
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
// Keyboard shortcuts
|
// Keyboard shortcuts
|
||||||
|
// ============================================================================
|
||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeLocationModal();
|
closeLocationModal();
|
||||||
closeAssignModal();
|
closeAssignModal();
|
||||||
|
closeScheduleModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -846,6 +1245,12 @@ document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('schedule-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeScheduleModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Load project details on page load
|
// Load project details on page load
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
loadProjectDetails();
|
loadProjectDetails();
|
||||||
|
|||||||
309
templates/report_preview.html
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Report Preview - {{ project.name if project else 'Sound Level Data' }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- jspreadsheet CSS -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/jspreadsheet.min.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.css" />
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-100 dark:bg-slate-900">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 shadow-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
Report Preview & Editor
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
{% if file %}{{ file.file_path.split('/')[-1] }}{% endif %}
|
||||||
|
{% if location %} @ {{ location.name }}{% endif %}
|
||||||
|
{% if start_time and end_time %} | Time: {{ start_time }} - {{ end_time }}{% endif %}
|
||||||
|
| {{ filtered_count }} of {{ original_count }} rows
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="downloadReport()"
|
||||||
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
Download Excel
|
||||||
|
</button>
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/view-rnd"
|
||||||
|
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">
|
||||||
|
Back to Viewer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Info Section -->
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4 mb-4">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Report Title</label>
|
||||||
|
<input type="text" id="edit-report-title" value="{{ report_title }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project Name</label>
|
||||||
|
<input type="text" id="edit-project-name" value="{{ project_name }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client Name</label>
|
||||||
|
<input type="text" id="edit-client-name" value="{{ client_name }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Location</label>
|
||||||
|
<input type="text" id="edit-location-name" value="{{ location_name }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Spreadsheet Editor -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 p-4">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Data Table</h2>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span>Right-click for options</span>
|
||||||
|
<span class="text-gray-300 dark:text-gray-600">|</span>
|
||||||
|
<span>Double-click to edit</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="spreadsheet" class="overflow-x-auto"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Help Text -->
|
||||||
|
<div class="mt-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<h3 class="text-sm font-medium text-blue-800 dark:text-blue-300 mb-2">Editing Tips</h3>
|
||||||
|
<ul class="text-sm text-blue-700 dark:text-blue-400 list-disc list-inside space-y-1">
|
||||||
|
<li>Double-click any cell to edit its value</li>
|
||||||
|
<li>Use the Comments column to add notes about specific measurements</li>
|
||||||
|
<li>Right-click a row to delete it from the report</li>
|
||||||
|
<li>Right-click to add new rows if needed</li>
|
||||||
|
<li>Press Enter to confirm edits, Escape to cancel</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- jspreadsheet JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jsuites@5/dist/jsuites.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/jspreadsheet-ce@4/dist/index.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Initialize spreadsheet data from server
|
||||||
|
const initialData = {{ spreadsheet_data | tojson }};
|
||||||
|
|
||||||
|
// Create jspreadsheet instance
|
||||||
|
let spreadsheet = null;
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
spreadsheet = jspreadsheet(document.getElementById('spreadsheet'), {
|
||||||
|
data: initialData,
|
||||||
|
columns: [
|
||||||
|
{ title: 'Test #', width: 80, type: 'numeric' },
|
||||||
|
{ title: 'Date', width: 110, type: 'text' },
|
||||||
|
{ title: 'Time', width: 90, type: 'text' },
|
||||||
|
{ title: 'LAmax (dBA)', width: 100, type: 'numeric' },
|
||||||
|
{ title: 'LA01 (dBA)', width: 100, type: 'numeric' },
|
||||||
|
{ title: 'LA10 (dBA)', width: 100, type: 'numeric' },
|
||||||
|
{ title: 'Comments', width: 250, type: 'text' }
|
||||||
|
],
|
||||||
|
allowInsertRow: true,
|
||||||
|
allowDeleteRow: true,
|
||||||
|
allowInsertColumn: false,
|
||||||
|
allowDeleteColumn: false,
|
||||||
|
rowDrag: true,
|
||||||
|
columnSorting: true,
|
||||||
|
search: true,
|
||||||
|
pagination: 50,
|
||||||
|
paginationOptions: [25, 50, 100, 200],
|
||||||
|
defaultColWidth: 100,
|
||||||
|
minDimensions: [7, 1],
|
||||||
|
tableOverflow: true,
|
||||||
|
tableWidth: '100%',
|
||||||
|
contextMenu: function(instance, col, row, e) {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
if (row !== null) {
|
||||||
|
items.push({
|
||||||
|
title: 'Insert row above',
|
||||||
|
onclick: function() {
|
||||||
|
instance.insertRow(1, row, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
title: 'Insert row below',
|
||||||
|
onclick: function() {
|
||||||
|
instance.insertRow(1, row + 1, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
items.push({
|
||||||
|
title: 'Delete this row',
|
||||||
|
onclick: function() {
|
||||||
|
instance.deleteRow(row);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
},
|
||||||
|
style: {
|
||||||
|
A: 'text-align: center;',
|
||||||
|
B: 'text-align: center;',
|
||||||
|
C: 'text-align: center;',
|
||||||
|
D: 'text-align: right;',
|
||||||
|
E: 'text-align: right;',
|
||||||
|
F: 'text-align: right;',
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function downloadReport() {
|
||||||
|
// Get current data from spreadsheet
|
||||||
|
const data = spreadsheet.getData();
|
||||||
|
|
||||||
|
// Get report settings
|
||||||
|
const reportTitle = document.getElementById('edit-report-title').value;
|
||||||
|
const projectName = document.getElementById('edit-project-name').value;
|
||||||
|
const clientName = document.getElementById('edit-client-name').value;
|
||||||
|
const locationName = document.getElementById('edit-location-name').value;
|
||||||
|
|
||||||
|
// Build time filter info
|
||||||
|
let timeFilter = '';
|
||||||
|
{% if start_time and end_time %}
|
||||||
|
timeFilter = 'Time Filter: {{ start_time }} - {{ end_time }}';
|
||||||
|
{% if start_date or end_date %}
|
||||||
|
timeFilter += ' | Date Range: {{ start_date or "start" }} to {{ end_date or "end" }}';
|
||||||
|
{% endif %}
|
||||||
|
timeFilter += ' | {{ filtered_count }} of {{ original_count }} rows';
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
// Send to server to generate Excel
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/generate-from-preview', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
data: data,
|
||||||
|
report_title: reportTitle,
|
||||||
|
project_name: projectName,
|
||||||
|
client_name: clientName,
|
||||||
|
location_name: locationName,
|
||||||
|
time_filter: timeFilter
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Download the file
|
||||||
|
const blob = await response.blob();
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
|
||||||
|
// Get filename from Content-Disposition header if available
|
||||||
|
const contentDisposition = response.headers.get('Content-Disposition');
|
||||||
|
let filename = 'report.xlsx';
|
||||||
|
if (contentDisposition) {
|
||||||
|
const match = contentDisposition.match(/filename="(.+)"/);
|
||||||
|
if (match) filename = match[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
a.remove();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
alert('Error generating report: ' + (error.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error generating report: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom styles for jspreadsheet to match dark mode */
|
||||||
|
.dark .jexcel {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel thead td {
|
||||||
|
background-color: #334155 !important;
|
||||||
|
color: #e2e8f0 !important;
|
||||||
|
border-color: #475569 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel tbody td {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel tbody td:hover {
|
||||||
|
background-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel tbody tr:nth-child(even) td {
|
||||||
|
background-color: #0f172a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_pagination {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_pagination a {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_search {
|
||||||
|
background-color: #1e293b;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_search input {
|
||||||
|
background-color: #334155;
|
||||||
|
color: #e2e8f0;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_content {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_contextmenu {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_contextmenu a {
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .jexcel_contextmenu a:hover {
|
||||||
|
background-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure proper sizing */
|
||||||
|
.jexcel_content {
|
||||||
|
max-height: 600px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
856
templates/rnd_viewer.html
Normal file
@@ -0,0 +1,856 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ filename }} - Sound Level Data Viewer{% endblock %}
|
||||||
|
|
||||||
|
{% block extra_head %}
|
||||||
|
<!-- Chart.js -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-date-fns"></script>
|
||||||
|
<style>
|
||||||
|
.data-table {
|
||||||
|
max-height: 500px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.data-table table {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.data-table th {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
.metric-card {
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.metric-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Header with breadcrumb -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||||
|
<a href="/projects" class="hover:text-seismo-orange">Projects</a>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/projects/{{ project_id }}" class="hover:text-seismo-orange">{{ project.name if project else 'Project' }}</a>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-gray-900 dark:text-white">{{ filename }}</span>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-3">
|
||||||
|
<svg class="w-8 h-8 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ filename }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Sound Level Meter Measurement Data
|
||||||
|
{% if unit %} - {{ unit.id }}{% endif %}
|
||||||
|
{% if location %} @ {{ location.name }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{# Only show Report button for Leq files (15-min averaged data with LN percentiles) #}
|
||||||
|
{% if file and '_Leq_' in file.file_path %}
|
||||||
|
<!-- Generate Excel Report Button -->
|
||||||
|
<button onclick="openReportModal()"
|
||||||
|
class="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
Generate Excel Report
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/api/projects/{{ project_id }}/files/{{ file_id }}/download"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors flex items-center gap-2">
|
||||||
|
<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>
|
||||||
|
Download RND
|
||||||
|
</a>
|
||||||
|
<a href="/projects/{{ project_id }}"
|
||||||
|
class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
Back to Project
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loading-state" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-seismo-orange animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading measurement data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error State -->
|
||||||
|
<div id="error-state" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-12 text-center">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-4 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-red-500 font-semibold mb-2">Error Loading Data</p>
|
||||||
|
<p id="error-message" class="text-gray-500 dark:text-gray-400"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Content (hidden until loaded) -->
|
||||||
|
<div id="data-content" class="hidden space-y-6">
|
||||||
|
<!-- Summary Cards -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">File Type</div>
|
||||||
|
<div id="summary-file-type" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Total Rows</div>
|
||||||
|
<div id="summary-rows" class="text-lg font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Start Time</div>
|
||||||
|
<div id="summary-start" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">End Time</div>
|
||||||
|
<div id="summary-end" class="text-sm font-bold text-gray-900 dark:text-white mt-1">-</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-blue-50 dark:bg-blue-900/20">
|
||||||
|
<div class="text-xs text-blue-600 dark:text-blue-400 uppercase tracking-wide">Avg Level</div>
|
||||||
|
<div id="summary-avg" class="text-lg font-bold text-blue-700 dark:text-blue-300 mt-1">- dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="metric-card bg-white dark:bg-slate-800 rounded-xl shadow p-4 bg-red-50 dark:bg-red-900/20">
|
||||||
|
<div class="text-xs text-red-600 dark:text-red-400 uppercase tracking-wide">Max Level</div>
|
||||||
|
<div id="summary-max" class="text-lg font-bold text-red-700 dark:text-red-300 mt-1">- dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Sound Level Over Time</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<label class="text-sm text-gray-500 dark:text-gray-400">Show:</label>
|
||||||
|
<select id="chart-metric-select" class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="all">All Metrics</option>
|
||||||
|
<option value="leq">Leq Only</option>
|
||||||
|
<option value="lmax">Lmax Only</option>
|
||||||
|
<option value="lmin">Lmin Only</option>
|
||||||
|
<option value="lpeak">Lpeak Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="relative" style="height: 400px;">
|
||||||
|
<canvas id="soundLevelChart"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Measurement Data</h2>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<input type="text" id="table-search" placeholder="Search..."
|
||||||
|
class="text-sm border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-1 bg-white dark:bg-gray-700 text-gray-900 dark:text-white w-48">
|
||||||
|
<span id="row-count" class="text-sm text-gray-500 dark:text-gray-400"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="data-table">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead id="table-header" class="bg-gray-50 dark:bg-gray-900 text-gray-600 dark:text-gray-400 text-xs uppercase">
|
||||||
|
<!-- Headers will be inserted here -->
|
||||||
|
</thead>
|
||||||
|
<tbody id="table-body" class="divide-y divide-gray-100 dark:divide-gray-700">
|
||||||
|
<!-- Data rows will be inserted here -->
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let chartInstance = null;
|
||||||
|
let allData = [];
|
||||||
|
let allHeaders = [];
|
||||||
|
|
||||||
|
// Load data on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadRndData();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadRndData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/projects/{{ project_id }}/files/{{ file_id }}/rnd-data');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(result.detail || 'Failed to load data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store data globally
|
||||||
|
allData = result.data;
|
||||||
|
allHeaders = result.headers;
|
||||||
|
|
||||||
|
// Update summary cards
|
||||||
|
updateSummary(result.summary);
|
||||||
|
|
||||||
|
// Render chart
|
||||||
|
renderChart(result.data, result.summary.file_type);
|
||||||
|
|
||||||
|
// Render table
|
||||||
|
renderTable(result.headers, result.data);
|
||||||
|
|
||||||
|
// Show content, hide loading
|
||||||
|
document.getElementById('loading-state').classList.add('hidden');
|
||||||
|
document.getElementById('data-content').classList.remove('hidden');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading RND data:', error);
|
||||||
|
document.getElementById('loading-state').classList.add('hidden');
|
||||||
|
document.getElementById('error-state').classList.remove('hidden');
|
||||||
|
document.getElementById('error-message').textContent = error.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSummary(summary) {
|
||||||
|
document.getElementById('summary-file-type').textContent =
|
||||||
|
summary.file_type === 'leq' ? 'Leq (Time-Averaged)' :
|
||||||
|
summary.file_type === 'lp' ? 'Lp (Instantaneous)' : 'Unknown';
|
||||||
|
|
||||||
|
document.getElementById('summary-rows').textContent = summary.total_rows.toLocaleString();
|
||||||
|
document.getElementById('summary-start').textContent = summary.time_start || '-';
|
||||||
|
document.getElementById('summary-end').textContent = summary.time_end || '-';
|
||||||
|
|
||||||
|
// Find the main metric based on file type
|
||||||
|
const avgKey = summary.file_type === 'leq' ? 'Leq(Main)_avg' : 'Lp(Main)_avg';
|
||||||
|
const maxKey = summary.file_type === 'leq' ? 'Lmax(Main)_max' : 'Lmax(Main)_max';
|
||||||
|
|
||||||
|
if (summary[avgKey] !== undefined) {
|
||||||
|
document.getElementById('summary-avg').textContent = summary[avgKey].toFixed(1) + ' dB';
|
||||||
|
}
|
||||||
|
if (summary[maxKey] !== undefined) {
|
||||||
|
document.getElementById('summary-max').textContent = summary[maxKey].toFixed(1) + ' dB';
|
||||||
|
} else if (summary['Lpeak(Main)_max'] !== undefined) {
|
||||||
|
document.getElementById('summary-max').textContent = summary['Lpeak(Main)_max'].toFixed(1) + ' dB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart(data, fileType) {
|
||||||
|
const ctx = document.getElementById('soundLevelChart').getContext('2d');
|
||||||
|
|
||||||
|
// Prepare datasets based on file type
|
||||||
|
const datasets = [];
|
||||||
|
const labels = data.map(row => row['Start Time'] || '');
|
||||||
|
|
||||||
|
// Define metrics to chart based on file type
|
||||||
|
const metricsConfig = fileType === 'leq' ? [
|
||||||
|
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(59, 130, 246)', fill: false },
|
||||||
|
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
||||||
|
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
||||||
|
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
||||||
|
] : [
|
||||||
|
{ key: 'Lp(Main)', label: 'Lp', color: 'rgb(59, 130, 246)', fill: false },
|
||||||
|
{ key: 'Leq(Main)', label: 'Leq', color: 'rgb(249, 115, 22)', fill: false },
|
||||||
|
{ key: 'Lmax(Main)', label: 'Lmax', color: 'rgb(239, 68, 68)', fill: false },
|
||||||
|
{ key: 'Lmin(Main)', label: 'Lmin', color: 'rgb(34, 197, 94)', fill: false },
|
||||||
|
{ key: 'Lpeak(Main)', label: 'Lpeak', color: 'rgb(168, 85, 247)', fill: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
metricsConfig.forEach(metric => {
|
||||||
|
const values = data.map(row => {
|
||||||
|
const val = row[metric.key];
|
||||||
|
return typeof val === 'number' ? val : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only add if we have data
|
||||||
|
if (values.some(v => v !== null)) {
|
||||||
|
datasets.push({
|
||||||
|
label: metric.label,
|
||||||
|
data: values,
|
||||||
|
borderColor: metric.color,
|
||||||
|
backgroundColor: metric.color.replace('rgb', 'rgba').replace(')', ', 0.1)'),
|
||||||
|
fill: metric.fill,
|
||||||
|
tension: 0.1,
|
||||||
|
pointRadius: data.length > 100 ? 0 : 2,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Downsample if too many points
|
||||||
|
let chartLabels = labels;
|
||||||
|
let chartDatasets = datasets;
|
||||||
|
|
||||||
|
if (data.length > 1000) {
|
||||||
|
const sampleRate = Math.ceil(data.length / 1000);
|
||||||
|
chartLabels = labels.filter((_, i) => i % sampleRate === 0);
|
||||||
|
chartDatasets = datasets.map(ds => ({
|
||||||
|
...ds,
|
||||||
|
data: ds.data.filter((_, i) => i % sampleRate === 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
chartInstance = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: chartLabels,
|
||||||
|
datasets: chartDatasets
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
labels: {
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||||||
|
usePointStyle: true,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
backgroundColor: document.documentElement.classList.contains('dark') ? '#1f2937' : '#ffffff',
|
||||||
|
titleColor: document.documentElement.classList.contains('dark') ? '#f9fafb' : '#111827',
|
||||||
|
bodyColor: document.documentElement.classList.contains('dark') ? '#d1d5db' : '#4b5563',
|
||||||
|
borderColor: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||||||
|
borderWidth: 1,
|
||||||
|
callbacks: {
|
||||||
|
label: function(context) {
|
||||||
|
return context.dataset.label + ': ' + (context.parsed.y !== null ? context.parsed.y.toFixed(1) + ' dB' : 'N/A');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Time',
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||||||
|
maxTicksLimit: 10,
|
||||||
|
maxRotation: 45,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Sound Level (dB)',
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#9ca3af' : '#374151',
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: document.documentElement.classList.contains('dark') ? '#374151' : '#e5e7eb',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chart metric filter
|
||||||
|
document.getElementById('chart-metric-select').addEventListener('change', function(e) {
|
||||||
|
const value = e.target.value;
|
||||||
|
chartInstance.data.datasets.forEach((ds, i) => {
|
||||||
|
if (value === 'all') {
|
||||||
|
ds.hidden = false;
|
||||||
|
} else if (value === 'leq') {
|
||||||
|
ds.hidden = !['Leq', 'Lp'].includes(ds.label);
|
||||||
|
} else if (value === 'lmax') {
|
||||||
|
ds.hidden = ds.label !== 'Lmax';
|
||||||
|
} else if (value === 'lmin') {
|
||||||
|
ds.hidden = ds.label !== 'Lmin';
|
||||||
|
} else if (value === 'lpeak') {
|
||||||
|
ds.hidden = ds.label !== 'Lpeak';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
chartInstance.update();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(headers, data) {
|
||||||
|
const headerRow = document.getElementById('table-header');
|
||||||
|
const tbody = document.getElementById('table-body');
|
||||||
|
|
||||||
|
// Render headers
|
||||||
|
headerRow.innerHTML = '<tr>' + headers.map(h =>
|
||||||
|
`<th class="px-4 py-3 text-left font-medium">${escapeHtml(h)}</th>`
|
||||||
|
).join('') + '</tr>';
|
||||||
|
|
||||||
|
// Render rows (limit to first 500 for performance)
|
||||||
|
const displayData = data.slice(0, 500);
|
||||||
|
tbody.innerHTML = displayData.map(row =>
|
||||||
|
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
||||||
|
headers.map(h => {
|
||||||
|
const val = row[h];
|
||||||
|
let displayVal = val;
|
||||||
|
if (val === null || val === undefined) {
|
||||||
|
displayVal = '-';
|
||||||
|
} else if (typeof val === 'number') {
|
||||||
|
displayVal = val.toFixed(1);
|
||||||
|
}
|
||||||
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||||
|
}).join('') +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Update row count
|
||||||
|
document.getElementById('row-count').textContent =
|
||||||
|
data.length > 500 ? `Showing 500 of ${data.length.toLocaleString()} rows` : `${data.length.toLocaleString()} rows`;
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
document.getElementById('table-search').addEventListener('input', function(e) {
|
||||||
|
const searchTerm = e.target.value.toLowerCase();
|
||||||
|
const filtered = data.filter(row =>
|
||||||
|
Object.values(row).some(v =>
|
||||||
|
String(v).toLowerCase().includes(searchTerm)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayFiltered = filtered.slice(0, 500);
|
||||||
|
tbody.innerHTML = displayFiltered.map(row =>
|
||||||
|
'<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50">' +
|
||||||
|
headers.map(h => {
|
||||||
|
const val = row[h];
|
||||||
|
let displayVal = val;
|
||||||
|
if (val === null || val === undefined) {
|
||||||
|
displayVal = '-';
|
||||||
|
} else if (typeof val === 'number') {
|
||||||
|
displayVal = val.toFixed(1);
|
||||||
|
}
|
||||||
|
return `<td class="px-4 py-2 text-gray-700 dark:text-gray-300">${escapeHtml(String(displayVal))}</td>`;
|
||||||
|
}).join('') +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
document.getElementById('row-count').textContent =
|
||||||
|
filtered.length > 500 ? `Showing 500 of ${filtered.length.toLocaleString()} filtered rows` : `${filtered.length.toLocaleString()} rows`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = str;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report Generation Modal Functions
|
||||||
|
let reportTemplates = [];
|
||||||
|
|
||||||
|
async function loadTemplates() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/report-templates?project_id={{ project_id }}');
|
||||||
|
if (response.ok) {
|
||||||
|
reportTemplates = await response.json();
|
||||||
|
populateTemplateDropdown();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading templates:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update preset buttons
|
||||||
|
updatePresetButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTimePreset(preset) {
|
||||||
|
const startTimeInput = document.getElementById('start-time');
|
||||||
|
const endTimeInput = document.getElementById('end-time');
|
||||||
|
|
||||||
|
// Remove active state from all preset buttons
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set time values based on preset
|
||||||
|
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':
|
||||||
|
// Just enable custom input, don't change values
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight active preset
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Remove active state from all
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check which preset matches
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportModal() {
|
||||||
|
document.getElementById('report-modal').classList.remove('hidden');
|
||||||
|
loadTemplates();
|
||||||
|
|
||||||
|
// Pre-fill fields if available
|
||||||
|
const locationInput = document.getElementById('report-location');
|
||||||
|
if (locationInput && !locationInput.value) {
|
||||||
|
locationInput.value = '{{ location.name if location else "" }}';
|
||||||
|
}
|
||||||
|
const projectInput = document.getElementById('report-project');
|
||||||
|
if (projectInput && !projectInput.value) {
|
||||||
|
projectInput.value = '{{ project.name if project else "" }}';
|
||||||
|
}
|
||||||
|
const clientInput = document.getElementById('report-client');
|
||||||
|
if (clientInput && !clientInput.value) {
|
||||||
|
clientInput.value = '{{ project.client_name if project and project.client_name else "" }}';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default to "All Day"
|
||||||
|
setTimePreset('all');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReportModal() {
|
||||||
|
document.getElementById('report-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateReport(preview = false) {
|
||||||
|
const reportTitle = document.getElementById('report-title').value || 'Background Noise Study';
|
||||||
|
const projectName = document.getElementById('report-project').value || '';
|
||||||
|
const clientName = document.getElementById('report-client').value || '';
|
||||||
|
const locationName = document.getElementById('report-location').value || '';
|
||||||
|
const startTime = document.getElementById('start-time').value || '';
|
||||||
|
const endTime = document.getElementById('end-time').value || '';
|
||||||
|
const startDate = document.getElementById('start-date').value || '';
|
||||||
|
const endDate = document.getElementById('end-date').value || '';
|
||||||
|
|
||||||
|
// Build the URL with query parameters
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
report_title: reportTitle,
|
||||||
|
project_name: projectName,
|
||||||
|
client_name: clientName,
|
||||||
|
location_name: locationName,
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
start_date: startDate,
|
||||||
|
end_date: endDate
|
||||||
|
});
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
// Open preview page
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/preview-report?${params.toString()}`;
|
||||||
|
} else {
|
||||||
|
// Direct download
|
||||||
|
window.location.href = `/api/projects/{{ project_id }}/files/{{ file_id }}/generate-report?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeReportModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAsTemplate() {
|
||||||
|
const name = prompt('Enter a name for this template:');
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
const templateData = {
|
||||||
|
name: 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', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(templateData)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Template saved successfully!');
|
||||||
|
loadTemplates();
|
||||||
|
} else {
|
||||||
|
alert('Failed to save template');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Error saving template: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeReportModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update preset buttons when time inputs change
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const startTimeInput = document.getElementById('start-time');
|
||||||
|
const endTimeInput = document.getElementById('end-time');
|
||||||
|
if (startTimeInput) startTimeInput.addEventListener('change', updatePresetButtons);
|
||||||
|
if (endTimeInput) endTimeInput.addEventListener('change', updatePresetButtons);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Report Generation Modal -->
|
||||||
|
<div id="report-modal" class="hidden fixed inset-0 z-50 overflow-y-auto" aria-labelledby="modal-title" role="dialog" aria-modal="true">
|
||||||
|
<div class="flex items-end justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
||||||
|
<!-- Background overlay -->
|
||||||
|
<div class="fixed inset-0 bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75 transition-opacity" onclick="closeReportModal()"></div>
|
||||||
|
|
||||||
|
<!-- Modal panel -->
|
||||||
|
<div class="inline-block align-bottom bg-white dark:bg-slate-800 rounded-lg text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-2xl sm:w-full">
|
||||||
|
<div class="bg-white dark:bg-slate-800 px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
|
||||||
|
<div class="sm:flex sm:items-start">
|
||||||
|
<div class="mx-auto flex-shrink-0 flex items-center justify-center h-12 w-12 rounded-full bg-emerald-100 dark:bg-emerald-900/30 sm:mx-0 sm:h-10 sm:w-10">
|
||||||
|
<svg class="h-6 w-6 text-emerald-600 dark:text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 text-center sm:mt-0 sm:ml-4 sm:text-left flex-1">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-white" id="modal-title">
|
||||||
|
Generate Excel Report
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="mt-4 space-y-4">
|
||||||
|
<!-- Template Selection -->
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="flex-1">
|
||||||
|
<label for="template-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Load Template
|
||||||
|
</label>
|
||||||
|
<select id="template-select" onchange="applyTemplate()"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
<option value="">-- Select a template --</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" onclick="saveAsTemplate()"
|
||||||
|
class="mt-6 px-3 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-md hover:bg-gray-300 dark:hover:bg-gray-600"
|
||||||
|
title="Save current settings as template">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Report Title -->
|
||||||
|
<div>
|
||||||
|
<label for="report-title" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Report Title
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-title" value="Background Noise Study"
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project and Client in a row -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="report-project" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Project Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-project" value=""
|
||||||
|
class="mt-1 block w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:outline-none focus:ring-emerald-500 focus:border-emerald-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="report-client" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Client Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-client" value=""
|
||||||
|
class="mt-1 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>
|
||||||
|
|
||||||
|
<!-- Location Name -->
|
||||||
|
<div>
|
||||||
|
<label for="report-location" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Location Name
|
||||||
|
</label>
|
||||||
|
<input type="text" id="report-location" value=""
|
||||||
|
class="mt-1 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>
|
||||||
|
|
||||||
|
<!-- Time Filter Section -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
Time Filter
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Preset Buttons -->
|
||||||
|
<div class="flex gap-2 mb-3">
|
||||||
|
<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">
|
||||||
|
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">
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Custom Time Inputs -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label for="start-time" class="block text-xs text-gray-500 dark:text-gray-400">Start Time</label>
|
||||||
|
<input type="time" id="start-time" value=""
|
||||||
|
class="mt-1 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">End Time</label>
|
||||||
|
<input type="time" id="end-time" value=""
|
||||||
|
class="mt-1 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>
|
||||||
|
|
||||||
|
<!-- Date Range (Optional) -->
|
||||||
|
<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-3">
|
||||||
|
<div>
|
||||||
|
<label for="start-date" class="block text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="date" id="start-date" value=""
|
||||||
|
class="mt-1 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">To</label>
|
||||||
|
<input type="date" id="end-date" value=""
|
||||||
|
class="mt-1 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>
|
||||||
|
|
||||||
|
<!-- Info about what's included -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<strong>Report will include:</strong>
|
||||||
|
</p>
|
||||||
|
<ul class="mt-1 text-xs text-gray-500 dark:text-gray-400 list-disc list-inside space-y-0.5">
|
||||||
|
<li>Data table (Test #, Date, Time, LAmax, LA01, LA10, Comments)</li>
|
||||||
|
<li>Line chart visualization</li>
|
||||||
|
<li>Time period summary (Evening, Night, Morning, Daytime)</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 sm:px-6 flex flex-col sm:flex-row-reverse gap-2">
|
||||||
|
<button type="button" onclick="generateReport(false)"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-transparent shadow-sm px-4 py-2 bg-emerald-600 text-base font-medium text-white hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Download Excel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="generateReport(true)"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-emerald-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-emerald-600 dark:text-emerald-400 hover:bg-emerald-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Preview & Edit
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeReportModal()"
|
||||||
|
class="w-full inline-flex justify-center rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-700 text-base font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-emerald-500 sm:w-auto sm:text-sm">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -131,10 +131,8 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id"
|
{% include "partials/project_picker.html" with context %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
|
||||||
placeholder="PROJ-001">
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||||
@@ -159,8 +157,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modemPairingField" class="hidden">
|
<div id="modemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
{% set picker_id = "-add-seismo" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/modem_picker.html" with context %}
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -183,6 +181,21 @@
|
|||||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||||
|
<select name="deployment_type"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="slm">Sound Level Meter (SLM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||||
|
{% set picker_id = "-add-modem" %}
|
||||||
|
{% set device_type_filter = "" %}
|
||||||
|
{% include "partials/unit_picker.html" with context %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
<!-- Sound Level Meter-specific fields -->
|
||||||
@@ -297,9 +310,9 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id" id="editProjectId"
|
{% set picker_id = "-edit" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
@@ -327,8 +340,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="editModemPairingField" class="hidden">
|
<div id="editModemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
{% set picker_id = "-edit-seismo" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/modem_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -350,6 +363,21 @@
|
|||||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
||||||
|
<select name="deployment_type" id="editDeploymentType"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="">Not assigned</option>
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="slm">Sound Level Meter (SLM)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
||||||
|
{% set picker_id = "-edit-modem" %}
|
||||||
|
{% set device_type_filter = "" %}
|
||||||
|
{% include "partials/unit_picker.html" with context %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
<!-- Sound Level Meter-specific fields -->
|
||||||
@@ -419,6 +447,55 @@
|
|||||||
<textarea name="note" id="editNote" rows="3"
|
<textarea name="note" id="editNote" rows="3"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Cascade to Paired Device Section -->
|
||||||
|
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
||||||
|
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
|
||||||
|
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" 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 status</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" 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 status</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_project" id="editCascadeProject" 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">Project</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_location" id="editCascadeLocation" 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">Address</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" 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">Coordinates</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="cascade_note" id="editCascadeNote" 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">Notes</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||||
|
Check the fields you want to sync to the paired device
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
@@ -650,11 +727,7 @@
|
|||||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||||
if (event.detail.successful) {
|
if (event.detail.successful) {
|
||||||
closeAddUnitModal();
|
closeAddUnitModal();
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
// Show success message
|
// Show success message
|
||||||
alert('Unit added successfully!');
|
alert('Unit added successfully!');
|
||||||
} else {
|
} else {
|
||||||
@@ -692,6 +765,33 @@
|
|||||||
function closeEditUnitModal() {
|
function closeEditUnitModal() {
|
||||||
document.getElementById('editUnitModal').classList.add('hidden');
|
document.getElementById('editUnitModal').classList.add('hidden');
|
||||||
document.getElementById('editUnitForm').reset();
|
document.getElementById('editUnitForm').reset();
|
||||||
|
// Also clear the project picker
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = '';
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch project display name for edit modal
|
||||||
|
async function fetchProjectDisplayForEdit(projectId) {
|
||||||
|
if (!projectId) return '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const project = await response.json();
|
||||||
|
const parts = [
|
||||||
|
project.project_number,
|
||||||
|
project.client_name,
|
||||||
|
project.name
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' - ') || projectId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch project:', e);
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle device-specific fields in edit modal
|
// Toggle device-specific fields in edit modal
|
||||||
@@ -753,7 +853,23 @@
|
|||||||
document.getElementById('editUnitId').value = unit.id;
|
document.getElementById('editUnitId').value = unit.id;
|
||||||
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||||
document.getElementById('editUnitType').value = unit.unit_type;
|
document.getElementById('editUnitType').value = unit.unit_type;
|
||||||
document.getElementById('editProjectId').value = unit.project_id;
|
|
||||||
|
// Populate project picker (uses -edit suffix)
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
|
||||||
|
if (unit.project_id) {
|
||||||
|
// Fetch project display name
|
||||||
|
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('editAddress').value = unit.address;
|
document.getElementById('editAddress').value = unit.address;
|
||||||
document.getElementById('editCoordinates').value = unit.coordinates;
|
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||||
document.getElementById('editNote').value = unit.note;
|
document.getElementById('editNote').value = unit.note;
|
||||||
@@ -765,12 +881,62 @@
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||||
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
|
||||||
|
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
||||||
|
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
||||||
|
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
|
||||||
|
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
|
||||||
|
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
|
||||||
|
if (unit.deployed_with_modem_id) {
|
||||||
|
// Fetch modem display (ID + IP + note)
|
||||||
|
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(modem => {
|
||||||
|
if (modem && modemPickerSearch) {
|
||||||
|
let display = modem.id;
|
||||||
|
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
||||||
|
if (modem.note) display += ` - ${modem.note}`;
|
||||||
|
modemPickerSearch.value = display;
|
||||||
|
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (modemPickerSearch) modemPickerSearch.value = '';
|
||||||
|
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Modem fields
|
// Modem fields
|
||||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
|
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
||||||
|
|
||||||
|
// Populate unit picker for modem (uses -edit-modem suffix)
|
||||||
|
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
|
||||||
|
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
|
||||||
|
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
|
||||||
|
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
|
||||||
|
if (unit.deployed_with_unit_id) {
|
||||||
|
// Fetch unit display (ID + note)
|
||||||
|
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
|
||||||
|
.then(r => r.ok ? r.json() : null)
|
||||||
|
.then(linkedUnit => {
|
||||||
|
if (linkedUnit && unitPickerSearch) {
|
||||||
|
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
|
||||||
|
unitPickerSearch.value = display;
|
||||||
|
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (unitPickerSearch) unitPickerSearch.value = '';
|
||||||
|
if (unitPickerClear) unitPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// SLM fields
|
// SLM fields
|
||||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||||
@@ -781,6 +947,35 @@
|
|||||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||||
|
|
||||||
|
// Cascade section - show if there's a paired device
|
||||||
|
const cascadeSection = document.getElementById('editCascadeSection');
|
||||||
|
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
||||||
|
const pairedDeviceName = document.getElementById('editPairedDeviceName');
|
||||||
|
|
||||||
|
// Determine paired device based on device type
|
||||||
|
let pairedUnitId = null;
|
||||||
|
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
|
||||||
|
pairedUnitId = unit.deployed_with_unit_id;
|
||||||
|
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
|
||||||
|
pairedUnitId = unit.deployed_with_modem_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pairedUnitId) {
|
||||||
|
cascadeToUnitId.value = pairedUnitId;
|
||||||
|
pairedDeviceName.textContent = pairedUnitId;
|
||||||
|
cascadeSection.classList.remove('hidden');
|
||||||
|
// Reset checkboxes
|
||||||
|
document.getElementById('editCascadeDeployed').checked = false;
|
||||||
|
document.getElementById('editCascadeRetired').checked = false;
|
||||||
|
document.getElementById('editCascadeProject').checked = false;
|
||||||
|
document.getElementById('editCascadeLocation').checked = false;
|
||||||
|
document.getElementById('editCascadeCoordinates').checked = false;
|
||||||
|
document.getElementById('editCascadeNote').checked = false;
|
||||||
|
} else {
|
||||||
|
cascadeToUnitId.value = '';
|
||||||
|
cascadeSection.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// Store unit ID for form submission
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
@@ -814,11 +1009,7 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeEditUnitModal();
|
closeEditUnitModal();
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert('Unit updated successfully!');
|
alert('Unit updated successfully!');
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -846,11 +1037,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -878,11 +1065,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${unitId} moved to ignore list`);
|
alert(`Unit ${unitId} moved to ignore list`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -905,11 +1088,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
alert(`Unit ${unitId} deleted successfully`);
|
alert(`Unit ${unitId} deleted successfully`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -948,11 +1127,7 @@
|
|||||||
`;
|
`;
|
||||||
resultDiv.classList.remove('hidden');
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
// Trigger roster refresh for current active tab
|
refreshDeviceList();
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal after 2 seconds
|
// Close modal after 2 seconds
|
||||||
setTimeout(() => closeImportModal(), 2000);
|
setTimeout(() => closeImportModal(), 2000);
|
||||||
@@ -968,35 +1143,26 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle roster tab switching with auto-refresh
|
// Refresh device list (applies current client-side filters after load)
|
||||||
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
function refreshDeviceList() {
|
||||||
|
htmx.ajax('GET', '/partials/devices-all', {
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
target: '#device-content',
|
||||||
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
|
||||||
|
|
||||||
tabButtons.forEach(button => {
|
|
||||||
button.addEventListener('click', function() {
|
|
||||||
// Remove active-roster-tab class from all buttons
|
|
||||||
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
|
|
||||||
// Add active-roster-tab class to clicked button
|
|
||||||
this.classList.add('active-roster-tab');
|
|
||||||
|
|
||||||
// Update current endpoint for auto-refresh
|
|
||||||
currentRosterEndpoint = this.getAttribute('data-endpoint');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-refresh the current active tab every 10 seconds
|
|
||||||
setInterval(() => {
|
|
||||||
const rosterContent = document.getElementById('roster-content');
|
|
||||||
if (rosterContent) {
|
|
||||||
// Use HTMX to trigger a refresh of the current endpoint
|
|
||||||
htmx.ajax('GET', currentRosterEndpoint, {
|
|
||||||
target: '#roster-content',
|
|
||||||
swap: 'innerHTML'
|
swap: 'innerHTML'
|
||||||
|
}).then(() => {
|
||||||
|
// Re-apply filters after content loads
|
||||||
|
setTimeout(filterDevices, 100);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, 10000); // 10 seconds
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
||||||
|
setInterval(() => {
|
||||||
|
const deviceContent = document.getElementById('device-content');
|
||||||
|
if (deviceContent && !document.querySelector('.modal:not(.hidden)')) {
|
||||||
|
// Only auto-refresh if no modal is open
|
||||||
|
refreshDeviceList();
|
||||||
|
}
|
||||||
|
}, 30000);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Un-ignore Unit (remove from ignored list)
|
// Un-ignore Unit (remove from ignored list)
|
||||||
@@ -1345,4 +1511,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
<!-- Include Project Create Modal for inline project creation -->
|
||||||
|
{% include "partials/project_create_modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -77,7 +77,7 @@
|
|||||||
{% if not from_project and not from_nrl %}
|
{% if not from_project and not from_nrl %}
|
||||||
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button onclick="openConfigModal()"
|
<button onclick="openSLMSettingsModal('{{ unit_id }}')"
|
||||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
@@ -104,73 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
<!-- Unified SLM Settings Modal -->
|
||||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
{% include 'partials/slm_settings_modal.html' %}
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
|
|
||||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="config-modal-content"
|
|
||||||
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<div class="p-6 space-y-4 animate-pulse">
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Modal functions
|
|
||||||
function openConfigModal() {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
// Reload config when opening
|
|
||||||
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
|
|
||||||
target: '#config-modal-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConfigModal() {
|
|
||||||
document.getElementById('config-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcut
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside to close
|
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for config updates to refresh live view
|
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
||||||
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
|
|
||||||
// Refresh live view after config update
|
|
||||||
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
|
|
||||||
target: '#live-view-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -137,27 +137,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
<!-- Unified SLM Settings Modal -->
|
||||||
<div id="slm-config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
{% include 'partials/slm_settings_modal.html' %}
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
|
||||||
<button onclick="closeDeviceConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="slm-config-modal-content">
|
|
||||||
<div class="animate-pulse space-y-4">
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
@@ -365,33 +346,23 @@ function updateDashboardChart(data) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Configuration modal
|
// Configuration modal - use unified SLM settings modal
|
||||||
function openDeviceConfigModal(unitId) {
|
function openDeviceConfigModal(unitId) {
|
||||||
const modal = document.getElementById('slm-config-modal');
|
// Call the unified modal function from slm_settings_modal.html
|
||||||
modal.classList.remove('hidden');
|
if (typeof openSLMSettingsModal === 'function') {
|
||||||
|
openSLMSettingsModal(unitId);
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
} else {
|
||||||
target: '#slm-config-modal-content',
|
console.error('openSLMSettingsModal not found');
|
||||||
swap: 'innerHTML'
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDeviceConfigModal() {
|
function closeDeviceConfigModal() {
|
||||||
document.getElementById('slm-config-modal').classList.add('hidden');
|
// Call the unified modal close function
|
||||||
|
if (typeof closeSLMSettingsModal === 'function') {
|
||||||
|
closeSLMSettingsModal();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeDeviceConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeDeviceConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
stopDashboardStream();
|
stopDashboardStream();
|
||||||
|
|||||||
@@ -121,8 +121,13 @@
|
|||||||
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
||||||
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewProjectContainer" class="mt-1">
|
||||||
|
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
||||||
|
<span id="viewProjectText">--</span>
|
||||||
|
</a>
|
||||||
|
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
@@ -172,6 +177,48 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Paired Device (for modems only) -->
|
||||||
|
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
||||||
|
<div id="pairedDeviceInfo">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connectivity (for modems only) -->
|
||||||
|
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
||||||
|
<div class="flex items-center gap-4 mb-4">
|
||||||
|
<button onclick="pingModem()" id="modemPingBtn"
|
||||||
|
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
Ping Test
|
||||||
|
</button>
|
||||||
|
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Future Diagnostics Placeholders -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
||||||
|
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
||||||
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
@@ -251,11 +298,11 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project ID -->
|
<!-- Project -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
||||||
<input type="text" name="project_id" id="projectId"
|
{% set picker_id = "-detail" %}
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
{% include "partials/project_picker.html" with context %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
@@ -415,6 +462,26 @@ let currentSnapshot = null;
|
|||||||
let unitMap = null;
|
let unitMap = null;
|
||||||
let mapMarker = null;
|
let mapMarker = null;
|
||||||
|
|
||||||
|
// Fetch project display name (combines project_number, client_name, name)
|
||||||
|
async function fetchProjectDisplay(projectId) {
|
||||||
|
if (!projectId) return '';
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const project = await response.json();
|
||||||
|
const parts = [
|
||||||
|
project.project_number,
|
||||||
|
project.client_name,
|
||||||
|
project.name
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' - ') || projectId;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to fetch project:', e);
|
||||||
|
}
|
||||||
|
return projectId;
|
||||||
|
}
|
||||||
|
|
||||||
// Load unit data on page load
|
// Load unit data on page load
|
||||||
async function loadUnitData() {
|
async function loadUnitData() {
|
||||||
try {
|
try {
|
||||||
@@ -536,7 +603,31 @@ function populateViewMode() {
|
|||||||
// Basic info
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||||
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
|
||||||
|
// Project display with clickable link
|
||||||
|
const projectId = currentUnit.project_id;
|
||||||
|
const projectLink = document.getElementById('viewProjectLink');
|
||||||
|
const projectNoLink = document.getElementById('viewProjectNoLink');
|
||||||
|
const projectText = document.getElementById('viewProjectText');
|
||||||
|
|
||||||
|
if (projectId) {
|
||||||
|
// Fetch project display name and show link
|
||||||
|
fetchProjectDisplay(projectId).then(displayText => {
|
||||||
|
if (projectText) projectText.textContent = displayText;
|
||||||
|
if (projectLink) {
|
||||||
|
projectLink.href = `/projects/${projectId}`;
|
||||||
|
projectLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (projectNoLink) projectNoLink.classList.add('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectNoLink) {
|
||||||
|
projectNoLink.textContent = 'Not assigned';
|
||||||
|
projectNoLink.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
if (projectLink) projectLink.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
|
|
||||||
@@ -557,9 +648,15 @@ function populateViewMode() {
|
|||||||
if (currentUnit.device_type === 'modem') {
|
if (currentUnit.device_type === 'modem') {
|
||||||
document.getElementById('viewSeismographFields').classList.add('hidden');
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||||
document.getElementById('viewModemFields').classList.remove('hidden');
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||||
|
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
||||||
|
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
||||||
|
// Load paired device info
|
||||||
|
loadPairedDevice();
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||||
document.getElementById('viewModemFields').classList.add('hidden');
|
document.getElementById('viewModemFields').classList.add('hidden');
|
||||||
|
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
||||||
|
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -567,7 +664,22 @@ function populateViewMode() {
|
|||||||
function populateEditForm() {
|
function populateEditForm() {
|
||||||
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||||
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||||
document.getElementById('projectId').value = currentUnit.project_id || '';
|
|
||||||
|
// Populate project picker (uses -detail suffix)
|
||||||
|
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
||||||
|
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
||||||
|
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
||||||
|
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
||||||
|
if (currentUnit.project_id) {
|
||||||
|
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
if (projectPickerSearch) projectPickerSearch.value = '';
|
||||||
|
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
@@ -999,10 +1111,66 @@ async function deleteHistoryEntry(historyId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load paired device info for modems
|
||||||
|
async function loadPairedDevice() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
||||||
|
if (response.ok) {
|
||||||
|
const html = await response.text();
|
||||||
|
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading paired device:', error);
|
||||||
|
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping modem and show result
|
||||||
|
async function pingModem() {
|
||||||
|
const btn = document.getElementById('modemPingBtn');
|
||||||
|
const resultSpan = document.getElementById('modemPingResult');
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = `
|
||||||
|
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Pinging...
|
||||||
|
`;
|
||||||
|
btn.disabled = true;
|
||||||
|
resultSpan.textContent = 'Testing connection...';
|
||||||
|
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'success') {
|
||||||
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
||||||
|
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
||||||
|
} else {
|
||||||
|
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
||||||
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultSpan.textContent = 'Error: ' + error.message;
|
||||||
|
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore button
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Load data when page loads
|
// Load data when page loads
|
||||||
loadUnitData().then(() => {
|
loadUnitData().then(() => {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Include Project Create Modal for inline project creation -->
|
||||||
|
{% include "partials/project_create_modal.html" %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||