Compare commits
29 Commits
v0.4.0
...
65ea0920db
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ea0920db | ||
|
|
1f3fa7a718 | ||
|
|
a9c9b1fd48 | ||
|
|
4c213c96ee | ||
|
|
ff38b74548 | ||
|
|
c8a030a3ba | ||
|
|
d8a8330427 | ||
|
|
1ef0557ccb | ||
|
|
6c7ce5aad0 | ||
|
|
54754e2279 | ||
|
|
8787a2dbb8 | ||
|
|
7971092509 | ||
|
|
d349af9444 | ||
|
|
be83cb3fe7 | ||
|
|
e9216b9abc | ||
|
|
d93785c230 | ||
|
|
98ee9d7cea | ||
|
|
04c66bdf9c | ||
|
|
8a5fadb5df | ||
|
|
893cb96e8d | ||
|
|
c30d7fac22 | ||
|
|
6d34e543fe | ||
|
|
4d74eda65f | ||
|
|
96cb27ef83 | ||
|
|
85b211e532 | ||
|
|
e16f61aca7 | ||
|
|
dba4ad168c | ||
|
|
e78d252cf3 | ||
|
|
ab9c650d93 |
@@ -1,19 +1,41 @@
|
||||
# Python cache / compiled
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
|
||||
# Build artifacts
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
|
||||
# VCS
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Databases (must live in volumes)
|
||||
*.db
|
||||
*.db-journal
|
||||
|
||||
# Environment / virtualenv
|
||||
.env
|
||||
.venv
|
||||
venv/
|
||||
ENV/
|
||||
|
||||
# Runtime data (mounted volumes)
|
||||
data/
|
||||
|
||||
# Editors / OS junk
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.claude
|
||||
sfm.code-workspace
|
||||
|
||||
# Tests (optional)
|
||||
tests/
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -211,3 +211,5 @@ __marimo__/
|
||||
*.db
|
||||
*.db-journal
|
||||
data/
|
||||
.aider*
|
||||
.aider*
|
||||
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -5,6 +5,74 @@ All notable changes to Seismo Fleet Manager will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.3] - 2026-01-14
|
||||
|
||||
### Added
|
||||
- **Sound Level Meter roster tooling**: Roster manager surfaces SLM metadata, supports rename unit flows, and adds return-to-project navigation to keep SLM dashboard users oriented.
|
||||
- **Project management templates**: New schedule and unit list templates plus file/session lists show what each project stores before teams dive into deployments.
|
||||
|
||||
### Changed
|
||||
- **Project view refresh**: FTP browser now downloads folders locally, the countdown timer was rebuilt, and project/device templates gained edit modals for projects and locations so navigation feels smoother.
|
||||
- **SLM control sync & accuracy**: Control center groundwork now runs inside the dev UI, configuration edits propagate to SLMM (which caches configs for faster responses), and the SLM live view reads the correct DRD fields after the refactor.
|
||||
|
||||
### Fixed
|
||||
- **SLM UI syntax bug**: Resolved the unexpected token error that appeared in the refreshed SLM components.
|
||||
|
||||
## [0.4.2] - 2026-01-05
|
||||
|
||||
### Added
|
||||
- **SLM Configuration Interface**: Sound Level Meters can now be configured directly from the SLM dashboard
|
||||
- Configuration modal with comprehensive SLM parameter editing
|
||||
- TCP port configuration for SLM control connections (default: 2255)
|
||||
- FTP port configuration for SLM data retrieval (default: 21)
|
||||
- Modem assignment for network access or direct IP connection support
|
||||
- Test Modem button with ping-based connectivity verification (shows IP and response time)
|
||||
- Test SLM Connection button for end-to-end connectivity validation
|
||||
- Dynamic form fields that hide/show based on modem selection
|
||||
- **SLM Dashboard Endpoints**: New API routes for SLM management
|
||||
- `GET /api/slm-dashboard/config/{unit_id}` - Load SLM configuration form
|
||||
- `POST /api/slm-dashboard/config/{unit_id}` - Save SLM configuration
|
||||
- `GET /api/slm-dashboard/test-modem/{modem_id}` - Ping modem for connectivity test
|
||||
- **Database Schema Updates**: Added `slm_ftp_port` column to roster table
|
||||
- Migration script: `scripts/add_slm_ftp_port.py`
|
||||
- Supports both TCP (control) and FTP (data) port configuration per SLM unit
|
||||
- **Docker Environment Enhancements**:
|
||||
- Added `iputils-ping` and `curl` packages to Docker image for network diagnostics
|
||||
- Health check endpoint support via curl
|
||||
|
||||
### Fixed
|
||||
- **Form Validation**: Fixed 400 Bad Request error when adding modem units
|
||||
- Form fields for device-specific parameters now properly disabled when hidden
|
||||
- Empty string values for integer fields no longer cause validation failures
|
||||
- JavaScript now disables hidden form sections to prevent unwanted data submission
|
||||
- **Unit Status Accuracy**: Fixed issue where unit status was loading from a saved cache instead of actual last-heard time
|
||||
- Unit status now accurately reflects real-time connectivity
|
||||
- Status determination based on actual `slm_last_check` timestamp
|
||||
|
||||
### Changed
|
||||
- **Roster Form Behavior**: Device-specific form fields are now disabled (not just hidden) when not applicable
|
||||
- Prevents SLM fields from submitting when adding modems
|
||||
- Prevents modem fields from submitting when adding SLMs
|
||||
- Cleaner form submissions with only relevant data
|
||||
- **Port Field Handling**: Backend now accepts port fields as strings and converts to integers
|
||||
- Handles empty string values gracefully
|
||||
- Proper type conversion with None fallback for empty values
|
||||
|
||||
### Technical Details
|
||||
- Added `setFieldsDisabled()` helper function for managing form field state
|
||||
- Updated `toggleDeviceFields()` and `toggleEditDeviceFields()` to disable/enable fields
|
||||
- Backend type conversion: `slm_tcp_port` and `slm_ftp_port` accept strings, convert to int with empty string handling
|
||||
- Modem ping uses subprocess with 1 packet, 2-second timeout, returns response time in milliseconds
|
||||
- Configuration form uses 3-column grid layout for TCP Port, FTP Port, and Direct IP fields
|
||||
|
||||
## [0.4.1] - 2026-01-05
|
||||
### Added
|
||||
- **SLM Integration**: Sound Level Meters are now manageable in SFM
|
||||
|
||||
### Fixed
|
||||
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.
|
||||
|
||||
|
||||
## [0.4.0] - 2025-12-16
|
||||
|
||||
### Added
|
||||
@@ -293,6 +361,9 @@ No database migration required for v0.4.0. All new features use existing databas
|
||||
- Photo management per unit
|
||||
- Automated status categorization (OK/Pending/Missing)
|
||||
|
||||
[0.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.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
|
||||
|
||||
@@ -3,6 +3,11 @@ FROM python:3.11-slim
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Install system dependencies (ping for network diagnostics)
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy requirements first for better caching
|
||||
COPY requirements.txt .
|
||||
|
||||
|
||||
53
README.md
53
README.md
@@ -1,4 +1,4 @@
|
||||
# Seismo Fleet Manager v0.4.0
|
||||
# Seismo Fleet Manager v0.4.3
|
||||
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
|
||||
@@ -308,7 +308,7 @@ print(response.json())
|
||||
|-------|------|-------------|
|
||||
| id | string | Unit identifier (primary key) |
|
||||
| unit_type | string | Hardware model name (default: `series3`) |
|
||||
| device_type | string | `seismograph` or `modem` discriminator |
|
||||
| device_type | string | Device type: `"seismograph"`, `"modem"`, or `"slm"` (sound level meter) |
|
||||
| deployed | boolean | Whether the unit is in the field |
|
||||
| retired | boolean | Removes the unit from deployments but preserves history |
|
||||
| note | string | Notes about the unit |
|
||||
@@ -334,6 +334,39 @@ print(response.json())
|
||||
| phone_number | string | Cellular number for the modem |
|
||||
| hardware_model | string | Modem hardware reference |
|
||||
|
||||
**Sound Level Meter (SLM) fields**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| slm_host | string | Direct IP address for SLM (if not using modem) |
|
||||
| slm_tcp_port | integer | TCP control port (default: 2255) |
|
||||
| slm_ftp_port | integer | FTP file transfer port (default: 21) |
|
||||
| slm_model | string | Device model (NL-43, NL-53) |
|
||||
| slm_serial_number | string | Manufacturer serial number |
|
||||
| slm_frequency_weighting | string | Frequency weighting setting (A, C, Z) |
|
||||
| slm_time_weighting | string | Time weighting setting (F=Fast, S=Slow) |
|
||||
| slm_measurement_range | string | Measurement range setting |
|
||||
| slm_last_check | datetime | Last status check timestamp |
|
||||
| deployed_with_modem_id | string | Modem pairing (shared with seismographs) |
|
||||
|
||||
### Device Type Schema
|
||||
|
||||
Terra-View supports three device types with the following standardized `device_type` values:
|
||||
|
||||
- **`"seismograph"`** (default) - Seismic monitoring devices (Series 3, Series 4, Micromate)
|
||||
- Uses: calibration dates, modem pairing
|
||||
- Examples: BE1234, UM12345 (Series 3/4 units)
|
||||
|
||||
- **`"modem"`** - Field modems and network equipment
|
||||
- Uses: IP address, phone number, hardware model
|
||||
- Examples: MDM001, MODEM-2025-01
|
||||
|
||||
- **`"slm"`** - Sound level meters (Rion NL-43/NL-53)
|
||||
- Uses: TCP/FTP configuration, measurement settings, modem pairing
|
||||
- Examples: SLM-43-01, NL43-001
|
||||
|
||||
**Important**: All `device_type` values must be lowercase. The legacy value `"sound_level_meter"` has been deprecated in favor of the shorter `"slm"`. Run `backend/migrate_standardize_device_types.py` to update existing databases.
|
||||
|
||||
### Emitter Table (Device Check-ins)
|
||||
|
||||
| Field | Type | Description |
|
||||
@@ -463,6 +496,12 @@ docker compose down -v
|
||||
|
||||
## Release Highlights
|
||||
|
||||
### v0.4.3 — 2026-01-14
|
||||
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
|
||||
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
|
||||
- **Project view polish**: FTP browser supports folder downloads, the timer display was reimplemented, and the project/device templates gained edit modals for projects and locations to streamline navigation.
|
||||
- **SLM sync & accuracy**: Configuration edits now propagate to SLMM (which caches configs for faster responses) and the live view uses the correct DRD fields so telemetry aligns with the control center.
|
||||
|
||||
### v0.4.0 — 2025-12-16
|
||||
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
||||
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
||||
@@ -532,9 +571,15 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
**Current: 0.4.0** — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||
**Current: 0.4.3** — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
||||
|
||||
Previous: 0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
||||
Previous: 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.0 — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||
|
||||
0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
||||
|
||||
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
||||
|
||||
|
||||
BIN
assets/terra-view-icon_large.png
Normal file
BIN
assets/terra-view-icon_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
108
backend/init_projects_db.py
Normal file
108
backend/init_projects_db.py
Normal file
@@ -0,0 +1,108 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database initialization script for Projects system.
|
||||
|
||||
This script creates the new project management tables and populates
|
||||
the project_types table with default templates.
|
||||
|
||||
Usage:
|
||||
python -m backend.init_projects_db
|
||||
"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import engine, SessionLocal
|
||||
from backend.models import (
|
||||
Base,
|
||||
ProjectType,
|
||||
Project,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
ScheduledAction,
|
||||
RecordingSession,
|
||||
DataFile,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
def init_project_types(db: Session):
|
||||
"""Initialize default project types."""
|
||||
project_types = [
|
||||
{
|
||||
"id": "sound_monitoring",
|
||||
"name": "Sound Monitoring",
|
||||
"description": "Noise monitoring projects with sound level meters and NRLs (Noise Recording Locations)",
|
||||
"icon": "volume-2", # Lucide icon name
|
||||
"supports_sound": True,
|
||||
"supports_vibration": False,
|
||||
},
|
||||
{
|
||||
"id": "vibration_monitoring",
|
||||
"name": "Vibration Monitoring",
|
||||
"description": "Seismic/vibration monitoring projects with seismographs and monitoring points",
|
||||
"icon": "activity", # Lucide icon name
|
||||
"supports_sound": False,
|
||||
"supports_vibration": True,
|
||||
},
|
||||
{
|
||||
"id": "combined",
|
||||
"name": "Combined Monitoring",
|
||||
"description": "Full-spectrum monitoring with both sound and vibration capabilities",
|
||||
"icon": "layers", # Lucide icon name
|
||||
"supports_sound": True,
|
||||
"supports_vibration": True,
|
||||
},
|
||||
]
|
||||
|
||||
for pt_data in project_types:
|
||||
existing = db.query(ProjectType).filter_by(id=pt_data["id"]).first()
|
||||
if not existing:
|
||||
pt = ProjectType(**pt_data)
|
||||
db.add(pt)
|
||||
print(f"✓ Created project type: {pt_data['name']}")
|
||||
else:
|
||||
print(f" Project type already exists: {pt_data['name']}")
|
||||
|
||||
db.commit()
|
||||
|
||||
|
||||
def create_tables():
|
||||
"""Create all tables defined in models."""
|
||||
print("Creating project management tables...")
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("✓ Tables created successfully")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("Terra-View Projects System - Database Initialization")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
# Create tables
|
||||
create_tables()
|
||||
print()
|
||||
|
||||
# Initialize project types
|
||||
db = SessionLocal()
|
||||
try:
|
||||
print("Initializing project types...")
|
||||
init_project_types(db)
|
||||
print()
|
||||
print("=" * 60)
|
||||
print("✓ Database initialization complete!")
|
||||
print("=" * 60)
|
||||
print()
|
||||
print("Next steps:")
|
||||
print(" 1. Restart Terra-View to load new routes")
|
||||
print(" 2. Navigate to /projects to create your first project")
|
||||
print(" 3. Check documentation for API endpoints")
|
||||
except Exception as e:
|
||||
print(f"✗ Error during initialization: {e}")
|
||||
db.rollback()
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
327
backend/main.py
327
backend/main.py
@@ -1,15 +1,24 @@
|
||||
import os
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
import logging
|
||||
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict
|
||||
from typing import List, Dict, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
||||
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.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
|
||||
@@ -20,13 +29,23 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.4.0"
|
||||
VERSION = "0.4.3"
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
version=VERSION
|
||||
)
|
||||
|
||||
# Add validation error handler to log details
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||
logger.error(f"Body: {await request.body()}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": exc.errors()}
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@@ -65,13 +84,60 @@ app.include_router(roster.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(photos.router)
|
||||
app.include_router(roster_edit.router)
|
||||
app.include_router(roster_rename.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboard_tabs.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(slmm.router)
|
||||
app.include_router(slm_ui.router)
|
||||
app.include_router(slm_dashboard.router)
|
||||
app.include_router(seismo_dashboard.router)
|
||||
|
||||
from backend.routers import settings
|
||||
app.include_router(settings.router)
|
||||
|
||||
# Projects system routers
|
||||
app.include_router(projects.router)
|
||||
app.include_router(project_locations.router)
|
||||
app.include_router(scheduler.router)
|
||||
|
||||
# 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.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
"""Initialize services on app startup"""
|
||||
logger.info("Starting scheduler service...")
|
||||
await start_scheduler()
|
||||
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")
|
||||
def shutdown_event():
|
||||
"""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...")
|
||||
stop_scheduler()
|
||||
logger.info("Scheduler service stopped")
|
||||
|
||||
|
||||
# Legacy routes from the original backend
|
||||
@@ -107,6 +173,138 @@ async def settings_page(request: Request):
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/sound-level-meters", response_class=HTMLResponse)
|
||||
async def sound_level_meters_page(request: Request):
|
||||
"""Sound Level Meters management dashboard"""
|
||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/slm/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_legacy_dashboard(
|
||||
request: Request,
|
||||
unit_id: str,
|
||||
from_project: Optional[str] = None,
|
||||
from_nrl: Optional[str] = None,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""Legacy SLM control center dashboard for a specific unit"""
|
||||
# Get project details if from_project is provided
|
||||
project = None
|
||||
if from_project:
|
||||
from backend.models import Project
|
||||
project = db.query(Project).filter_by(id=from_project).first()
|
||||
|
||||
# Get NRL location details if from_nrl is provided
|
||||
nrl_location = None
|
||||
if from_nrl:
|
||||
from backend.models import NRLLocation
|
||||
nrl_location = db.query(NRLLocation).filter_by(id=from_nrl).first()
|
||||
|
||||
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id,
|
||||
"from_project": from_project,
|
||||
"from_nrl": from_nrl,
|
||||
"project": project,
|
||||
"nrl_location": nrl_location
|
||||
})
|
||||
|
||||
|
||||
@app.get("/seismographs", response_class=HTMLResponse)
|
||||
async def seismographs_page(request: Request):
|
||||
"""Seismographs management dashboard"""
|
||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/projects", response_class=HTMLResponse)
|
||||
async def projects_page(request: Request):
|
||||
"""Projects management and overview"""
|
||||
return templates.TemplateResponse("projects/overview.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}", response_class=HTMLResponse)
|
||||
async def project_detail_page(request: Request, project_id: str):
|
||||
"""Project detail dashboard"""
|
||||
return templates.TemplateResponse("projects/detail.html", {
|
||||
"request": request,
|
||||
"project_id": project_id
|
||||
})
|
||||
|
||||
|
||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
||||
async def nrl_detail_page(
|
||||
request: Request,
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
||||
from sqlalchemy import and_
|
||||
|
||||
# Get project
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
return templates.TemplateResponse("404.html", {
|
||||
"request": request,
|
||||
"message": "Project not found"
|
||||
}, status_code=404)
|
||||
|
||||
# Get location
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
return templates.TemplateResponse("404.html", {
|
||||
"request": request,
|
||||
"message": "Location not found"
|
||||
}, status_code=404)
|
||||
|
||||
# Get active assignment
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active"
|
||||
)
|
||||
).first()
|
||||
|
||||
assigned_unit = None
|
||||
if assignment:
|
||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
|
||||
# Get session count
|
||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
||||
|
||||
# Get file count (DataFile links to session, not directly to location)
|
||||
file_count = db.query(DataFile).join(
|
||||
RecordingSession,
|
||||
DataFile.session_id == RecordingSession.id
|
||||
).filter(RecordingSession.location_id == location_id).count()
|
||||
|
||||
# Check for active session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == location_id,
|
||||
RecordingSession.status == "recording"
|
||||
)
|
||||
).first()
|
||||
|
||||
return templates.TemplateResponse("nrl_detail.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"location_id": location_id,
|
||||
"project": project,
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
"assigned_unit": assigned_unit,
|
||||
"session_count": session_count,
|
||||
"file_count": file_count,
|
||||
"active_session": active_session,
|
||||
})
|
||||
|
||||
|
||||
# ===== PWA ROUTES =====
|
||||
|
||||
@app.get("/sw.js")
|
||||
@@ -356,6 +554,127 @@ async def unknown_emitters_partial(request: Request):
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/devices-all", response_class=HTMLResponse)
|
||||
async def devices_all_partial(request: Request):
|
||||
"""Unified partial template for ALL devices with comprehensive filtering support"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
|
||||
# Add deployed/active units
|
||||
for unit_id, unit_data in snapshot["active"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "Unknown"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add benched units
|
||||
for unit_id, unit_data in snapshot["benched"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "N/A"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add retired units
|
||||
for unit_id, unit_data in snapshot["retired"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Retired",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": True,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add ignored units
|
||||
for unit_id, unit_data in snapshot.get("ignored", {}).items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Ignored",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"ignored": True,
|
||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||
"device_type": unit_data.get("device_type", "unknown"),
|
||||
"address": "",
|
||||
"coordinates": "",
|
||||
"project_id": "",
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
})
|
||||
|
||||
# Sort by status category, then by ID
|
||||
def sort_key(unit):
|
||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
||||
if unit["deployed"]:
|
||||
return (0, unit["id"])
|
||||
elif not unit["retired"] and not unit["ignored"]:
|
||||
return (1, unit["id"])
|
||||
elif unit["retired"]:
|
||||
return (2, unit["id"])
|
||||
else:
|
||||
return (3, unit["id"])
|
||||
|
||||
units_list.sort(key=sort_key)
|
||||
|
||||
return templates.TemplateResponse("partials/devices_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
|
||||
67
backend/migrate_add_auto_increment_index.py
Normal file
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)
|
||||
88
backend/migrate_add_report_templates.py
Normal file
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()
|
||||
78
backend/migrate_add_slm_fields.py
Normal file
78
backend/migrate_add_slm_fields.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Add sound level meter fields to roster table.
|
||||
|
||||
Adds columns for sound_level_meter device type support.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def migrate():
|
||||
"""Add SLM fields to roster table if they don't exist."""
|
||||
|
||||
# Try multiple possible database locations
|
||||
possible_paths = [
|
||||
Path("data/seismo_fleet.db"),
|
||||
Path("data/sfm.db"),
|
||||
Path("data/seismo.db"),
|
||||
]
|
||||
|
||||
db_path = None
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
db_path = path
|
||||
break
|
||||
|
||||
if db_path is None:
|
||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||
print("Creating database with models.py will include new fields automatically.")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(roster)")
|
||||
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
new_columns = {
|
||||
"slm_host": "TEXT",
|
||||
"slm_tcp_port": "INTEGER",
|
||||
"slm_model": "TEXT",
|
||||
"slm_serial_number": "TEXT",
|
||||
"slm_frequency_weighting": "TEXT",
|
||||
"slm_time_weighting": "TEXT",
|
||||
"slm_measurement_range": "TEXT",
|
||||
"slm_last_check": "DATETIME",
|
||||
}
|
||||
|
||||
migrations_applied = []
|
||||
|
||||
for column_name, column_type in new_columns.items():
|
||||
if column_name not in existing_columns:
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}")
|
||||
migrations_applied.append(column_name)
|
||||
print(f"✓ Added column: {column_name} ({column_type})")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to add column {column_name}: {e}")
|
||||
else:
|
||||
print(f"○ Column already exists: {column_name}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if migrations_applied:
|
||||
print(f"\n✓ Migration complete! Added {len(migrations_applied)} new columns.")
|
||||
else:
|
||||
print("\n○ No migration needed - all columns already exist.")
|
||||
|
||||
print("\nSound level meter fields are now available in the roster table.")
|
||||
print("Note: Use device_type='slm' for Sound Level Meters. Legacy 'sound_level_meter' has been deprecated.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
106
backend/migrate_standardize_device_types.py
Normal file
106
backend/migrate_standardize_device_types.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Database Migration: Standardize device_type values
|
||||
|
||||
This migration ensures all device_type values follow the official schema:
|
||||
- "seismograph" - Seismic monitoring devices
|
||||
- "modem" - Field modems and network equipment
|
||||
- "slm" - Sound level meters (NL-43/NL-53)
|
||||
|
||||
Changes:
|
||||
- Converts "sound_level_meter" → "slm"
|
||||
- Safe to run multiple times (idempotent)
|
||||
- No data loss
|
||||
|
||||
Usage:
|
||||
python backend/migrate_standardize_device_types.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path so we can import backend modules
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Database configuration
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
|
||||
def migrate():
|
||||
"""Standardize device_type values in the database"""
|
||||
db = SessionLocal()
|
||||
|
||||
try:
|
||||
print("=" * 70)
|
||||
print("Database Migration: Standardize device_type values")
|
||||
print("=" * 70)
|
||||
print()
|
||||
|
||||
# Check for existing "sound_level_meter" values
|
||||
result = db.execute(
|
||||
text("SELECT COUNT(*) as count FROM roster WHERE device_type = 'sound_level_meter'")
|
||||
).fetchone()
|
||||
|
||||
count_to_migrate = result[0] if result else 0
|
||||
|
||||
if count_to_migrate == 0:
|
||||
print("✓ No records need migration - all device_type values are already standardized")
|
||||
print()
|
||||
print("Current device_type distribution:")
|
||||
|
||||
# Show distribution
|
||||
distribution = db.execute(
|
||||
text("SELECT device_type, COUNT(*) as count FROM roster GROUP BY device_type ORDER BY count DESC")
|
||||
).fetchall()
|
||||
|
||||
for row in distribution:
|
||||
device_type, count = row
|
||||
print(f" - {device_type}: {count} units")
|
||||
|
||||
print()
|
||||
print("Migration not needed.")
|
||||
return
|
||||
|
||||
print(f"Found {count_to_migrate} record(s) with device_type='sound_level_meter'")
|
||||
print()
|
||||
print("Converting 'sound_level_meter' → 'slm'...")
|
||||
|
||||
# Perform the migration
|
||||
db.execute(
|
||||
text("UPDATE roster SET device_type = 'slm' WHERE device_type = 'sound_level_meter'")
|
||||
)
|
||||
db.commit()
|
||||
|
||||
print(f"✓ Successfully migrated {count_to_migrate} record(s)")
|
||||
print()
|
||||
|
||||
# Show final distribution
|
||||
print("Updated device_type distribution:")
|
||||
distribution = db.execute(
|
||||
text("SELECT device_type, COUNT(*) as count FROM roster GROUP BY device_type ORDER BY count DESC")
|
||||
).fetchall()
|
||||
|
||||
for row in distribution:
|
||||
device_type, count = row
|
||||
print(f" - {device_type}: {count} units")
|
||||
|
||||
print()
|
||||
print("=" * 70)
|
||||
print("Migration completed successfully!")
|
||||
print("=" * 70)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"\n❌ Error during migration: {e}")
|
||||
print("\nRolling back changes...")
|
||||
raise
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
@@ -19,14 +19,17 @@ class RosterUnit(Base):
|
||||
Roster table: represents our *intended assignment* of a unit.
|
||||
This is editable from the GUI.
|
||||
|
||||
Supports multiple device types (seismograph, modem) with type-specific fields.
|
||||
Supports multiple device types with type-specific fields:
|
||||
- "seismograph" - Seismic monitoring devices (default)
|
||||
- "modem" - Field modems and network equipment
|
||||
- "slm" - Sound level meters (NL-43/NL-53)
|
||||
"""
|
||||
__tablename__ = "roster"
|
||||
|
||||
# Core fields (all device types)
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, default="series3") # Backward compatibility
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem"
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
note = Column(String, nullable=True)
|
||||
@@ -36,16 +39,29 @@ class RosterUnit(Base):
|
||||
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
# Seismograph-specific fields (nullable for modems)
|
||||
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||
last_calibrated = Column(Date, nullable=True)
|
||||
next_calibration_due = Column(Date, nullable=True)
|
||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
||||
|
||||
# Modem-specific fields (nullable for seismographs)
|
||||
# Modem assignment (shared by seismographs and SLMs)
|
||||
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
||||
|
||||
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
|
||||
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
||||
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||
|
||||
|
||||
class IgnoredUnit(Base):
|
||||
"""
|
||||
@@ -95,3 +111,282 @@ class UserPreferences(Base):
|
||||
status_ok_threshold_hours = Column(Integer, default=12)
|
||||
status_pending_threshold_hours = Column(Integer, default=24)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Project Management System
|
||||
# ============================================================================
|
||||
|
||||
class ProjectType(Base):
|
||||
"""
|
||||
Project type templates: defines available project types and their capabilities.
|
||||
Pre-populated with: sound_monitoring, vibration_monitoring, combined.
|
||||
"""
|
||||
__tablename__ = "project_types"
|
||||
|
||||
id = Column(String, primary_key=True) # sound_monitoring, vibration_monitoring, combined
|
||||
name = Column(String, nullable=False, unique=True) # "Sound Monitoring", "Vibration Monitoring"
|
||||
description = Column(Text, nullable=True)
|
||||
icon = Column(String, nullable=True) # Icon identifier for UI
|
||||
supports_sound = Column(Boolean, default=False) # Enables SLM features
|
||||
supports_vibration = Column(Boolean, default=False) # Enables seismograph features
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""
|
||||
Projects: top-level organization for monitoring work.
|
||||
Type-aware to enable/disable features based on project_type_id.
|
||||
"""
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
name = Column(String, nullable=False, unique=True)
|
||||
description = Column(Text, nullable=True)
|
||||
project_type_id = Column(String, nullable=False) # FK to ProjectType.id
|
||||
status = Column(String, default="active") # active, completed, archived
|
||||
|
||||
# Project metadata
|
||||
client_name = Column(String, nullable=True)
|
||||
site_address = Column(String, nullable=True)
|
||||
site_coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
start_date = Column(Date, nullable=True)
|
||||
end_date = Column(Date, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class MonitoringLocation(Base):
|
||||
"""
|
||||
Monitoring locations: generic location for monitoring activities.
|
||||
Can be NRL (Noise Recording Location) for sound projects,
|
||||
or monitoring point for vibration projects.
|
||||
"""
|
||||
__tablename__ = "monitoring_locations"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||
location_type = Column(String, nullable=False) # "sound" | "vibration"
|
||||
|
||||
name = Column(String, nullable=False) # NRL-001, VP-North, etc.
|
||||
description = Column(Text, nullable=True)
|
||||
coordinates = Column(String, nullable=True) # "lat,lon"
|
||||
address = Column(String, nullable=True)
|
||||
|
||||
# Type-specific metadata stored as JSON
|
||||
# For sound: {"ambient_conditions": "urban", "expected_sources": ["traffic"]}
|
||||
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
|
||||
location_metadata = Column(Text, nullable=True)
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class UnitAssignment(Base):
|
||||
"""
|
||||
Unit assignments: links devices (SLMs or seismographs) to monitoring locations.
|
||||
Supports temporary assignments with assigned_until.
|
||||
"""
|
||||
__tablename__ = "unit_assignments"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
location_id = Column(String, nullable=False, index=True) # FK to MonitoringLocation.id
|
||||
|
||||
assigned_at = Column(DateTime, default=datetime.utcnow)
|
||||
assigned_until = Column(DateTime, nullable=True) # Null = indefinite
|
||||
status = Column(String, default="active") # active, completed, cancelled
|
||||
notes = Column(Text, nullable=True)
|
||||
|
||||
# Denormalized for efficient queries
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
project_id = Column(String, nullable=False, index=True) # FK to Project.id
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class ScheduledAction(Base):
|
||||
"""
|
||||
Scheduled actions: automation for recording start/stop/download.
|
||||
Terra-View executes these by calling SLMM or SFM endpoints.
|
||||
"""
|
||||
__tablename__ = "scheduled_actions"
|
||||
|
||||
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 (nullable if location-based)
|
||||
|
||||
action_type = Column(String, nullable=False) # start, stop, download, calibrate
|
||||
device_type = Column(String, nullable=False) # "slm" | "seismograph"
|
||||
|
||||
scheduled_time = Column(DateTime, nullable=False, index=True)
|
||||
executed_at = Column(DateTime, nullable=True)
|
||||
execution_status = Column(String, default="pending") # pending, completed, failed, cancelled
|
||||
|
||||
# Response from device module (SLMM or SFM)
|
||||
module_response = Column(Text, nullable=True) # JSON
|
||||
error_message = Column(Text, nullable=True)
|
||||
|
||||
notes = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class RecordingSession(Base):
|
||||
"""
|
||||
Recording sessions: tracks actual monitoring sessions.
|
||||
Created when recording starts, updated when it stops.
|
||||
"""
|
||||
__tablename__ = "recording_sessions"
|
||||
|
||||
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=False, index=True) # FK to RosterUnit.id
|
||||
|
||||
session_type = Column(String, nullable=False) # sound | vibration
|
||||
started_at = Column(DateTime, nullable=False)
|
||||
stopped_at = Column(DateTime, nullable=True)
|
||||
duration_seconds = Column(Integer, nullable=True)
|
||||
status = Column(String, default="recording") # recording, completed, failed
|
||||
|
||||
# Snapshot of device configuration at recording time
|
||||
session_metadata = Column(Text, nullable=True) # JSON
|
||||
|
||||
created_at = Column(DateTime, default=datetime.utcnow)
|
||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
|
||||
class DataFile(Base):
|
||||
"""
|
||||
Data files: references to recorded data files.
|
||||
Terra-View tracks file metadata; actual files stored in data/Projects/ directory.
|
||||
"""
|
||||
__tablename__ = "data_files"
|
||||
|
||||
id = Column(String, primary_key=True, index=True) # UUID
|
||||
session_id = Column(String, nullable=False, index=True) # FK to RecordingSession.id
|
||||
|
||||
file_path = Column(String, nullable=False) # Relative to data/Projects/
|
||||
file_type = Column(String, nullable=False) # wav, csv, mseed, json
|
||||
file_size_bytes = Column(Integer, nullable=True)
|
||||
downloaded_at = Column(DateTime, nullable=True)
|
||||
checksum = Column(String, nullable=True) # SHA256 or MD5
|
||||
|
||||
# Additional file metadata
|
||||
file_metadata = Column(Text, nullable=True) # JSON
|
||||
|
||||
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
|
||||
"""
|
||||
__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"
|
||||
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
|
||||
|
||||
327
backend/routers/alerts.py
Normal file
327
backend/routers/alerts.py
Normal file
@@ -0,0 +1,327 @@
|
||||
"""
|
||||
Alerts Router
|
||||
|
||||
API endpoints for managing in-app alerts.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/alerts", tags=["alerts"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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,
|
||||
}
|
||||
522
backend/routers/project_locations.py
Normal file
522
backend/routers/project_locations.py
Normal file
@@ -0,0 +1,522 @@
|
||||
"""
|
||||
Project Locations Router
|
||||
|
||||
Handles monitoring locations (NRLs for sound, monitoring points for vibration)
|
||||
and unit assignments within projects.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
Project,
|
||||
ProjectType,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
RosterUnit,
|
||||
RecordingSession,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}", tags=["project-locations"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Monitoring Locations CRUD
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/locations", response_class=HTMLResponse)
|
||||
async def get_project_locations(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
location_type: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Get all monitoring locations for a project.
|
||||
Returns HTML partial with location list.
|
||||
"""
|
||||
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)
|
||||
|
||||
# Filter by type if provided
|
||||
if location_type:
|
||||
query = query.filter_by(location_type=location_type)
|
||||
|
||||
locations = query.order_by(MonitoringLocation.name).all()
|
||||
|
||||
# Enrich with assignment info
|
||||
locations_data = []
|
||||
for location in locations:
|
||||
# Get active assignment
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location.id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).first()
|
||||
|
||||
assigned_unit = None
|
||||
if assignment:
|
||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
|
||||
# Count recording sessions
|
||||
session_count = db.query(RecordingSession).filter_by(
|
||||
location_id=location.id
|
||||
).count()
|
||||
|
||||
locations_data.append({
|
||||
"location": location,
|
||||
"assignment": assignment,
|
||||
"assigned_unit": assigned_unit,
|
||||
"session_count": session_count,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/location_list.html", {
|
||||
"request": request,
|
||||
"project": project,
|
||||
"locations": locations_data,
|
||||
})
|
||||
|
||||
|
||||
@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")
|
||||
async def create_location(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new monitoring location within a project.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
location = MonitoringLocation(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_type=form_data.get("location_type"),
|
||||
name=form_data.get("name"),
|
||||
description=form_data.get("description"),
|
||||
coordinates=form_data.get("coordinates"),
|
||||
address=form_data.get("address"),
|
||||
location_metadata=form_data.get("location_metadata"), # JSON string
|
||||
)
|
||||
|
||||
db.add(location)
|
||||
db.commit()
|
||||
db.refresh(location)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"location_id": location.id,
|
||||
"message": f"Location '{location.name}' created successfully",
|
||||
})
|
||||
|
||||
|
||||
@router.put("/locations/{location_id}")
|
||||
async def update_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update a monitoring location.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
data = await request.json()
|
||||
|
||||
# Update fields if provided
|
||||
if "name" in data:
|
||||
location.name = data["name"]
|
||||
if "description" in data:
|
||||
location.description = data["description"]
|
||||
if "location_type" in data:
|
||||
location.location_type = data["location_type"]
|
||||
if "coordinates" in data:
|
||||
location.coordinates = data["coordinates"]
|
||||
if "address" in data:
|
||||
location.address = data["address"]
|
||||
if "location_metadata" in data:
|
||||
location.location_metadata = data["location_metadata"]
|
||||
|
||||
location.updated_at = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Location updated successfully"}
|
||||
|
||||
|
||||
@router.delete("/locations/{location_id}")
|
||||
async def delete_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete a monitoring location.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# Check if location has active assignments
|
||||
active_assignments = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).count()
|
||||
|
||||
if active_assignments > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete location with active unit assignments. Unassign units first.",
|
||||
)
|
||||
|
||||
db.delete(location)
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Location deleted successfully"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Unit Assignments
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/assignments", response_class=HTMLResponse)
|
||||
async def get_project_assignments(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
status: Optional[str] = Query("active"),
|
||||
):
|
||||
"""
|
||||
Get all unit assignments for a project.
|
||||
Returns HTML partial with assignment list.
|
||||
"""
|
||||
query = db.query(UnitAssignment).filter_by(project_id=project_id)
|
||||
|
||||
if status:
|
||||
query = query.filter_by(status=status)
|
||||
|
||||
assignments = query.order_by(UnitAssignment.assigned_at.desc()).all()
|
||||
|
||||
# Enrich with unit and location details
|
||||
assignments_data = []
|
||||
for assignment in assignments:
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
location = db.query(MonitoringLocation).filter_by(id=assignment.location_id).first()
|
||||
|
||||
assignments_data.append({
|
||||
"assignment": assignment,
|
||||
"unit": unit,
|
||||
"location": location,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/assignment_list.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"assignments": assignments_data,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/locations/{location_id}/assign")
|
||||
async def assign_unit_to_location(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Assign a unit to a monitoring location.
|
||||
"""
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
form_data = await request.form()
|
||||
unit_id = form_data.get("unit_id")
|
||||
|
||||
# Verify unit exists and matches location type
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
# Check device type matches location type
|
||||
expected_device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||
if unit.device_type != expected_device_type:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unit type '{unit.device_type}' does not match location type '{location.location_type}'",
|
||||
)
|
||||
|
||||
# Check if location already has an active assignment
|
||||
existing_assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == location_id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).first()
|
||||
|
||||
if existing_assignment:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Location already has an active unit assignment ({existing_assignment.unit_id}). Unassign first.",
|
||||
)
|
||||
|
||||
# Create new assignment
|
||||
assigned_until_str = form_data.get("assigned_until")
|
||||
assigned_until = datetime.fromisoformat(assigned_until_str) if assigned_until_str else None
|
||||
|
||||
assignment = UnitAssignment(
|
||||
id=str(uuid.uuid4()),
|
||||
unit_id=unit_id,
|
||||
location_id=location_id,
|
||||
project_id=project_id,
|
||||
device_type=unit.device_type,
|
||||
assigned_until=assigned_until,
|
||||
status="active",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
|
||||
db.add(assignment)
|
||||
db.commit()
|
||||
db.refresh(assignment)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"assignment_id": assignment.id,
|
||||
"message": f"Unit '{unit_id}' assigned to '{location.name}'",
|
||||
})
|
||||
|
||||
|
||||
@router.post("/assignments/{assignment_id}/unassign")
|
||||
async def unassign_unit(
|
||||
project_id: str,
|
||||
assignment_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Unassign a unit from a location.
|
||||
"""
|
||||
assignment = db.query(UnitAssignment).filter_by(
|
||||
id=assignment_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise HTTPException(status_code=404, detail="Assignment not found")
|
||||
|
||||
# Check if there are active recording sessions
|
||||
active_sessions = db.query(RecordingSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == assignment.location_id,
|
||||
RecordingSession.unit_id == assignment.unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
)
|
||||
).count()
|
||||
|
||||
if active_sessions > 0:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot unassign unit with active recording sessions. Stop recording first.",
|
||||
)
|
||||
|
||||
assignment.status = "completed"
|
||||
assignment.assigned_until = datetime.utcnow()
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Unit unassigned successfully"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Available Units for Assignment
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/available-units", response_class=JSONResponse)
|
||||
async def get_available_units(
|
||||
project_id: str,
|
||||
location_type: str = Query(...),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get list of available units for assignment to a location.
|
||||
Filters by device type matching the location type.
|
||||
"""
|
||||
# Determine required device type
|
||||
required_device_type = "slm" if location_type == "sound" else "seismograph"
|
||||
|
||||
# Get all units of the required type that are deployed and not retired
|
||||
all_units = db.query(RosterUnit).filter(
|
||||
and_(
|
||||
RosterUnit.device_type == required_device_type,
|
||||
RosterUnit.deployed == True,
|
||||
RosterUnit.retired == False,
|
||||
)
|
||||
).all()
|
||||
|
||||
# Filter out units that already have active assignments
|
||||
assigned_unit_ids = db.query(UnitAssignment.unit_id).filter(
|
||||
UnitAssignment.status == "active"
|
||||
).distinct().all()
|
||||
assigned_unit_ids = [uid[0] for uid in assigned_unit_ids]
|
||||
|
||||
available_units = [
|
||||
{
|
||||
"id": unit.id,
|
||||
"device_type": unit.device_type,
|
||||
"location": unit.address or unit.location,
|
||||
"model": unit.slm_model if unit.device_type == "slm" else unit.unit_type,
|
||||
}
|
||||
for unit in all_units
|
||||
if unit.id not in assigned_unit_ids
|
||||
]
|
||||
|
||||
return available_units
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# NRL-specific endpoints for detail page
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/nrl/{location_id}/sessions", response_class=HTMLResponse)
|
||||
async def get_nrl_sessions(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get recording sessions for a specific NRL.
|
||||
Returns HTML partial with session list.
|
||||
"""
|
||||
from backend.models import RecordingSession, RosterUnit
|
||||
|
||||
sessions = db.query(RecordingSession).filter_by(
|
||||
location_id=location_id
|
||||
).order_by(RecordingSession.started_at.desc()).all()
|
||||
|
||||
# Enrich with unit details
|
||||
sessions_data = []
|
||||
for session in sessions:
|
||||
unit = None
|
||||
if session.unit_id:
|
||||
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
|
||||
|
||||
sessions_data.append({
|
||||
"session": session,
|
||||
"unit": unit,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/session_list.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"location_id": location_id,
|
||||
"sessions": sessions_data,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/nrl/{location_id}/files", response_class=HTMLResponse)
|
||||
async def get_nrl_files(
|
||||
project_id: str,
|
||||
location_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Get data files for a specific NRL.
|
||||
Returns HTML partial with file list.
|
||||
"""
|
||||
from backend.models import DataFile, RecordingSession
|
||||
|
||||
# Join DataFile with RecordingSession to filter by location_id
|
||||
files = db.query(DataFile).join(
|
||||
RecordingSession,
|
||||
DataFile.session_id == RecordingSession.id
|
||||
).filter(
|
||||
RecordingSession.location_id == location_id
|
||||
).order_by(DataFile.created_at.desc()).all()
|
||||
|
||||
# Enrich with session details
|
||||
files_data = []
|
||||
for file in files:
|
||||
session = None
|
||||
if file.session_id:
|
||||
session = db.query(RecordingSession).filter_by(id=file.session_id).first()
|
||||
|
||||
files_data.append({
|
||||
"file": file,
|
||||
"session": session,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/file_list.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"location_id": location_id,
|
||||
"files": files_data,
|
||||
})
|
||||
2408
backend/routers/projects.py
Normal file
2408
backend/routers/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
458
backend/routers/recurring_schedules.py
Normal file
458
backend/routers/recurring_schedules.py
Normal file
@@ -0,0 +1,458 @@
|
||||
"""
|
||||
Recurring Schedules Router
|
||||
|
||||
API endpoints for managing recurring monitoring schedules.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
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
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}/recurring-schedules", tags=["recurring-schedules"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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"),
|
||||
)
|
||||
created_schedules.append({
|
||||
"schedule_id": schedule.id,
|
||||
"location_id": location.id,
|
||||
"location_name": location.name,
|
||||
})
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"schedules": created_schedules,
|
||||
"count": len(created_schedules),
|
||||
"message": f"Created {len(created_schedules)} recurring schedule(s)",
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# 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
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,13 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import httpx
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# SLMM backend URL for syncing device configs to cache
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||
@@ -37,13 +45,98 @@ def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||
return unit
|
||||
|
||||
|
||||
async def sync_slm_to_slmm_cache(
|
||||
unit_id: str,
|
||||
host: str = None,
|
||||
tcp_port: int = None,
|
||||
ftp_port: int = None,
|
||||
ftp_username: str = None,
|
||||
ftp_password: str = None,
|
||||
deployed_with_modem_id: str = None,
|
||||
db: Session = None
|
||||
) -> dict:
|
||||
"""
|
||||
Sync SLM device configuration to SLMM backend cache.
|
||||
|
||||
Terra-View is the source of truth for device configs. This function updates
|
||||
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
||||
info by unit_id without Terra-View passing host:port with every request.
|
||||
|
||||
Args:
|
||||
unit_id: Unique identifier for the SLM device
|
||||
host: Direct IP address/hostname OR will be resolved from modem
|
||||
tcp_port: TCP control port (default: 2255)
|
||||
ftp_port: FTP port (default: 21)
|
||||
ftp_username: FTP username (optional)
|
||||
ftp_password: FTP password (optional)
|
||||
deployed_with_modem_id: If set, resolve modem IP as host
|
||||
db: Database session for modem lookup
|
||||
|
||||
Returns:
|
||||
dict: {"success": bool, "message": str}
|
||||
"""
|
||||
# Resolve host from modem if assigned
|
||||
if deployed_with_modem_id and db:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=deployed_with_modem_id,
|
||||
device_type="modem"
|
||||
).first()
|
||||
if modem and modem.ip_address:
|
||||
host = modem.ip_address
|
||||
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
||||
|
||||
# Validate required fields
|
||||
if not host:
|
||||
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
||||
return {"success": False, "message": "No host IP address available"}
|
||||
|
||||
# Set defaults
|
||||
tcp_port = tcp_port or 2255
|
||||
ftp_port = ftp_port or 21
|
||||
|
||||
# Build SLMM cache payload
|
||||
config_payload = {
|
||||
"host": host,
|
||||
"tcp_port": tcp_port,
|
||||
"tcp_enabled": True,
|
||||
"ftp_enabled": bool(ftp_username and ftp_password),
|
||||
"web_enabled": False
|
||||
}
|
||||
|
||||
if ftp_username and ftp_password:
|
||||
config_payload["ftp_username"] = ftp_username
|
||||
config_payload["ftp_password"] = ftp_password
|
||||
|
||||
# Call SLMM cache update API
|
||||
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.put(slmm_url, json=config_payload)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
||||
return {"success": True, "message": "Device config cached in SLMM"}
|
||||
else:
|
||||
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
||||
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
||||
return {"success": False, "message": "SLMM service unavailable"}
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
||||
return {"success": False, "message": str(e)}
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
def add_roster_unit(
|
||||
async def add_roster_unit(
|
||||
id: str = Form(...),
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
deployed: bool = Form(False),
|
||||
retired: bool = Form(False),
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
@@ -57,8 +150,27 @@ def add_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
slm_ftp_port: str = Form(None),
|
||||
slm_model: str = Form(None),
|
||||
slm_serial_number: str = Form(None),
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
||||
|
||||
# Convert boolean strings to actual booleans
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||
|
||||
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||
|
||||
@@ -81,8 +193,8 @@ def add_roster_unit(
|
||||
id=id,
|
||||
device_type=device_type,
|
||||
unit_type=unit_type,
|
||||
deployed=deployed,
|
||||
retired=retired,
|
||||
deployed=deployed_bool,
|
||||
retired=retired_bool,
|
||||
note=note,
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
@@ -97,12 +209,56 @@ def add_roster_unit(
|
||||
ip_address=ip_address if ip_address else None,
|
||||
phone_number=phone_number if phone_number else None,
|
||||
hardware_model=hardware_model if hardware_model else None,
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host=slm_host if slm_host else None,
|
||||
slm_tcp_port=slm_tcp_port_int,
|
||||
slm_ftp_port=slm_ftp_port_int,
|
||||
slm_model=slm_model if slm_model else None,
|
||||
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
||||
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_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,
|
||||
)
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
|
||||
# If sound level meter, sync config to SLMM cache
|
||||
if device_type == "slm":
|
||||
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=id,
|
||||
host=slm_host,
|
||||
tcp_port=slm_tcp_port_int,
|
||||
ftp_port=slm_ftp_port_int,
|
||||
deployed_with_modem_id=deployed_with_modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
||||
# Don't fail the operation - device is still added to Terra-View roster
|
||||
# User can manually sync later or SLMM will be synced on next config update
|
||||
|
||||
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||
|
||||
|
||||
@router.get("/modems")
|
||||
def get_modems_list(db: Session = Depends(get_db)):
|
||||
"""Get list of all modem units for dropdown selection"""
|
||||
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": modem.id,
|
||||
"ip_address": modem.ip_address,
|
||||
"phone_number": modem.phone_number,
|
||||
"hardware_model": modem.hardware_model,
|
||||
"deployed": modem.deployed
|
||||
}
|
||||
for modem in modems
|
||||
]
|
||||
|
||||
|
||||
@router.get("/{unit_id}")
|
||||
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get a single roster unit by ID"""
|
||||
@@ -127,6 +283,14 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||
"slm_model": unit.slm_model or "",
|
||||
"slm_serial_number": unit.slm_serial_number or "",
|
||||
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||
}
|
||||
|
||||
|
||||
@@ -135,8 +299,8 @@ def edit_roster_unit(
|
||||
unit_id: str,
|
||||
device_type: str = Form("seismograph"),
|
||||
unit_type: str = Form("series3"),
|
||||
deployed: bool = Form(False),
|
||||
retired: bool = Form(False),
|
||||
deployed: str = Form(None),
|
||||
retired: str = Form(None),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
@@ -150,12 +314,29 @@ def edit_roster_unit(
|
||||
ip_address: str = Form(None),
|
||||
phone_number: str = Form(None),
|
||||
hardware_model: str = Form(None),
|
||||
# Sound Level Meter-specific fields
|
||||
slm_host: str = Form(None),
|
||||
slm_tcp_port: str = Form(None),
|
||||
slm_ftp_port: str = Form(None),
|
||||
slm_model: str = Form(None),
|
||||
slm_serial_number: str = Form(None),
|
||||
slm_frequency_weighting: str = Form(None),
|
||||
slm_time_weighting: str = Form(None),
|
||||
slm_measurement_range: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
# Convert boolean strings to actual booleans
|
||||
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||
|
||||
# Convert port strings to integers
|
||||
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||
|
||||
# Parse date fields if provided
|
||||
last_cal_date = None
|
||||
if last_calibrated:
|
||||
@@ -179,8 +360,8 @@ def edit_roster_unit(
|
||||
# Update all fields
|
||||
unit.device_type = device_type
|
||||
unit.unit_type = unit_type
|
||||
unit.deployed = deployed
|
||||
unit.retired = retired
|
||||
unit.deployed = deployed_bool
|
||||
unit.retired = retired_bool
|
||||
unit.note = note
|
||||
unit.project_id = project_id
|
||||
unit.location = location
|
||||
@@ -198,6 +379,16 @@ def edit_roster_unit(
|
||||
unit.phone_number = phone_number if phone_number else None
|
||||
unit.hardware_model = hardware_model if hardware_model else None
|
||||
|
||||
# Sound Level Meter-specific fields
|
||||
unit.slm_host = slm_host if slm_host else None
|
||||
unit.slm_tcp_port = slm_tcp_port_int
|
||||
unit.slm_ftp_port = slm_ftp_port_int
|
||||
unit.slm_model = slm_model if slm_model else None
|
||||
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
||||
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
||||
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
||||
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
||||
|
||||
# Record history entries for changed fields
|
||||
if old_note != note:
|
||||
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||
@@ -267,16 +458,20 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
|
||||
|
||||
|
||||
@router.delete("/{unit_id}")
|
||||
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
async def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Permanently delete a unit from the database.
|
||||
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
||||
|
||||
For SLM devices, also removes from SLMM to stop background polling.
|
||||
"""
|
||||
deleted = False
|
||||
was_slm = False
|
||||
|
||||
# Try to delete from roster table
|
||||
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if roster_unit:
|
||||
was_slm = roster_unit.device_type == "slm"
|
||||
db.delete(roster_unit)
|
||||
deleted = True
|
||||
|
||||
@@ -297,6 +492,19 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
raise HTTPException(status_code=404, detail="Unit not found")
|
||||
|
||||
db.commit()
|
||||
|
||||
# If it was an SLM, also delete from SLMM
|
||||
if was_slm:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.delete(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config")
|
||||
if response.status_code in [200, 404]:
|
||||
logger.info(f"Deleted SLM {unit_id} from SLMM")
|
||||
else:
|
||||
logger.warning(f"Failed to delete SLM {unit_id} from SLMM: {response.status_code}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting SLM {unit_id} from SLMM: {e}")
|
||||
|
||||
return {"message": "Unit deleted", "id": unit_id}
|
||||
|
||||
|
||||
|
||||
139
backend/routers/roster_rename.py
Normal file
139
backend/routers/roster_rename.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""
|
||||
Roster Unit Rename Router
|
||||
|
||||
Provides endpoint for safely renaming unit IDs across all database tables.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, Emitter, UnitHistory
|
||||
from backend.routers.roster_edit import record_history, sync_slm_to_slmm_cache
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-rename"])
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@router.post("/rename")
|
||||
async def rename_unit(
|
||||
old_id: str = Form(...),
|
||||
new_id: str = Form(...),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Rename a unit ID across all tables.
|
||||
Updates the unit ID in roster, emitters, unit_history, and all foreign key references.
|
||||
|
||||
IMPORTANT: This operation updates the primary key, which affects all relationships.
|
||||
"""
|
||||
# Validate input
|
||||
if not old_id or not new_id:
|
||||
raise HTTPException(status_code=400, detail="Both old_id and new_id are required")
|
||||
|
||||
if old_id == new_id:
|
||||
raise HTTPException(status_code=400, detail="New ID must be different from old ID")
|
||||
|
||||
# Check if old unit exists
|
||||
old_unit = db.query(RosterUnit).filter(RosterUnit.id == old_id).first()
|
||||
if not old_unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit '{old_id}' not found")
|
||||
|
||||
# Check if new ID already exists
|
||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == new_id).first()
|
||||
if existing_unit:
|
||||
raise HTTPException(status_code=409, detail=f"Unit ID '{new_id}' already exists")
|
||||
|
||||
device_type = old_unit.device_type
|
||||
|
||||
try:
|
||||
# Record history for the rename operation (using old_id since that's still valid)
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=old_id,
|
||||
change_type="id_change",
|
||||
field_name="id",
|
||||
old_value=old_id,
|
||||
new_value=new_id,
|
||||
source="manual",
|
||||
notes=f"Unit renamed from '{old_id}' to '{new_id}'"
|
||||
)
|
||||
|
||||
# Update roster table (primary)
|
||||
old_unit.id = new_id
|
||||
old_unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Update emitters table
|
||||
emitter = db.query(Emitter).filter(Emitter.id == old_id).first()
|
||||
if emitter:
|
||||
emitter.id = new_id
|
||||
|
||||
# Update unit_history table (all entries for this unit)
|
||||
db.query(UnitHistory).filter(UnitHistory.unit_id == old_id).update(
|
||||
{"unit_id": new_id},
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
# Update deployed_with_modem_id references (units that reference this as modem)
|
||||
db.query(RosterUnit).filter(RosterUnit.deployed_with_modem_id == old_id).update(
|
||||
{"deployed_with_modem_id": new_id},
|
||||
synchronize_session=False
|
||||
)
|
||||
|
||||
# Update unit_assignments table (if exists)
|
||||
try:
|
||||
from backend.models import UnitAssignment
|
||||
db.query(UnitAssignment).filter(UnitAssignment.unit_id == old_id).update(
|
||||
{"unit_id": new_id},
|
||||
synchronize_session=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update unit_assignments: {e}")
|
||||
|
||||
# Update recording_sessions table (if exists)
|
||||
try:
|
||||
from backend.models import RecordingSession
|
||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
||||
{"unit_id": new_id},
|
||||
synchronize_session=False
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not update recording_sessions: {e}")
|
||||
|
||||
# Commit all changes
|
||||
db.commit()
|
||||
|
||||
# If sound level meter, sync updated config to SLMM cache
|
||||
if device_type == "slm":
|
||||
logger.info(f"Syncing renamed SLM {new_id} (was {old_id}) config to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=new_id,
|
||||
host=old_unit.slm_host,
|
||||
tcp_port=old_unit.slm_tcp_port,
|
||||
ftp_port=old_unit.slm_ftp_port,
|
||||
deployed_with_modem_id=old_unit.deployed_with_modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for renamed unit {new_id}: {result['message']}")
|
||||
|
||||
logger.info(f"Successfully renamed unit '{old_id}' to '{new_id}'")
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"message": f"Successfully renamed unit from '{old_id}' to '{new_id}'",
|
||||
"old_id": old_id,
|
||||
"new_id": new_id,
|
||||
"device_type": device_type
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Error renaming unit '{old_id}' to '{new_id}': {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to rename unit: {str(e)}"
|
||||
)
|
||||
409
backend/routers/scheduler.py
Normal file
409
backend/routers/scheduler.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""
|
||||
Scheduler Router
|
||||
|
||||
Handles scheduled actions for automated recording control.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, HTTPException, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import (
|
||||
Project,
|
||||
ScheduledAction,
|
||||
MonitoringLocation,
|
||||
UnitAssignment,
|
||||
RosterUnit,
|
||||
)
|
||||
from backend.services.scheduler import get_scheduler
|
||||
|
||||
router = APIRouter(prefix="/api/projects/{project_id}/scheduler", tags=["scheduler"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scheduled Actions List
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/actions", response_class=HTMLResponse)
|
||||
async def get_scheduled_actions(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
status: Optional[str] = Query(None),
|
||||
start_date: Optional[str] = Query(None),
|
||||
end_date: Optional[str] = Query(None),
|
||||
):
|
||||
"""
|
||||
Get scheduled actions for a project.
|
||||
Returns HTML partial with agenda/calendar view.
|
||||
"""
|
||||
query = db.query(ScheduledAction).filter_by(project_id=project_id)
|
||||
|
||||
# Filter by status
|
||||
if status:
|
||||
query = query.filter_by(execution_status=status)
|
||||
else:
|
||||
# By default, show pending and upcoming completed/failed
|
||||
query = query.filter(
|
||||
or_(
|
||||
ScheduledAction.execution_status == "pending",
|
||||
and_(
|
||||
ScheduledAction.execution_status.in_(["completed", "failed"]),
|
||||
ScheduledAction.scheduled_time >= datetime.utcnow() - timedelta(days=7),
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Filter by date range
|
||||
if start_date:
|
||||
query = query.filter(ScheduledAction.scheduled_time >= datetime.fromisoformat(start_date))
|
||||
if end_date:
|
||||
query = query.filter(ScheduledAction.scheduled_time <= datetime.fromisoformat(end_date))
|
||||
|
||||
actions = query.order_by(ScheduledAction.scheduled_time).all()
|
||||
|
||||
# Enrich with location and unit details
|
||||
actions_data = []
|
||||
for action in actions:
|
||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||
|
||||
unit = None
|
||||
if action.unit_id:
|
||||
unit = db.query(RosterUnit).filter_by(id=action.unit_id).first()
|
||||
else:
|
||||
# Get from assignment
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == action.location_id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).first()
|
||||
if assignment:
|
||||
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
||||
|
||||
actions_data.append({
|
||||
"action": action,
|
||||
"location": location,
|
||||
"unit": unit,
|
||||
})
|
||||
|
||||
return templates.TemplateResponse("partials/projects/scheduler_agenda.html", {
|
||||
"request": request,
|
||||
"project_id": project_id,
|
||||
"actions": actions_data,
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Create Scheduled Action
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/actions/create")
|
||||
async def create_scheduled_action(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Create a new scheduled action.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
location_id = form_data.get("location_id")
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
# Determine device type from location
|
||||
device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||
|
||||
# Get unit_id (optional - can be determined from assignment at execution time)
|
||||
unit_id = form_data.get("unit_id")
|
||||
|
||||
action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
unit_id=unit_id,
|
||||
action_type=form_data.get("action_type"),
|
||||
device_type=device_type,
|
||||
scheduled_time=datetime.fromisoformat(form_data.get("scheduled_time")),
|
||||
execution_status="pending",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
|
||||
db.add(action)
|
||||
db.commit()
|
||||
db.refresh(action)
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"action_id": action.id,
|
||||
"message": f"Scheduled action '{action.action_type}' created for {action.scheduled_time}",
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Schedule Recording Session
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/schedule-session")
|
||||
async def schedule_recording_session(
|
||||
project_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Schedule a complete recording session (start + stop).
|
||||
Creates two scheduled actions: start and stop.
|
||||
"""
|
||||
project = db.query(Project).filter_by(id=project_id).first()
|
||||
if not project:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
|
||||
form_data = await request.form()
|
||||
|
||||
location_id = form_data.get("location_id")
|
||||
location = db.query(MonitoringLocation).filter_by(
|
||||
id=location_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not location:
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
|
||||
device_type = "slm" if location.location_type == "sound" else "seismograph"
|
||||
unit_id = form_data.get("unit_id")
|
||||
|
||||
start_time = datetime.fromisoformat(form_data.get("start_time"))
|
||||
duration_minutes = int(form_data.get("duration_minutes", 60))
|
||||
stop_time = start_time + timedelta(minutes=duration_minutes)
|
||||
|
||||
# Create START action
|
||||
start_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="start",
|
||||
device_type=device_type,
|
||||
scheduled_time=start_time,
|
||||
execution_status="pending",
|
||||
notes=form_data.get("notes"),
|
||||
)
|
||||
|
||||
# Create STOP action
|
||||
stop_action = ScheduledAction(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=project_id,
|
||||
location_id=location_id,
|
||||
unit_id=unit_id,
|
||||
action_type="stop",
|
||||
device_type=device_type,
|
||||
scheduled_time=stop_time,
|
||||
execution_status="pending",
|
||||
notes=f"Auto-stop after {duration_minutes} minutes",
|
||||
)
|
||||
|
||||
db.add(start_action)
|
||||
db.add(stop_action)
|
||||
db.commit()
|
||||
|
||||
return JSONResponse({
|
||||
"success": True,
|
||||
"start_action_id": start_action.id,
|
||||
"stop_action_id": stop_action.id,
|
||||
"message": f"Recording session scheduled from {start_time} to {stop_time}",
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Update/Cancel Scheduled Action
|
||||
# ============================================================================
|
||||
|
||||
@router.put("/actions/{action_id}")
|
||||
async def update_scheduled_action(
|
||||
project_id: str,
|
||||
action_id: str,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Update a scheduled action (only if not yet executed).
|
||||
"""
|
||||
action = db.query(ScheduledAction).filter_by(
|
||||
id=action_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
|
||||
if action.execution_status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot update action that has already been executed",
|
||||
)
|
||||
|
||||
data = await request.json()
|
||||
|
||||
if "scheduled_time" in data:
|
||||
action.scheduled_time = datetime.fromisoformat(data["scheduled_time"])
|
||||
if "notes" in data:
|
||||
action.notes = data["notes"]
|
||||
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Action updated successfully"}
|
||||
|
||||
|
||||
@router.post("/actions/{action_id}/cancel")
|
||||
async def cancel_scheduled_action(
|
||||
project_id: str,
|
||||
action_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Cancel a pending scheduled action.
|
||||
"""
|
||||
action = db.query(ScheduledAction).filter_by(
|
||||
id=action_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
|
||||
if action.execution_status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Can only cancel pending actions",
|
||||
)
|
||||
|
||||
action.execution_status = "cancelled"
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Action cancelled successfully"}
|
||||
|
||||
|
||||
@router.delete("/actions/{action_id}")
|
||||
async def delete_scheduled_action(
|
||||
project_id: str,
|
||||
action_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Delete a scheduled action (only if pending or cancelled).
|
||||
"""
|
||||
action = db.query(ScheduledAction).filter_by(
|
||||
id=action_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
|
||||
if action.execution_status not in ["pending", "cancelled"]:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete action that has been executed",
|
||||
)
|
||||
|
||||
db.delete(action)
|
||||
db.commit()
|
||||
|
||||
return {"success": True, "message": "Action deleted successfully"}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Manual Execution
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/actions/{action_id}/execute")
|
||||
async def execute_action_now(
|
||||
project_id: str,
|
||||
action_id: str,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
Manually trigger execution of a scheduled action (for testing/debugging).
|
||||
"""
|
||||
action = db.query(ScheduledAction).filter_by(
|
||||
id=action_id,
|
||||
project_id=project_id,
|
||||
).first()
|
||||
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail="Action not found")
|
||||
|
||||
if action.execution_status != "pending":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Action is not pending",
|
||||
)
|
||||
|
||||
# Execute via scheduler service
|
||||
scheduler = get_scheduler()
|
||||
result = await scheduler.execute_action_by_id(action_id)
|
||||
|
||||
# Refresh from DB to get updated status
|
||||
db.refresh(action)
|
||||
|
||||
return JSONResponse({
|
||||
"success": result.get("success", False),
|
||||
"result": result,
|
||||
"action": {
|
||||
"id": action.id,
|
||||
"execution_status": action.execution_status,
|
||||
"executed_at": action.executed_at.isoformat() if action.executed_at else None,
|
||||
"error_message": action.error_message,
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Scheduler Status
|
||||
# ============================================================================
|
||||
|
||||
@router.get("/status")
|
||||
async def get_scheduler_status():
|
||||
"""
|
||||
Get scheduler service status.
|
||||
"""
|
||||
scheduler = get_scheduler()
|
||||
|
||||
return {
|
||||
"running": scheduler.running,
|
||||
"check_interval": scheduler.check_interval,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/execute-pending")
|
||||
async def trigger_pending_execution():
|
||||
"""
|
||||
Manually trigger execution of all pending actions (for testing).
|
||||
"""
|
||||
scheduler = get_scheduler()
|
||||
results = await scheduler.execute_pending_actions()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"executed_count": len(results),
|
||||
"results": results,
|
||||
}
|
||||
81
backend/routers/seismo_dashboard.py
Normal file
81
backend/routers/seismo_dashboard.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Seismograph Dashboard API Router
|
||||
Provides endpoints for the seismograph-specific dashboard
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns HTML partial with seismograph statistics summary
|
||||
"""
|
||||
# Get all seismograph units
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
).all()
|
||||
|
||||
total = len(seismos)
|
||||
deployed = sum(1 for s in seismos if s.deployed)
|
||||
benched = sum(1 for s in seismos if not s.deployed)
|
||||
|
||||
# Count modems assigned to deployed seismographs
|
||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||
without_modem = deployed - with_modem
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/seismo_stats.html",
|
||||
{
|
||||
"request": request,
|
||||
"total": total,
|
||||
"deployed": deployed,
|
||||
"benched": benched,
|
||||
"with_modem": with_modem,
|
||||
"without_modem": without_modem
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_seismo_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None)
|
||||
):
|
||||
"""
|
||||
Returns HTML partial with filterable seismograph unit list
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
)
|
||||
|
||||
# Apply search filter
|
||||
if search:
|
||||
search_lower = search.lower()
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||
(RosterUnit.address.ilike(f"%{search}%"))
|
||||
)
|
||||
|
||||
seismos = query.order_by(RosterUnit.id).all()
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"partials/seismo_unit_list.html",
|
||||
{
|
||||
"request": request,
|
||||
"units": seismos,
|
||||
"search": search or ""
|
||||
}
|
||||
)
|
||||
@@ -100,6 +100,14 @@ def get_all_roster_units(db: Session = Depends(get_db)):
|
||||
"ip_address": unit.ip_address or "",
|
||||
"phone_number": unit.phone_number or "",
|
||||
"hardware_model": unit.hardware_model or "",
|
||||
"slm_host": unit.slm_host or "",
|
||||
"slm_tcp_port": unit.slm_tcp_port,
|
||||
"slm_model": unit.slm_model or "",
|
||||
"slm_serial_number": unit.slm_serial_number or "",
|
||||
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||
} for unit in units]
|
||||
|
||||
@@ -469,3 +477,75 @@ async def upload_snapshot(file: UploadFile = File(...)):
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# SLMM SYNC ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
@router.post("/slmm/sync-all")
|
||||
async def sync_all_slms(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Manually trigger full sync of all SLM devices from Terra-View roster to SLMM.
|
||||
|
||||
This ensures SLMM database matches Terra-View roster (source of truth).
|
||||
Also cleans up orphaned devices in SLMM that are not in Terra-View.
|
||||
"""
|
||||
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
|
||||
|
||||
try:
|
||||
# Sync all SLMs
|
||||
sync_results = await sync_all_slms_to_slmm(db)
|
||||
|
||||
# Clean up orphaned devices
|
||||
cleanup_results = await cleanup_orphaned_slmm_devices(db)
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"sync": sync_results,
|
||||
"cleanup": cleanup_results
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/slmm/status")
|
||||
async def get_slmm_sync_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get status of SLMM synchronization.
|
||||
|
||||
Shows which devices are in Terra-View roster vs SLMM database.
|
||||
"""
|
||||
from backend.services.slmm_sync import get_slmm_devices
|
||||
|
||||
try:
|
||||
# Get devices from both systems
|
||||
roster_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
||||
slmm_devices = await get_slmm_devices()
|
||||
|
||||
if slmm_devices is None:
|
||||
raise HTTPException(status_code=503, detail="SLMM service unavailable")
|
||||
|
||||
roster_unit_ids = {unit.unit_type for unit in roster_slms}
|
||||
slmm_unit_ids = set(slmm_devices)
|
||||
|
||||
# Find differences
|
||||
in_roster_only = roster_unit_ids - slmm_unit_ids
|
||||
in_slmm_only = slmm_unit_ids - roster_unit_ids
|
||||
in_both = roster_unit_ids & slmm_unit_ids
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"terra_view_total": len(roster_unit_ids),
|
||||
"slmm_total": len(slmm_unit_ids),
|
||||
"synced": len(in_both),
|
||||
"missing_from_slmm": list(in_roster_only),
|
||||
"orphaned_in_slmm": list(in_slmm_only),
|
||||
"in_sync": len(in_roster_only) == 0 and len(in_slmm_only) == 0
|
||||
}
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
|
||||
|
||||
380
backend/routers/slm_dashboard.py
Normal file
380
backend/routers/slm_dashboard.py
Normal file
@@ -0,0 +1,380 @@
|
||||
"""
|
||||
SLM Dashboard Router
|
||||
|
||||
Provides API endpoints for the Sound Level Meters dashboard page.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get summary statistics for SLM dashboard.
|
||||
Returns HTML partial with stat cards.
|
||||
"""
|
||||
# Query all SLMs
|
||||
all_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
||||
|
||||
# Count deployed vs benched
|
||||
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
||||
benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired)
|
||||
retired_count = sum(1 for slm in all_slms if slm.retired)
|
||||
|
||||
# Count recently active (checked in last hour)
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
active_count = sum(1 for slm in all_slms
|
||||
if slm.slm_last_check and slm.slm_last_check > one_hour_ago)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_stats.html", {
|
||||
"request": request,
|
||||
"total_count": len(all_slms),
|
||||
"deployed_count": deployed_count,
|
||||
"benched_count": benched_count,
|
||||
"active_count": active_count,
|
||||
"retired_count": retired_count
|
||||
})
|
||||
|
||||
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_slm_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None),
|
||||
project: str = Query(None),
|
||||
include_measurement: bool = Query(False),
|
||||
):
|
||||
"""
|
||||
Get list of SLM units for the sidebar.
|
||||
Returns HTML partial with unit cards.
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(device_type="slm")
|
||||
|
||||
# Filter by project if provided
|
||||
if project:
|
||||
query = query.filter(RosterUnit.project_id == project)
|
||||
|
||||
# Filter by search term if provided
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.like(search_term)) |
|
||||
(RosterUnit.slm_model.like(search_term)) |
|
||||
(RosterUnit.address.like(search_term))
|
||||
)
|
||||
|
||||
units = query.order_by(
|
||||
RosterUnit.retired.asc(),
|
||||
RosterUnit.deployed.desc(),
|
||||
RosterUnit.id.asc()
|
||||
).all()
|
||||
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
for unit in units:
|
||||
unit.is_recent = bool(unit.slm_last_check and unit.slm_last_check > one_hour_ago)
|
||||
|
||||
if include_measurement:
|
||||
async def fetch_measurement_state(client: httpx.AsyncClient, unit_id: str) -> str | None:
|
||||
try:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state")
|
||||
if response.status_code == 200:
|
||||
return response.json().get("measurement_state")
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
deployed_units = [unit for unit in units if unit.deployed and not unit.retired]
|
||||
if deployed_units:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
tasks = [fetch_measurement_state(client, unit.id) for unit in deployed_units]
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
for unit, state in zip(deployed_units, results):
|
||||
if isinstance(state, Exception):
|
||||
unit.measurement_state = None
|
||||
else:
|
||||
unit.measurement_state = state
|
||||
|
||||
return templates.TemplateResponse("partials/slm_device_list.html", {
|
||||
"request": request,
|
||||
"units": units
|
||||
})
|
||||
|
||||
|
||||
@router.get("/live-view/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_live_view(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get live view panel for a specific SLM unit.
|
||||
Returns HTML partial with live metrics and chart.
|
||||
"""
|
||||
# Get unit from database
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||
|
||||
if not unit:
|
||||
return templates.TemplateResponse("partials/slm_live_view_error.html", {
|
||||
"request": request,
|
||||
"error": f"Unit {unit_id} not found"
|
||||
})
|
||||
|
||||
# Get modem information if assigned
|
||||
modem = None
|
||||
modem_ip = None
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first()
|
||||
if modem:
|
||||
modem_ip = modem.ip_address
|
||||
else:
|
||||
logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found")
|
||||
|
||||
# Fallback to direct slm_host if no modem assigned (backward compatibility)
|
||||
if not modem_ip and unit.slm_host:
|
||||
modem_ip = unit.slm_host
|
||||
logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}")
|
||||
|
||||
# Try to get current status from SLMM
|
||||
current_status = None
|
||||
measurement_state = None
|
||||
is_measuring = False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
state_data = state_response.json()
|
||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||
is_measuring = state_data.get("is_measuring", False)
|
||||
|
||||
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
|
||||
if is_measuring:
|
||||
try:
|
||||
sync_response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
|
||||
timeout=10.0
|
||||
)
|
||||
if sync_response.status_code == 200:
|
||||
sync_data = sync_response.json()
|
||||
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
|
||||
else:
|
||||
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
|
||||
except Exception as e:
|
||||
# Don't fail the whole request if sync fails
|
||||
logger.warning(f"Could not sync start time for {unit_id}: {e}")
|
||||
|
||||
# Get live status (now with corrected start time)
|
||||
status_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||
)
|
||||
if status_response.status_code == 200:
|
||||
status_data = status_response.json()
|
||||
current_status = status_data.get("data", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"modem": modem,
|
||||
"modem_ip": modem_ip,
|
||||
"current_status": current_status,
|
||||
"measurement_state": measurement_state,
|
||||
"is_measuring": is_measuring
|
||||
})
|
||||
|
||||
|
||||
@router.post("/control/{unit_id}/{action}")
|
||||
async def control_slm(unit_id: str, action: str):
|
||||
"""
|
||||
Send control commands to SLM (start, stop, pause, resume, reset).
|
||||
Proxies to SLMM backend.
|
||||
"""
|
||||
valid_actions = ["start", "stop", "pause", "resume", "reset"]
|
||||
|
||||
if action not in valid_actions:
|
||||
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to control {unit_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get configuration form for a specific SLM unit.
|
||||
Returns HTML partial with configuration form.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||
|
||||
if not unit:
|
||||
return HTMLResponse(
|
||||
content=f'<div class="text-red-500">Unit {unit_id} not found</div>',
|
||||
status_code=404
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_config_form.html", {
|
||||
"request": request,
|
||||
"unit": unit
|
||||
})
|
||||
|
||||
|
||||
@router.post("/config/{unit_id}")
|
||||
async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Save SLM configuration.
|
||||
Updates unit parameters in the database.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||
|
||||
if not unit:
|
||||
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
form_data = await request.form()
|
||||
|
||||
# Update SLM-specific fields
|
||||
unit.slm_model = form_data.get("slm_model") or None
|
||||
unit.slm_serial_number = form_data.get("slm_serial_number") or None
|
||||
unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None
|
||||
unit.slm_time_weighting = form_data.get("slm_time_weighting") or None
|
||||
unit.slm_measurement_range = form_data.get("slm_measurement_range") or None
|
||||
|
||||
# Update network configuration
|
||||
modem_id = form_data.get("deployed_with_modem_id")
|
||||
unit.deployed_with_modem_id = modem_id if modem_id else None
|
||||
|
||||
# Always update TCP and FTP ports (used regardless of modem assignment)
|
||||
unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None
|
||||
unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None
|
||||
|
||||
# Only update direct IP if no modem is assigned
|
||||
if not modem_id:
|
||||
unit.slm_host = form_data.get("slm_host") or None
|
||||
else:
|
||||
# Clear legacy direct IP field when modem is assigned
|
||||
unit.slm_host = None
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Updated configuration for SLM {unit_id}")
|
||||
|
||||
# Sync updated configuration to SLMM cache
|
||||
logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=unit_id,
|
||||
host=unit.slm_host, # Use the updated host from Terra-View
|
||||
tcp_port=unit.slm_tcp_port,
|
||||
ftp_port=unit.slm_ftp_port,
|
||||
deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}")
|
||||
# Config still saved in Terra-View (source of truth)
|
||||
|
||||
return {"status": "success", "unit_id": unit_id}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to save config for {unit_id}: {e}")
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
|
||||
@router.get("/test-modem/{modem_id}")
|
||||
async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Test modem connectivity with a simple ping/health check.
|
||||
Returns response time and connection status.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# 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": response_time,
|
||||
"message": "Modem is responding to ping"
|
||||
}
|
||||
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 (> 2 seconds)"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"detail": str(e)
|
||||
}
|
||||
123
backend/routers/slm_ui.py
Normal file
123
backend/routers/slm_ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Sound Level Meter UI Router
|
||||
|
||||
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
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")
|
||||
|
||||
|
||||
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Sound level meter detail page with controls."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "slm":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
return templates.TemplateResponse("slm_detail.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/{unit_id}/summary")
|
||||
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get SLM summary data for dashboard card."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "slm":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Try to get live status from SLMM
|
||||
status_data = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||
if response.status_code == 200:
|
||||
status_data = response.json().get("data")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"device_type": "slm",
|
||||
"deployed": unit.deployed,
|
||||
"model": unit.slm_model or "NL-43",
|
||||
"location": unit.address or unit.location,
|
||||
"coordinates": unit.coordinates,
|
||||
"note": unit.note,
|
||||
"status": status_data,
|
||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM dashboard card partial."""
|
||||
|
||||
summary = await get_slm_summary(unit_id, db)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_card.html", {
|
||||
"request": request,
|
||||
"slm": summary
|
||||
})
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM control panel partial."""
|
||||
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "slm":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Get current status from SLMM
|
||||
measurement_state = None
|
||||
battery_level = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
measurement_state = state_response.json().get("measurement_state")
|
||||
|
||||
# Get battery level
|
||||
battery_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||
)
|
||||
if battery_response.status_code == 200:
|
||||
battery_level = battery_response.json().get("battery_level")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id,
|
||||
"unit": unit,
|
||||
"measurement_state": measurement_state,
|
||||
"battery_level": battery_level,
|
||||
"is_measuring": measurement_state == "Start"
|
||||
})
|
||||
301
backend/routers/slmm.py
Normal file
301
backend/routers/slmm.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
SLMM (Sound Level Meter Manager) Proxy Router
|
||||
|
||||
Proxies requests from SFM to the standalone SLMM backend service.
|
||||
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
import websockets
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
# WebSocket URL derived from HTTP URL
|
||||
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def check_slmm_health():
|
||||
"""
|
||||
Check if the SLMM backend service is reachable and healthy.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"slmm_status": "connected",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"slmm_version": data.get("version", "unknown"),
|
||||
"slmm_response": data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
|
||||
except httpx.ConnectError:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "unreachable",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
|
||||
# WebSocket routes MUST come before the catch-all route
|
||||
@router.websocket("/{unit_id}/stream")
|
||||
async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /stream endpoint.
|
||||
|
||||
This allows real-time streaming of measurement data from NL43 devices
|
||||
through the SFM unified interface.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
||||
|
||||
# Build target WebSocket URL
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id}")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend: {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client: {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id}")
|
||||
|
||||
|
||||
@router.websocket("/{unit_id}/live")
|
||||
async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /live endpoint.
|
||||
|
||||
Alternative WebSocket endpoint that may be used by some frontend components.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)")
|
||||
|
||||
# Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend (live): {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client (live): {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||
|
||||
|
||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_slmm(path: str, request: Request):
|
||||
"""
|
||||
Proxy all requests to the SLMM backend service.
|
||||
|
||||
This allows SFM to act as a unified frontend for all device types,
|
||||
while SLMM remains a standalone backend service.
|
||||
"""
|
||||
# Build target URL
|
||||
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||
|
||||
# Get query parameters
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Get request body if present
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read request body: {e}")
|
||||
body = None
|
||||
|
||||
# Get headers (exclude host and other proxy-specific headers)
|
||||
headers = dict(request.headers)
|
||||
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||
|
||||
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Forward the request to SLMM
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=query_params,
|
||||
headers=proxy_headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Return the response from SLMM
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="SLMM backend timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error proxying to SLMM: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||
)
|
||||
@@ -5,6 +5,7 @@ from typing import Dict, Any
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["units"])
|
||||
|
||||
@@ -42,3 +43,32 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||
"note": unit_data.get("note", ""),
|
||||
"coordinates": coords
|
||||
}
|
||||
|
||||
|
||||
@router.get("/units/{unit_id}")
|
||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get unit data directly from the roster (for settings/configuration).
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
|
||||
if not unit:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
return {
|
||||
"id": unit.id,
|
||||
"unit_type": unit.unit_type,
|
||||
"device_type": unit.device_type,
|
||||
"deployed": unit.deployed,
|
||||
"retired": unit.retired,
|
||||
"note": unit.note,
|
||||
"location": unit.location,
|
||||
"address": unit.address,
|
||||
"coordinates": unit.coordinates,
|
||||
"slm_host": unit.slm_host,
|
||||
"slm_tcp_port": unit.slm_tcp_port,
|
||||
"slm_ftp_port": unit.slm_ftp_port,
|
||||
"slm_model": unit.slm_model,
|
||||
"slm_serial_number": unit.slm_serial_number,
|
||||
"deployed_with_modem_id": unit.deployed_with_modem_id
|
||||
}
|
||||
|
||||
407
backend/services/alert_service.py
Normal file
407
backend/services/alert_service.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""
|
||||
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 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)
|
||||
454
backend/services/device_controller.py
Normal file
454
backend/services/device_controller.py
Normal file
@@ -0,0 +1,454 @@
|
||||
"""
|
||||
Device Controller Service
|
||||
|
||||
Routes device operations to the appropriate backend module:
|
||||
- SLMM for sound level meters
|
||||
- SFM for seismographs (future implementation)
|
||||
|
||||
This abstraction allows Projects system to work with any device type
|
||||
without knowing the underlying communication protocol.
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Optional, List
|
||||
from backend.services.slmm_client import get_slmm_client, SLMMClientError
|
||||
|
||||
|
||||
class DeviceControllerError(Exception):
|
||||
"""Base exception for device controller errors."""
|
||||
pass
|
||||
|
||||
|
||||
class UnsupportedDeviceTypeError(DeviceControllerError):
|
||||
"""Raised when device type is not supported."""
|
||||
pass
|
||||
|
||||
|
||||
class DeviceController:
|
||||
"""
|
||||
Unified interface for controlling all device types.
|
||||
|
||||
Routes commands to appropriate backend module based on device_type.
|
||||
|
||||
Usage:
|
||||
controller = DeviceController()
|
||||
await controller.start_recording("nl43-001", "slm", config={})
|
||||
await controller.stop_recording("seismo-042", "seismograph")
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.slmm_client = get_slmm_client()
|
||||
|
||||
# ========================================================================
|
||||
# Recording Control
|
||||
# ========================================================================
|
||||
|
||||
async def start_recording(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Start recording on a device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
config: Device-specific recording configuration
|
||||
|
||||
Returns:
|
||||
Response dict from device module
|
||||
|
||||
Raises:
|
||||
UnsupportedDeviceTypeError: Device type not supported
|
||||
DeviceControllerError: Operation failed
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.start_recording(unit_id, config)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
# TODO: Implement SFM client for seismograph control
|
||||
# For now, return a placeholder response
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph recording control not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(
|
||||
f"Device type '{device_type}' is not supported. "
|
||||
f"Supported types: slm, seismograph"
|
||||
)
|
||||
|
||||
async def stop_recording(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Stop recording on a device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.stop_recording(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
# TODO: Implement SFM client
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph recording control not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
async def pause_recording(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Pause recording on a device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.pause_recording(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph pause not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
async def resume_recording(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Resume paused recording on a device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Response dict from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.resume_recording(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph resume not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# Status & Monitoring
|
||||
# ========================================================================
|
||||
|
||||
async def get_device_status(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current device status.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Status dict from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.get_unit_status(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
# TODO: Implement SFM status check
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph status not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
async def get_live_data(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Get live data from device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
Live data dict from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.get_live_data(unit_id)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph live data not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download
|
||||
# ========================================================================
|
||||
|
||||
async def download_files(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
destination_path: str,
|
||||
files: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download data files from device.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
destination_path: Local path to save files
|
||||
files: List of filenames, or None for all
|
||||
|
||||
Returns:
|
||||
Download result with file list
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.download_files(
|
||||
unit_id,
|
||||
destination_path,
|
||||
files,
|
||||
)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
# TODO: Implement SFM file download
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph file download not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
raise UnsupportedDeviceTypeError(f"Unsupported device type: {device_type}")
|
||||
|
||||
# ========================================================================
|
||||
# Device Configuration
|
||||
# ========================================================================
|
||||
|
||||
async def update_device_config(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
config: Dict[str, Any],
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update device configuration.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
config: Configuration parameters
|
||||
|
||||
Returns:
|
||||
Updated config from device module
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
return await self.slmm_client.update_unit_config(
|
||||
unit_id,
|
||||
host=config.get("host"),
|
||||
tcp_port=config.get("tcp_port"),
|
||||
ftp_port=config.get("ftp_port"),
|
||||
ftp_username=config.get("ftp_username"),
|
||||
ftp_password=config.get("ftp_password"),
|
||||
)
|
||||
except SLMMClientError as e:
|
||||
raise DeviceControllerError(f"SLMM error: {str(e)}")
|
||||
|
||||
elif device_type == "seismograph":
|
||||
return {
|
||||
"status": "not_implemented",
|
||||
"message": "Seismograph config update not yet implemented",
|
||||
"unit_id": unit_id,
|
||||
}
|
||||
|
||||
else:
|
||||
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}")
|
||||
|
||||
# ========================================================================
|
||||
# Health Check
|
||||
# ========================================================================
|
||||
|
||||
async def check_device_connectivity(
|
||||
self,
|
||||
unit_id: str,
|
||||
device_type: str,
|
||||
) -> bool:
|
||||
"""
|
||||
Check if device is reachable.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
device_type: "slm" | "seismograph"
|
||||
|
||||
Returns:
|
||||
True if device is reachable, False otherwise
|
||||
"""
|
||||
if device_type == "slm":
|
||||
try:
|
||||
status = await self.slmm_client.get_unit_status(unit_id)
|
||||
return status.get("last_seen") is not None
|
||||
except:
|
||||
return False
|
||||
|
||||
elif device_type == "seismograph":
|
||||
# TODO: Implement SFM connectivity check
|
||||
return False
|
||||
|
||||
else:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_default_controller: Optional[DeviceController] = None
|
||||
|
||||
|
||||
def get_device_controller() -> DeviceController:
|
||||
"""
|
||||
Get the default device controller instance.
|
||||
|
||||
Returns:
|
||||
DeviceController instance
|
||||
"""
|
||||
global _default_controller
|
||||
if _default_controller is None:
|
||||
_default_controller = DeviceController()
|
||||
return _default_controller
|
||||
184
backend/services/device_status_monitor.py
Normal file
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()
|
||||
550
backend/services/recurring_schedule_service.py
Normal file
550
backend/services/recurring_schedule_service.py
Normal file
@@ -0,0 +1,550 @@
|
||||
"""
|
||||
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
|
||||
# Note: We don't have recurring_schedule_id field yet, so we can't clean up
|
||||
# generated actions. This is fine for now.
|
||||
|
||||
self.db.delete(schedule)
|
||||
self.db.commit()
|
||||
|
||||
logger.info(f"Deleted recurring schedule: {schedule.name}")
|
||||
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)
|
||||
490
backend/services/scheduler.py
Normal file
490
backend/services/scheduler.py
Normal file
@@ -0,0 +1,490 @@
|
||||
"""
|
||||
Scheduler Service
|
||||
|
||||
Executes scheduled actions for Projects system.
|
||||
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
|
||||
every minute and executing them when their scheduled time arrives.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import ScheduledAction, RecordingSession, MonitoringLocation, Project, RecurringSchedule
|
||||
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||
import uuid
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SchedulerService:
|
||||
"""
|
||||
Service for executing scheduled actions.
|
||||
|
||||
Usage:
|
||||
scheduler = SchedulerService()
|
||||
await scheduler.start() # Start background loop
|
||||
scheduler.stop() # Stop background loop
|
||||
"""
|
||||
|
||||
def __init__(self, check_interval: int = 60):
|
||||
"""
|
||||
Initialize scheduler.
|
||||
|
||||
Args:
|
||||
check_interval: Seconds between checks for pending actions (default: 60)
|
||||
"""
|
||||
self.check_interval = check_interval
|
||||
self.running = False
|
||||
self.task: Optional[asyncio.Task] = None
|
||||
self.device_controller = get_device_controller()
|
||||
|
||||
async def start(self):
|
||||
"""Start the scheduler background task."""
|
||||
if self.running:
|
||||
print("Scheduler is already running")
|
||||
return
|
||||
|
||||
self.running = True
|
||||
self.task = asyncio.create_task(self._run_loop())
|
||||
print(f"Scheduler started (checking every {self.check_interval}s)")
|
||||
|
||||
def stop(self):
|
||||
"""Stop the scheduler background task."""
|
||||
self.running = False
|
||||
if self.task:
|
||||
self.task.cancel()
|
||||
print("Scheduler stopped")
|
||||
|
||||
async def _run_loop(self):
|
||||
"""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:
|
||||
try:
|
||||
# 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:
|
||||
logger.error(f"Scheduler error: {e}", exc_info=True)
|
||||
# Continue running even if there's an error
|
||||
|
||||
await asyncio.sleep(self.check_interval)
|
||||
|
||||
async def execute_pending_actions(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Find and execute all pending scheduled actions that are due.
|
||||
|
||||
Returns:
|
||||
List of execution results
|
||||
"""
|
||||
db = SessionLocal()
|
||||
results = []
|
||||
|
||||
try:
|
||||
# Find pending actions that are due
|
||||
now = datetime.utcnow()
|
||||
pending_actions = db.query(ScheduledAction).filter(
|
||||
and_(
|
||||
ScheduledAction.execution_status == "pending",
|
||||
ScheduledAction.scheduled_time <= now,
|
||||
)
|
||||
).order_by(ScheduledAction.scheduled_time).all()
|
||||
|
||||
if not pending_actions:
|
||||
return []
|
||||
|
||||
print(f"Found {len(pending_actions)} pending action(s) to execute")
|
||||
|
||||
for action in pending_actions:
|
||||
result = await self._execute_action(action, db)
|
||||
results.append(result)
|
||||
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error executing pending actions: {e}")
|
||||
db.rollback()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return results
|
||||
|
||||
async def _execute_action(
|
||||
self,
|
||||
action: ScheduledAction,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a single scheduled action.
|
||||
|
||||
Args:
|
||||
action: ScheduledAction to execute
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Execution result dict
|
||||
"""
|
||||
print(f"Executing action {action.id}: {action.action_type} for unit {action.unit_id}")
|
||||
|
||||
result = {
|
||||
"action_id": action.id,
|
||||
"action_type": action.action_type,
|
||||
"unit_id": action.unit_id,
|
||||
"scheduled_time": action.scheduled_time.isoformat(),
|
||||
"success": False,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
try:
|
||||
# Determine which unit to use
|
||||
# If unit_id is specified, use it; otherwise get from location assignment
|
||||
unit_id = action.unit_id
|
||||
if not unit_id:
|
||||
# Get assigned unit from location
|
||||
from backend.models import UnitAssignment
|
||||
assignment = db.query(UnitAssignment).filter(
|
||||
and_(
|
||||
UnitAssignment.location_id == action.location_id,
|
||||
UnitAssignment.status == "active",
|
||||
)
|
||||
).first()
|
||||
|
||||
if not assignment:
|
||||
raise Exception(f"No active unit assigned to location {action.location_id}")
|
||||
|
||||
unit_id = assignment.unit_id
|
||||
|
||||
# Execute the action based on type
|
||||
if action.action_type == "start":
|
||||
response = await self._execute_start(action, unit_id, db)
|
||||
elif action.action_type == "stop":
|
||||
response = await self._execute_stop(action, unit_id, db)
|
||||
elif action.action_type == "download":
|
||||
response = await self._execute_download(action, unit_id, db)
|
||||
else:
|
||||
raise Exception(f"Unknown action type: {action.action_type}")
|
||||
|
||||
# Mark action as completed
|
||||
action.execution_status = "completed"
|
||||
action.executed_at = datetime.utcnow()
|
||||
action.module_response = json.dumps(response)
|
||||
|
||||
result["success"] = True
|
||||
result["response"] = response
|
||||
|
||||
print(f"✓ Action {action.id} completed successfully")
|
||||
|
||||
except Exception as e:
|
||||
# Mark action as failed
|
||||
action.execution_status = "failed"
|
||||
action.executed_at = datetime.utcnow()
|
||||
action.error_message = str(e)
|
||||
|
||||
result["error"] = str(e)
|
||||
|
||||
print(f"✗ Action {action.id} failed: {e}")
|
||||
|
||||
return result
|
||||
|
||||
async def _execute_start(
|
||||
self,
|
||||
action: ScheduledAction,
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a 'start' action."""
|
||||
# Parse action notes for automation settings
|
||||
auto_increment_index = False
|
||||
try:
|
||||
if action.notes:
|
||||
notes_data = json.loads(action.notes)
|
||||
auto_increment_index = notes_data.get("auto_increment_index", False)
|
||||
except json.JSONDecodeError:
|
||||
pass # Notes is plain text, not JSON
|
||||
|
||||
# If auto_increment_index is enabled, increment the store index before starting
|
||||
increment_response = None
|
||||
if auto_increment_index and action.device_type == "slm":
|
||||
try:
|
||||
logger.info(f"Auto-incrementing store index for unit {unit_id}")
|
||||
increment_response = await self.device_controller.increment_index(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
)
|
||||
logger.info(f"Index incremented: {increment_response}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to increment index for {unit_id}: {e}")
|
||||
# Continue with start anyway - don't fail the whole action
|
||||
|
||||
# Start recording via device controller
|
||||
response = await self.device_controller.start_recording(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
config={},
|
||||
)
|
||||
|
||||
# Create recording session
|
||||
session = RecordingSession(
|
||||
id=str(uuid.uuid4()),
|
||||
project_id=action.project_id,
|
||||
location_id=action.location_id,
|
||||
unit_id=unit_id,
|
||||
session_type="sound" if action.device_type == "slm" else "vibration",
|
||||
started_at=datetime.utcnow(),
|
||||
status="recording",
|
||||
session_metadata=json.dumps({
|
||||
"scheduled_action_id": action.id,
|
||||
"auto_increment_index": auto_increment_index,
|
||||
"increment_response": increment_response,
|
||||
}),
|
||||
)
|
||||
db.add(session)
|
||||
|
||||
return {
|
||||
"status": "started",
|
||||
"session_id": session.id,
|
||||
"device_response": response,
|
||||
"index_incremented": auto_increment_index,
|
||||
"increment_response": increment_response,
|
||||
}
|
||||
|
||||
async def _execute_stop(
|
||||
self,
|
||||
action: ScheduledAction,
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a 'stop' action."""
|
||||
# Stop recording via device controller
|
||||
response = await self.device_controller.stop_recording(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
)
|
||||
|
||||
# Find and update the active recording session
|
||||
active_session = db.query(RecordingSession).filter(
|
||||
and_(
|
||||
RecordingSession.location_id == action.location_id,
|
||||
RecordingSession.unit_id == unit_id,
|
||||
RecordingSession.status == "recording",
|
||||
)
|
||||
).first()
|
||||
|
||||
if active_session:
|
||||
active_session.stopped_at = datetime.utcnow()
|
||||
active_session.status = "completed"
|
||||
active_session.duration_seconds = int(
|
||||
(active_session.stopped_at - active_session.started_at).total_seconds()
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "stopped",
|
||||
"session_id": active_session.id if active_session else None,
|
||||
"device_response": response,
|
||||
}
|
||||
|
||||
async def _execute_download(
|
||||
self,
|
||||
action: ScheduledAction,
|
||||
unit_id: str,
|
||||
db: Session,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute a 'download' action."""
|
||||
# Get project and location info for file path
|
||||
location = db.query(MonitoringLocation).filter_by(id=action.location_id).first()
|
||||
project = db.query(Project).filter_by(id=action.project_id).first()
|
||||
|
||||
if not location or not project:
|
||||
raise Exception("Project or location not found")
|
||||
|
||||
# Build destination path
|
||||
# Example: data/Projects/{project-id}/sound/{location-name}/session-{timestamp}/
|
||||
session_timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H%M")
|
||||
location_type_dir = "sound" if action.device_type == "slm" else "vibration"
|
||||
|
||||
destination_path = (
|
||||
f"data/Projects/{project.id}/{location_type_dir}/"
|
||||
f"{location.name}/session-{session_timestamp}/"
|
||||
)
|
||||
|
||||
# Download files via device controller
|
||||
response = await self.device_controller.download_files(
|
||||
unit_id,
|
||||
action.device_type,
|
||||
destination_path,
|
||||
files=None, # Download all files
|
||||
)
|
||||
|
||||
# TODO: Create DataFile records for downloaded files
|
||||
|
||||
return {
|
||||
"status": "downloaded",
|
||||
"destination_path": destination_path,
|
||||
"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)
|
||||
# ========================================================================
|
||||
|
||||
async def execute_action_by_id(self, action_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Manually execute a specific action by ID.
|
||||
|
||||
Args:
|
||||
action_id: ScheduledAction ID
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
action = db.query(ScheduledAction).filter_by(id=action_id).first()
|
||||
if not action:
|
||||
return {"success": False, "error": "Action not found"}
|
||||
|
||||
result = await self._execute_action(action, db)
|
||||
db.commit()
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
return {"success": False, "error": str(e)}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# Singleton instance
|
||||
_scheduler_instance: Optional[SchedulerService] = None
|
||||
|
||||
|
||||
def get_scheduler() -> SchedulerService:
|
||||
"""
|
||||
Get the scheduler singleton instance.
|
||||
|
||||
Returns:
|
||||
SchedulerService instance
|
||||
"""
|
||||
global _scheduler_instance
|
||||
if _scheduler_instance is None:
|
||||
_scheduler_instance = SchedulerService()
|
||||
return _scheduler_instance
|
||||
|
||||
|
||||
async def start_scheduler():
|
||||
"""Start the global scheduler instance."""
|
||||
scheduler = get_scheduler()
|
||||
await scheduler.start()
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""Stop the global scheduler instance."""
|
||||
scheduler = get_scheduler()
|
||||
scheduler.stop()
|
||||
608
backend/services/slmm_client.py
Normal file
608
backend/services/slmm_client.py
Normal file
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
SLMM API Client Wrapper
|
||||
|
||||
Provides a clean interface for Terra-View to interact with the SLMM backend.
|
||||
All SLM operations should go through this client instead of direct HTTP calls.
|
||||
|
||||
SLMM (Sound Level Meter Manager) is a separate service running on port 8100
|
||||
that handles TCP/FTP communication with Rion NL-43/NL-53 devices.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any, List
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
|
||||
# SLMM backend base URLs
|
||||
SLMM_BASE_URL = "http://localhost:8100"
|
||||
SLMM_API_BASE = f"{SLMM_BASE_URL}/api/nl43"
|
||||
|
||||
|
||||
class SLMMClientError(Exception):
|
||||
"""Base exception for SLMM client errors."""
|
||||
pass
|
||||
|
||||
|
||||
class SLMMConnectionError(SLMMClientError):
|
||||
"""Raised when cannot connect to SLMM backend."""
|
||||
pass
|
||||
|
||||
|
||||
class SLMMDeviceError(SLMMClientError):
|
||||
"""Raised when device operation fails."""
|
||||
pass
|
||||
|
||||
|
||||
class SLMMClient:
|
||||
"""
|
||||
Client for interacting with SLMM backend.
|
||||
|
||||
Usage:
|
||||
client = SLMMClient()
|
||||
units = await client.get_all_units()
|
||||
status = await client.get_unit_status("nl43-001")
|
||||
await client.start_recording("nl43-001", config={...})
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = SLMM_BASE_URL, timeout: float = 30.0):
|
||||
self.base_url = base_url
|
||||
self.api_base = f"{base_url}/api/nl43"
|
||||
self.timeout = timeout
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
method: str,
|
||||
endpoint: str,
|
||||
data: Optional[Dict] = None,
|
||||
params: Optional[Dict] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Make an HTTP request to SLMM backend.
|
||||
|
||||
Args:
|
||||
method: HTTP method (GET, POST, PUT, DELETE)
|
||||
endpoint: API endpoint (e.g., "/units", "/{unit_id}/status")
|
||||
data: JSON body for POST/PUT requests
|
||||
params: Query parameters
|
||||
|
||||
Returns:
|
||||
Response JSON as dict
|
||||
|
||||
Raises:
|
||||
SLMMConnectionError: Cannot reach SLMM
|
||||
SLMMDeviceError: Device operation failed
|
||||
"""
|
||||
url = f"{self.api_base}{endpoint}"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=self.timeout) as client:
|
||||
response = await client.request(
|
||||
method=method,
|
||||
url=url,
|
||||
json=data,
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
# Handle empty responses
|
||||
if not response.content:
|
||||
return {}
|
||||
|
||||
return response.json()
|
||||
|
||||
except httpx.ConnectError as e:
|
||||
raise SLMMConnectionError(
|
||||
f"Cannot connect to SLMM backend at {self.base_url}. "
|
||||
f"Is SLMM running? Error: {str(e)}"
|
||||
)
|
||||
except httpx.HTTPStatusError as e:
|
||||
error_detail = "Unknown error"
|
||||
try:
|
||||
error_data = e.response.json()
|
||||
error_detail = error_data.get("detail", str(error_data))
|
||||
except:
|
||||
error_detail = e.response.text or str(e)
|
||||
|
||||
raise SLMMDeviceError(
|
||||
f"SLMM operation failed: {error_detail}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise SLMMClientError(f"Unexpected error: {str(e)}")
|
||||
|
||||
# ========================================================================
|
||||
# Unit Management
|
||||
# ========================================================================
|
||||
|
||||
async def get_all_units(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get all configured SLM units from SLMM.
|
||||
|
||||
Returns:
|
||||
List of unit dicts with id, config, and status
|
||||
"""
|
||||
# SLMM doesn't have a /units endpoint yet, so we'll need to add this
|
||||
# For now, return empty list or implement when SLMM endpoint is ready
|
||||
try:
|
||||
response = await self._request("GET", "/units")
|
||||
return response.get("units", [])
|
||||
except SLMMClientError:
|
||||
# Endpoint may not exist yet
|
||||
return []
|
||||
|
||||
async def get_unit_config(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get unit configuration from SLMM cache.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier (e.g., "nl43-001")
|
||||
|
||||
Returns:
|
||||
Config dict with host, tcp_port, ftp_port, etc.
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/config")
|
||||
|
||||
async def update_unit_config(
|
||||
self,
|
||||
unit_id: str,
|
||||
host: Optional[str] = None,
|
||||
tcp_port: Optional[int] = None,
|
||||
ftp_port: Optional[int] = None,
|
||||
ftp_username: Optional[str] = None,
|
||||
ftp_password: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Update unit configuration in SLMM cache.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
host: Device IP address
|
||||
tcp_port: TCP control port (default: 2255)
|
||||
ftp_port: FTP data port (default: 21)
|
||||
ftp_username: FTP username
|
||||
ftp_password: FTP password
|
||||
|
||||
Returns:
|
||||
Updated config
|
||||
"""
|
||||
config = {}
|
||||
if host is not None:
|
||||
config["host"] = host
|
||||
if tcp_port is not None:
|
||||
config["tcp_port"] = tcp_port
|
||||
if ftp_port is not None:
|
||||
config["ftp_port"] = ftp_port
|
||||
if ftp_username is not None:
|
||||
config["ftp_username"] = ftp_username
|
||||
if ftp_password is not None:
|
||||
config["ftp_password"] = ftp_password
|
||||
|
||||
return await self._request("PUT", f"/{unit_id}/config", data=config)
|
||||
|
||||
# ========================================================================
|
||||
# Status & Monitoring
|
||||
# ========================================================================
|
||||
|
||||
async def get_unit_status(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get cached status snapshot from SLMM.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Status dict with measurement_state, lp, leq, battery, etc.
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/status")
|
||||
|
||||
async def get_live_data(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Request fresh data from device (DOD command).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Live data snapshot
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/live")
|
||||
|
||||
# ========================================================================
|
||||
# Recording Control
|
||||
# ========================================================================
|
||||
|
||||
async def start_recording(
|
||||
self,
|
||||
unit_id: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Start recording on a unit.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
config: Optional recording config (interval, settings, etc.)
|
||||
|
||||
Returns:
|
||||
Response from SLMM with success status
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/start", data=config or {})
|
||||
|
||||
async def stop_recording(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Stop recording on a unit.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Response from SLMM
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/stop")
|
||||
|
||||
async def pause_recording(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Pause recording on a unit.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Response from SLMM
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/pause")
|
||||
|
||||
async def resume_recording(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Resume paused recording on a unit.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Response from SLMM
|
||||
"""
|
||||
return await self._request("POST", f"/{unit_id}/resume")
|
||||
|
||||
async def reset_data(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Reset measurement data on a unit.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Response from SLMM
|
||||
"""
|
||||
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
|
||||
# ========================================================================
|
||||
|
||||
async def get_frequency_weighting(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get frequency weighting setting (A, C, or Z).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with current weighting
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/frequency-weighting")
|
||||
|
||||
async def set_frequency_weighting(
|
||||
self,
|
||||
unit_id: str,
|
||||
weighting: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Set frequency weighting (A, C, or Z).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
weighting: "A", "C", or "Z"
|
||||
|
||||
Returns:
|
||||
Confirmation response
|
||||
"""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/{unit_id}/frequency-weighting",
|
||||
data={"weighting": weighting},
|
||||
)
|
||||
|
||||
async def get_time_weighting(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get time weighting setting (F, S, or I).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with current time weighting
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/time-weighting")
|
||||
|
||||
async def set_time_weighting(
|
||||
self,
|
||||
unit_id: str,
|
||||
weighting: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Set time weighting (F=Fast, S=Slow, I=Impulse).
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
weighting: "F", "S", or "I"
|
||||
|
||||
Returns:
|
||||
Confirmation response
|
||||
"""
|
||||
return await self._request(
|
||||
"PUT",
|
||||
f"/{unit_id}/time-weighting",
|
||||
data={"weighting": weighting},
|
||||
)
|
||||
|
||||
async def get_all_settings(self, unit_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Get all device settings.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
|
||||
Returns:
|
||||
Dict with all settings
|
||||
"""
|
||||
return await self._request("GET", f"/{unit_id}/settings")
|
||||
|
||||
# ========================================================================
|
||||
# Data Download (Future)
|
||||
# ========================================================================
|
||||
|
||||
async def download_files(
|
||||
self,
|
||||
unit_id: str,
|
||||
destination_path: str,
|
||||
files: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Download files from unit via FTP.
|
||||
|
||||
NOTE: This endpoint doesn't exist in SLMM yet. Will need to implement.
|
||||
|
||||
Args:
|
||||
unit_id: Unit identifier
|
||||
destination_path: Local path to save files
|
||||
files: List of filenames to download, or None for all
|
||||
|
||||
Returns:
|
||||
Dict with downloaded files list and metadata
|
||||
"""
|
||||
data = {
|
||||
"destination_path": destination_path,
|
||||
"files": files or "all",
|
||||
}
|
||||
return await self._request("POST", f"/{unit_id}/ftp/download", 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
|
||||
# ========================================================================
|
||||
|
||||
async def health_check(self) -> bool:
|
||||
"""
|
||||
Check if SLMM backend is reachable.
|
||||
|
||||
Returns:
|
||||
True if SLMM is responding, False otherwise
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{self.base_url}/health")
|
||||
return response.status_code == 200
|
||||
except:
|
||||
return False
|
||||
|
||||
|
||||
# Singleton instance for convenience
|
||||
_default_client: Optional[SLMMClient] = None
|
||||
|
||||
|
||||
def get_slmm_client() -> SLMMClient:
|
||||
"""
|
||||
Get the default SLMM client instance.
|
||||
|
||||
Returns:
|
||||
SLMMClient instance
|
||||
"""
|
||||
global _default_client
|
||||
if _default_client is None:
|
||||
_default_client = SLMMClient()
|
||||
return _default_client
|
||||
227
backend/services/slmm_sync.py
Normal file
227
backend/services/slmm_sync.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
SLMM Synchronization Service
|
||||
|
||||
This service ensures Terra-View roster is the single source of truth for SLM device configuration.
|
||||
When SLM devices are added, edited, or deleted in Terra-View, changes are automatically synced to SLMM.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import httpx
|
||||
import os
|
||||
from typing import Optional
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.models import RosterUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
async def sync_slm_to_slmm(unit: RosterUnit) -> bool:
|
||||
"""
|
||||
Sync a single SLM device from Terra-View roster to SLMM.
|
||||
|
||||
Args:
|
||||
unit: RosterUnit with device_type="slm"
|
||||
|
||||
Returns:
|
||||
True if sync successful, False otherwise
|
||||
"""
|
||||
if unit.device_type != "slm":
|
||||
logger.warning(f"Attempted to sync non-SLM unit {unit.id} to SLMM")
|
||||
return False
|
||||
|
||||
if not unit.slm_host:
|
||||
logger.warning(f"SLM {unit.id} has no host configured, skipping SLMM sync")
|
||||
return False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.put(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit.id}/config",
|
||||
json={
|
||||
"host": unit.slm_host,
|
||||
"tcp_port": unit.slm_tcp_port or 2255,
|
||||
"tcp_enabled": True,
|
||||
"ftp_enabled": True,
|
||||
"ftp_username": "USER", # Default NL43 credentials
|
||||
"ftp_password": "0000",
|
||||
"poll_enabled": not unit.retired, # Disable polling for retired units
|
||||
"poll_interval_seconds": 60, # Default interval
|
||||
}
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
logger.info(f"✓ Synced SLM {unit.id} to SLMM at {unit.slm_host}:{unit.slm_tcp_port or 2255}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to sync SLM {unit.id} to SLMM: {response.status_code} {response.text}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout syncing SLM {unit.id} to SLMM")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error syncing SLM {unit.id} to SLMM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def delete_slm_from_slmm(unit_id: str) -> bool:
|
||||
"""
|
||||
Delete a device from SLMM database.
|
||||
|
||||
Args:
|
||||
unit_id: The unit ID to delete
|
||||
|
||||
Returns:
|
||||
True if deletion successful or device doesn't exist, False on error
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.delete(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
logger.info(f"✓ Deleted SLM {unit_id} from SLMM")
|
||||
return True
|
||||
elif response.status_code == 404:
|
||||
logger.info(f"SLM {unit_id} not found in SLMM (already deleted)")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"Failed to delete SLM {unit_id} from SLMM: {response.status_code} {response.text}")
|
||||
return False
|
||||
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout deleting SLM {unit_id} from SLMM")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error deleting SLM {unit_id} from SLMM: {e}")
|
||||
return False
|
||||
|
||||
|
||||
async def sync_all_slms_to_slmm(db: Session) -> dict:
|
||||
"""
|
||||
Sync all SLM devices from Terra-View roster to SLMM.
|
||||
|
||||
This ensures SLMM database matches Terra-View roster as the source of truth.
|
||||
Should be called on Terra-View startup and optionally via admin endpoint.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with sync results
|
||||
"""
|
||||
logger.info("Starting full SLM sync to SLMM...")
|
||||
|
||||
# Get all SLM units from roster
|
||||
slm_units = db.query(RosterUnit).filter_by(device_type="slm").all()
|
||||
|
||||
results = {
|
||||
"total": len(slm_units),
|
||||
"synced": 0,
|
||||
"skipped": 0,
|
||||
"failed": 0
|
||||
}
|
||||
|
||||
for unit in slm_units:
|
||||
# Skip units without host configured
|
||||
if not unit.slm_host:
|
||||
results["skipped"] += 1
|
||||
logger.debug(f"Skipped {unit.unit_type} - no host configured")
|
||||
continue
|
||||
|
||||
# Sync to SLMM
|
||||
success = await sync_slm_to_slmm(unit)
|
||||
if success:
|
||||
results["synced"] += 1
|
||||
else:
|
||||
results["failed"] += 1
|
||||
|
||||
logger.info(
|
||||
f"SLM sync complete: {results['synced']} synced, "
|
||||
f"{results['skipped']} skipped, {results['failed']} failed"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_slmm_devices() -> Optional[list]:
|
||||
"""
|
||||
Get list of all devices currently in SLMM database.
|
||||
|
||||
Returns:
|
||||
List of device unit_ids, or None on error
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/_polling/status")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return [device["unit_id"] for device in data["data"]["devices"]]
|
||||
else:
|
||||
logger.error(f"Failed to get SLMM devices: {response.status_code}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting SLMM devices: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def cleanup_orphaned_slmm_devices(db: Session) -> dict:
|
||||
"""
|
||||
Remove devices from SLMM that are not in Terra-View roster.
|
||||
|
||||
This cleans up orphaned test devices or devices that were manually added to SLMM.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
Dictionary with cleanup results
|
||||
"""
|
||||
logger.info("Checking for orphaned devices in SLMM...")
|
||||
|
||||
# Get all device IDs from SLMM
|
||||
slmm_devices = await get_slmm_devices()
|
||||
if slmm_devices is None:
|
||||
return {"error": "Failed to get SLMM device list"}
|
||||
|
||||
# Get all SLM unit IDs from Terra-View roster
|
||||
roster_units = db.query(RosterUnit.id).filter_by(device_type="slm").all()
|
||||
roster_unit_ids = {unit.id for unit in roster_units}
|
||||
|
||||
# Find orphaned devices (in SLMM but not in roster)
|
||||
orphaned = [uid for uid in slmm_devices if uid not in roster_unit_ids]
|
||||
|
||||
results = {
|
||||
"total_in_slmm": len(slmm_devices),
|
||||
"total_in_roster": len(roster_unit_ids),
|
||||
"orphaned": len(orphaned),
|
||||
"deleted": 0,
|
||||
"failed": 0,
|
||||
"orphaned_devices": orphaned
|
||||
}
|
||||
|
||||
if not orphaned:
|
||||
logger.info("No orphaned devices found in SLMM")
|
||||
return results
|
||||
|
||||
logger.info(f"Found {len(orphaned)} orphaned devices in SLMM: {orphaned}")
|
||||
|
||||
# Delete orphaned devices
|
||||
for unit_id in orphaned:
|
||||
success = await delete_slm_from_slmm(unit_id)
|
||||
if success:
|
||||
results["deleted"] += 1
|
||||
else:
|
||||
results["failed"] += 1
|
||||
|
||||
logger.info(
|
||||
f"Cleanup complete: {results['deleted']} deleted, {results['failed']} failed"
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -24,13 +24,47 @@ def format_age(last_seen):
|
||||
return f"{int(hours)}h {int(mins)}m"
|
||||
|
||||
|
||||
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
||||
"""
|
||||
Calculate status based on how long ago the unit was last seen.
|
||||
|
||||
Args:
|
||||
last_seen: datetime of last seen (UTC)
|
||||
status_ok_threshold: hours before status becomes Pending (default 12)
|
||||
status_pending_threshold: hours before status becomes Missing (default 24)
|
||||
|
||||
Returns:
|
||||
"OK", "Pending", or "Missing"
|
||||
"""
|
||||
if not last_seen:
|
||||
return "Missing"
|
||||
|
||||
last_seen = ensure_utc(last_seen)
|
||||
now = datetime.now(timezone.utc)
|
||||
hours_ago = (now - last_seen).total_seconds() / 3600
|
||||
|
||||
if hours_ago > status_pending_threshold:
|
||||
return "Missing"
|
||||
elif hours_ago > status_ok_threshold:
|
||||
return "Pending"
|
||||
else:
|
||||
return "OK"
|
||||
|
||||
|
||||
def emit_status_snapshot():
|
||||
"""
|
||||
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||
Status is recalculated based on current time to ensure accuracy.
|
||||
"""
|
||||
|
||||
db = get_db_session()
|
||||
try:
|
||||
# Get user preferences for status thresholds
|
||||
from backend.models import UserPreferences
|
||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||
|
||||
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||
@@ -40,7 +74,6 @@ def emit_status_snapshot():
|
||||
# --- Merge roster entries first ---
|
||||
for unit_id, r in roster.items():
|
||||
e = emitters.get(unit_id)
|
||||
|
||||
if r.retired:
|
||||
# Retired units get separated later
|
||||
status = "Retired"
|
||||
@@ -49,8 +82,9 @@ def emit_status_snapshot():
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
status = e.status
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status based on current time, not stored value
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
age = format_age(last_seen)
|
||||
fname = e.last_file
|
||||
else:
|
||||
@@ -60,12 +94,12 @@ def emit_status_snapshot():
|
||||
age = "N/A"
|
||||
fname = ""
|
||||
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"fname": fname,
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"fname": fname,
|
||||
"deployed": r.deployed,
|
||||
"note": r.note or "",
|
||||
"retired": r.retired,
|
||||
@@ -76,20 +110,22 @@ def emit_status_snapshot():
|
||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||
"ip_address": r.ip_address,
|
||||
"phone_number": r.phone_number,
|
||||
"hardware_model": r.hardware_model,
|
||||
# Location for mapping
|
||||
"location": r.location or "",
|
||||
"address": r.address or "",
|
||||
"coordinates": r.coordinates or "",
|
||||
}
|
||||
"hardware_model": r.hardware_model,
|
||||
# Location for mapping
|
||||
"location": r.location or "",
|
||||
"address": r.address or "",
|
||||
"coordinates": r.coordinates or "",
|
||||
}
|
||||
|
||||
# --- Add unexpected emitter-only units ---
|
||||
for unit_id, e in emitters.items():
|
||||
if unit_id not in roster:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
# RECALCULATE status for unknown units too
|
||||
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": e.status,
|
||||
"status": status,
|
||||
"age": format_age(last_seen),
|
||||
"last": last_seen.isoformat(),
|
||||
"fname": e.last_file,
|
||||
|
||||
@@ -347,6 +347,3 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
console.error('Failed to initialize offline database:', error);
|
||||
}
|
||||
});
|
||||
|
||||
// Export for use in other scripts
|
||||
export default OfflineDB;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
services:
|
||||
|
||||
# --- PRODUCTION ---
|
||||
seismo-backend:
|
||||
# --- TERRA-VIEW PRODUCTION ---
|
||||
terra-view-prod:
|
||||
build: .
|
||||
container_name: seismo-fleet-manager
|
||||
container_name: terra-view
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
@@ -11,7 +11,12 @@ services:
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=production
|
||||
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
@@ -19,24 +24,48 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# --- DEVELOPMENT ---
|
||||
sfm-dev:
|
||||
build: .
|
||||
container_name: sfm-dev
|
||||
ports:
|
||||
- "1001:8001"
|
||||
# --- TERRA-VIEW DEVELOPMENT ---
|
||||
# terra-view-dev:
|
||||
# build: .
|
||||
# container_name: terra-view-dev
|
||||
# ports:
|
||||
# - "1001:8001"
|
||||
# volumes:
|
||||
# - ./data-dev:/app/data
|
||||
# environment:
|
||||
# - PYTHONUNBUFFERED=1
|
||||
# - ENVIRONMENT=development
|
||||
# - SLMM_BASE_URL=http://slmm:8100
|
||||
# restart: unless-stopped
|
||||
# depends_on:
|
||||
# - slmm
|
||||
# healthcheck:
|
||||
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
# interval: 30s
|
||||
# timeout: 10s
|
||||
# retries: 3
|
||||
# start_period: 40s
|
||||
|
||||
# --- SLMM (Sound Level Meter Manager) ---
|
||||
slmm:
|
||||
build:
|
||||
context: ../slmm
|
||||
dockerfile: Dockerfile
|
||||
container_name: slmm
|
||||
network_mode: host
|
||||
volumes:
|
||||
- ./data-dev:/app/data
|
||||
- ../slmm/data:/app/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=development
|
||||
- PORT=8100
|
||||
- CORS_ORIGINS=*
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
data:
|
||||
|
||||
276
docs/DEVICE_TYPE_DASHBOARDS.md
Normal file
276
docs/DEVICE_TYPE_DASHBOARDS.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Device Type Dashboards
|
||||
|
||||
This document describes the separate dashboard system for different device types in SFM.
|
||||
|
||||
## Overview
|
||||
|
||||
SFM now has dedicated dashboards for each device type:
|
||||
- **Main Dashboard** (`/`) - Combined overview of all devices
|
||||
- **Seismographs Dashboard** (`/seismographs`) - Seismograph-specific view
|
||||
- **Sound Level Meters Dashboard** (`/sound-level-meters`) - SLM-specific view
|
||||
- **Fleet Roster** (`/roster`) - All devices with filtering and management
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Main Dashboard
|
||||
|
||||
**Route**: `/`
|
||||
**Template**: [templates/dashboard.html](../templates/dashboard.html)
|
||||
**Purpose**: Combined overview showing all device types
|
||||
|
||||
**Features**:
|
||||
- Fleet summary card now includes device type breakdown
|
||||
- Shows count of seismographs and SLMs separately
|
||||
- Links to dedicated dashboards for each device type
|
||||
- Shared components: map, alerts, recent photos, fleet status
|
||||
|
||||
**Device Type Counts**:
|
||||
The dashboard calculates device type counts in JavaScript from the snapshot data:
|
||||
|
||||
```javascript
|
||||
// Device type counts
|
||||
let seismoCount = 0;
|
||||
let slmCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.retired) return; // Don't count retired units
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Seismographs Dashboard
|
||||
|
||||
**Route**: `/seismographs`
|
||||
**Template**: [templates/seismographs.html](../templates/seismographs.html)
|
||||
**Router**: [backend/routers/seismo_dashboard.py](../backend/routers/seismo_dashboard.py)
|
||||
|
||||
**API Endpoints**:
|
||||
- `GET /api/seismo-dashboard/stats` - Statistics summary (HTML partial)
|
||||
- `GET /api/seismo-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
||||
|
||||
**Features**:
|
||||
- Statistics cards (total, deployed, benched, with/without modem)
|
||||
- Searchable unit list with real-time filtering
|
||||
- Shows modem assignments
|
||||
- Links to individual unit detail pages
|
||||
|
||||
**Stats Calculation** ([backend/routers/seismo_dashboard.py:18](../backend/routers/seismo_dashboard.py#L18)):
|
||||
|
||||
```python
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
).all()
|
||||
|
||||
total = len(seismos)
|
||||
deployed = sum(1 for s in seismos if s.deployed)
|
||||
benched = sum(1 for s in seismos if not s.deployed)
|
||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||
```
|
||||
|
||||
### 3. Sound Level Meters Dashboard
|
||||
|
||||
**Route**: `/sound-level-meters`
|
||||
**Template**: [templates/sound_level_meters.html](../templates/sound_level_meters.html)
|
||||
**Router**: [backend/routers/slm_dashboard.py](../backend/routers/slm_dashboard.py)
|
||||
|
||||
**API Endpoints**:
|
||||
- `GET /api/slm-dashboard/stats` - Statistics summary (HTML partial)
|
||||
- `GET /api/slm-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
||||
- `GET /api/slm-dashboard/live-view/{unit_id}` - Live view panel (HTML partial)
|
||||
|
||||
**Features**:
|
||||
- Statistics cards (total, deployed, benched, measuring)
|
||||
- Searchable unit list
|
||||
- Live view panel with real-time measurement data
|
||||
- WebSocket integration for DRD streaming
|
||||
- Shows modem assignments and IP resolution
|
||||
|
||||
See [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) for detailed SLM dashboard documentation.
|
||||
|
||||
## Navigation
|
||||
|
||||
The sidebar navigation ([templates/base.html:116-128](../templates/base.html#L116-L128)) includes links to both dashboards:
|
||||
|
||||
```html
|
||||
<a href="/seismographs">
|
||||
<svg>...</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters">
|
||||
<svg>...</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
```
|
||||
|
||||
Active page highlighting is automatic based on `request.url.path`.
|
||||
|
||||
## Database Queries
|
||||
|
||||
All dashboards filter by device type using SQLAlchemy:
|
||||
|
||||
### Seismographs Query
|
||||
```python
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
).all()
|
||||
```
|
||||
|
||||
### Sound Level Meters Query
|
||||
```python
|
||||
slms = db.query(RosterUnit).filter_by(
|
||||
device_type="slm",
|
||||
retired=False
|
||||
).all()
|
||||
```
|
||||
|
||||
### With Search Filter
|
||||
```python
|
||||
query = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||
(RosterUnit.address.ilike(f"%{search}%"))
|
||||
)
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Stats Cards (Partials)
|
||||
|
||||
**Seismograph Stats**: [templates/partials/seismo_stats.html](../templates/partials/seismo_stats.html)
|
||||
- Total Seismographs
|
||||
- Deployed
|
||||
- Benched
|
||||
- With Modem (showing X / Y deployed)
|
||||
|
||||
**SLM Stats**: [templates/partials/slm_stats.html](../templates/partials/slm_stats.html)
|
||||
- Total SLMs
|
||||
- Deployed
|
||||
- Benched
|
||||
- Currently Measuring (from live status)
|
||||
|
||||
### Unit Lists (Partials)
|
||||
|
||||
**Seismograph List**: [templates/partials/seismo_unit_list.html](../templates/partials/seismo_unit_list.html)
|
||||
|
||||
Table columns:
|
||||
- Unit ID (link to detail page)
|
||||
- Status (Deployed/Benched badge)
|
||||
- Modem (link to modem unit)
|
||||
- Location (address or coordinates)
|
||||
- Notes
|
||||
- Actions (View Details link)
|
||||
|
||||
**SLM List**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
||||
|
||||
Table columns:
|
||||
- Unit ID (link to detail page)
|
||||
- Model (NL-43, NL-53)
|
||||
- Status (Deployed/Benched badge)
|
||||
- Modem (link to modem unit)
|
||||
- Location
|
||||
- Actions (View Details, Live View)
|
||||
|
||||
## HTMX Integration
|
||||
|
||||
Both dashboards use HTMX for dynamic updates:
|
||||
|
||||
### Auto-refresh Stats
|
||||
```html
|
||||
<div hx-get="/api/seismo-dashboard/stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
```
|
||||
|
||||
### Search with Debouncing
|
||||
```html
|
||||
<input type="text"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#seismo-units-list"
|
||||
name="search" />
|
||||
```
|
||||
|
||||
## Adding New Device Types
|
||||
|
||||
To add support for a new device type (e.g., "modem"):
|
||||
|
||||
1. **Create Router** (`backend/routers/modem_dashboard.py`):
|
||||
```python
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_modem_stats(request: Request, db: Session = Depends(get_db)):
|
||||
modems = db.query(RosterUnit).filter_by(
|
||||
device_type="modem",
|
||||
retired=False
|
||||
).all()
|
||||
# Calculate stats and return partial
|
||||
```
|
||||
|
||||
2. **Create Templates**:
|
||||
- `templates/modems.html` - Main dashboard page
|
||||
- `templates/partials/modem_stats.html` - Stats cards
|
||||
- `templates/partials/modem_unit_list.html` - Unit list table
|
||||
|
||||
3. **Register in main.py**:
|
||||
```python
|
||||
from backend.routers import modem_dashboard
|
||||
app.include_router(modem_dashboard.router)
|
||||
|
||||
@app.get("/modems", response_class=HTMLResponse)
|
||||
async def modems_page(request: Request):
|
||||
return templates.TemplateResponse("modems.html", {"request": request})
|
||||
```
|
||||
|
||||
4. **Add to Navigation** (`templates/base.html`):
|
||||
```html
|
||||
<a href="/modems">
|
||||
<svg>...</svg>
|
||||
Modems
|
||||
</a>
|
||||
```
|
||||
|
||||
5. **Update Main Dashboard** (`templates/dashboard.html`):
|
||||
Add modem count to the device type breakdown:
|
||||
```html
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/modems">Modems</a>
|
||||
<span id="modem-count">--</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Update JavaScript to count modems:
|
||||
```javascript
|
||||
let modemCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.device_type === 'modem' && !unit.retired) {
|
||||
modemCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('modem-count').textContent = modemCount;
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Separation of Concerns**: Each device type has its own dedicated interface
|
||||
2. **Scalability**: Easy to add new device types following the established pattern
|
||||
3. **Performance**: Queries are filtered by device type, reducing data transfer
|
||||
4. **User Experience**: Users can focus on specific device types without clutter
|
||||
5. **Maintainability**: Each dashboard is self-contained and easy to modify
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard details
|
||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Adding SLM device type support
|
||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
||||
288
docs/DEVICE_TYPE_SCHEMA.md
Normal file
288
docs/DEVICE_TYPE_SCHEMA.md
Normal file
@@ -0,0 +1,288 @@
|
||||
# Device Type Schema - Terra-View
|
||||
|
||||
## Overview
|
||||
|
||||
Terra-View uses a single roster table to manage three different device types. The `device_type` field is the primary discriminator that determines which fields are relevant for each unit.
|
||||
|
||||
## Official device_type Values
|
||||
|
||||
As of **Terra-View v0.4.3**, the following device_type values are standardized:
|
||||
|
||||
### 1. `"seismograph"` (Default)
|
||||
**Purpose**: Seismic monitoring devices
|
||||
|
||||
**Applicable Fields**:
|
||||
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
|
||||
- Specific: last_calibrated, next_calibration_due, deployed_with_modem_id
|
||||
|
||||
**Examples**:
|
||||
- `BE1234` - Series 3 seismograph
|
||||
- `UM12345` - Series 4 Micromate unit
|
||||
- `SEISMO-001` - Custom seismograph
|
||||
|
||||
**Unit Type Values**:
|
||||
- `series3` - Series 3 devices (default)
|
||||
- `series4` - Series 4 devices
|
||||
- `micromate` - Micromate devices
|
||||
|
||||
---
|
||||
|
||||
### 2. `"modem"`
|
||||
**Purpose**: Field modems and network equipment
|
||||
|
||||
**Applicable Fields**:
|
||||
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
|
||||
- Specific: ip_address, phone_number, hardware_model
|
||||
|
||||
**Examples**:
|
||||
- `MDM001` - Field modem
|
||||
- `MODEM-2025-01` - Network modem
|
||||
- `RAVEN-XTV-01` - Specific modem model
|
||||
|
||||
**Unit Type Values**:
|
||||
- `modem` - Generic modem
|
||||
- `raven-xtv` - Raven XTV model
|
||||
- Custom values for specific hardware
|
||||
|
||||
---
|
||||
|
||||
### 3. `"slm"` ⭐
|
||||
**Purpose**: Sound level meters (Rion NL-43/NL-53)
|
||||
|
||||
**Applicable Fields**:
|
||||
- Common: id, unit_type, deployed, retired, note, project_id, location, address, coordinates
|
||||
- Specific: slm_host, slm_tcp_port, slm_ftp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check, deployed_with_modem_id
|
||||
|
||||
**Examples**:
|
||||
- `SLM-43-01` - NL-43 sound level meter
|
||||
- `NL43-001` - NL-43 unit
|
||||
- `NL53-002` - NL-53 unit
|
||||
|
||||
**Unit Type Values**:
|
||||
- `nl43` - Rion NL-43 model
|
||||
- `nl53` - Rion NL-53 model
|
||||
|
||||
---
|
||||
|
||||
## Migration from Legacy Values
|
||||
|
||||
### Deprecated Values
|
||||
|
||||
The following device_type values have been **deprecated** and should be migrated:
|
||||
|
||||
- ❌ `"sound_level_meter"` → ✅ `"slm"`
|
||||
|
||||
### How to Migrate
|
||||
|
||||
Run the standardization migration script to update existing databases:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/tmi/terra-view
|
||||
python3 backend/migrate_standardize_device_types.py
|
||||
```
|
||||
|
||||
This script:
|
||||
- Converts all `"sound_level_meter"` values to `"slm"`
|
||||
- Is idempotent (safe to run multiple times)
|
||||
- Shows before/after distribution of device types
|
||||
- No data loss
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### RosterUnit Model (`backend/models.py`)
|
||||
|
||||
```python
|
||||
class RosterUnit(Base):
|
||||
"""
|
||||
Supports multiple device types:
|
||||
- "seismograph" - Seismic monitoring devices (default)
|
||||
- "modem" - Field modems and network equipment
|
||||
- "slm" - Sound level meters (NL-43/NL-53)
|
||||
"""
|
||||
__tablename__ = "roster"
|
||||
|
||||
# Core fields (all device types)
|
||||
id = Column(String, primary_key=True)
|
||||
unit_type = Column(String, default="series3")
|
||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
# ... other common fields
|
||||
|
||||
# Seismograph-specific
|
||||
last_calibrated = Column(Date, nullable=True)
|
||||
next_calibration_due = Column(Date, nullable=True)
|
||||
|
||||
# Modem-specific
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
|
||||
# SLM-specific
|
||||
slm_host = Column(String, nullable=True)
|
||||
slm_tcp_port = Column(Integer, nullable=True)
|
||||
slm_ftp_port = Column(Integer, nullable=True)
|
||||
slm_model = Column(String, nullable=True)
|
||||
slm_serial_number = Column(String, nullable=True)
|
||||
slm_frequency_weighting = Column(String, nullable=True)
|
||||
slm_time_weighting = Column(String, nullable=True)
|
||||
slm_measurement_range = Column(String, nullable=True)
|
||||
slm_last_check = Column(DateTime, nullable=True)
|
||||
|
||||
# Shared fields (seismograph + SLM)
|
||||
deployed_with_modem_id = Column(String, nullable=True) # FK to modem
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API Usage
|
||||
|
||||
### Adding a New Unit
|
||||
|
||||
**Seismograph**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/add \
|
||||
-F "id=BE1234" \
|
||||
-F "device_type=seismograph" \
|
||||
-F "unit_type=series3" \
|
||||
-F "deployed=true"
|
||||
```
|
||||
|
||||
**Modem**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/add \
|
||||
-F "id=MDM001" \
|
||||
-F "device_type=modem" \
|
||||
-F "ip_address=192.0.2.10" \
|
||||
-F "phone_number=+1-555-0100"
|
||||
```
|
||||
|
||||
**Sound Level Meter**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/add \
|
||||
-F "id=SLM-43-01" \
|
||||
-F "device_type=slm" \
|
||||
-F "slm_host=63.45.161.30" \
|
||||
-F "slm_tcp_port=2255" \
|
||||
-F "slm_model=NL-43"
|
||||
```
|
||||
|
||||
### CSV Import Format
|
||||
|
||||
```csv
|
||||
unit_id,unit_type,device_type,deployed,slm_host,slm_tcp_port,slm_model
|
||||
SLM-43-01,nl43,slm,true,63.45.161.30,2255,NL-43
|
||||
SLM-43-02,nl43,slm,true,63.45.161.31,2255,NL-43
|
||||
BE1234,series3,seismograph,true,,,
|
||||
MDM001,modem,modem,true,,,
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Frontend Behavior
|
||||
|
||||
### Device Type Selection
|
||||
|
||||
**Templates**: `unit_detail.html`, `roster.html`
|
||||
|
||||
```html
|
||||
<select name="device_type">
|
||||
<option value="seismograph">Seismograph</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="slm">Sound Level Meter</option>
|
||||
</select>
|
||||
```
|
||||
|
||||
### Conditional Field Display
|
||||
|
||||
JavaScript functions check `device_type` to show/hide relevant fields:
|
||||
|
||||
```javascript
|
||||
function toggleDetailFields() {
|
||||
const deviceType = document.getElementById('device_type').value;
|
||||
|
||||
if (deviceType === 'seismograph') {
|
||||
// Show calibration fields
|
||||
} else if (deviceType === 'modem') {
|
||||
// Show network fields
|
||||
} else if (deviceType === 'slm') {
|
||||
// Show SLM configuration fields
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Code Conventions
|
||||
|
||||
### Always Use Lowercase
|
||||
|
||||
✅ **Correct**:
|
||||
```python
|
||||
if unit.device_type == "slm":
|
||||
# Handle sound level meter
|
||||
```
|
||||
|
||||
❌ **Incorrect**:
|
||||
```python
|
||||
if unit.device_type == "SLM": # Wrong - case sensitive
|
||||
if unit.device_type == "sound_level_meter": # Deprecated
|
||||
```
|
||||
|
||||
### Query Patterns
|
||||
|
||||
**Filter by device type**:
|
||||
```python
|
||||
# Get all SLMs
|
||||
slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
||||
|
||||
# Get deployed seismographs
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
deployed=True
|
||||
).all()
|
||||
|
||||
# Get all modems
|
||||
modems = db.query(RosterUnit).filter_by(device_type="modem").all()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
### Verify Device Type Distribution
|
||||
|
||||
```bash
|
||||
# Quick check
|
||||
sqlite3 data/seismo_fleet.db "SELECT device_type, COUNT(*) FROM roster GROUP BY device_type;"
|
||||
|
||||
# Detailed view
|
||||
sqlite3 data/seismo_fleet.db "SELECT id, device_type, unit_type, deployed FROM roster ORDER BY device_type, id;"
|
||||
```
|
||||
|
||||
### Check for Legacy Values
|
||||
|
||||
```bash
|
||||
# Should return 0 rows after migration
|
||||
sqlite3 data/seismo_fleet.db "SELECT id FROM roster WHERE device_type = 'sound_level_meter';"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Version History
|
||||
|
||||
- **v0.4.3** (2026-01-16) - Standardized device_type values, deprecated `"sound_level_meter"` → `"slm"`
|
||||
- **v0.4.0** (2026-01-05) - Added SLM support with `"sound_level_meter"` value
|
||||
- **v0.2.0** (2025-12-03) - Added modem device type
|
||||
- **v0.1.0** (2024-11-20) - Initial release with seismograph-only support
|
||||
|
||||
---
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [README.md](../README.md) - Main project documentation with data model
|
||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Legacy SLM implementation notes
|
||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard features
|
||||
- [SLM_CONFIGURATION.md](SLM_CONFIGURATION.md) - SLM device configuration guide
|
||||
161
docs/DEVICE_TYPE_SLM_SUPPORT.md
Normal file
161
docs/DEVICE_TYPE_SLM_SUPPORT.md
Normal file
@@ -0,0 +1,161 @@
|
||||
# Sound Level Meter Device Type Support
|
||||
|
||||
**⚠️ IMPORTANT**: This documentation uses the legacy `sound_level_meter` device type value. As of v0.4.3, the standardized value is `"slm"`. Run `backend/migrate_standardize_device_types.py` to update your database.
|
||||
|
||||
## Overview
|
||||
|
||||
Added full support for "Sound Level Meter" as a device type in the roster management system. Users can now create, edit, and manage SLM units through the Fleet Roster interface.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Frontend - Unit Detail Page
|
||||
|
||||
**File**: `templates/unit_detail.html`
|
||||
|
||||
#### Added Device Type Option
|
||||
- Added "Sound Level Meter" option to device type dropdown (line 243)
|
||||
- Value: `sound_level_meter`
|
||||
|
||||
#### Added SLM-Specific Fields Section (lines 320-370)
|
||||
New form fields for Sound Level Meter configuration:
|
||||
|
||||
- **Host (IP Address)**: Device network address
|
||||
- Field name: `slm_host`
|
||||
- Example: `192.168.1.100`
|
||||
|
||||
- **TCP Port**: Control port (default 2255)
|
||||
- Field name: `slm_tcp_port`
|
||||
- Type: number
|
||||
|
||||
- **Model**: Device model designation
|
||||
- Field name: `slm_model`
|
||||
- Example: `NL-43`, `NL-53`
|
||||
|
||||
- **Serial Number**: Manufacturer serial number
|
||||
- Field name: `slm_serial_number`
|
||||
|
||||
- **Frequency Weighting**: Sound measurement weighting curve
|
||||
- Field name: `slm_frequency_weighting`
|
||||
- Options: A-weighting, C-weighting, Z-weighting (Flat)
|
||||
|
||||
- **Time Weighting**: Temporal averaging method
|
||||
- Field name: `slm_time_weighting`
|
||||
- Options: Fast (125ms), Slow (1s), Impulse (35ms)
|
||||
|
||||
- **Measurement Range**: Device measurement capability
|
||||
- Field name: `slm_measurement_range`
|
||||
- Example: `30-130 dB`
|
||||
|
||||
#### Updated JavaScript Functions
|
||||
|
||||
**toggleDetailFields()** (lines 552-571)
|
||||
- Now handles three device types: seismograph, modem, sound_level_meter
|
||||
- Hides all device-specific sections, then shows only the relevant one
|
||||
- Shows `slmFields` section when device type is `sound_level_meter`
|
||||
|
||||
**populateEditForm()** (lines 527-558)
|
||||
- Populates all 7 SLM-specific fields from unit data
|
||||
- Sets empty string as default if field is null
|
||||
|
||||
### 2. Backend - API Endpoints
|
||||
|
||||
**File**: `backend/routers/roster_edit.py`
|
||||
|
||||
#### Updated Add Unit Endpoint
|
||||
`POST /api/roster/add`
|
||||
|
||||
**New Parameters** (lines 61-67):
|
||||
- `slm_host`: str (optional)
|
||||
- `slm_tcp_port`: int (optional)
|
||||
- `slm_model`: str (optional)
|
||||
- `slm_serial_number`: str (optional)
|
||||
- `slm_frequency_weighting`: str (optional)
|
||||
- `slm_time_weighting`: str (optional)
|
||||
- `slm_measurement_range`: str (optional)
|
||||
|
||||
**Unit Creation** (lines 108-115):
|
||||
All SLM fields are set when creating new unit.
|
||||
|
||||
#### Updated Get Unit Endpoint
|
||||
`GET /api/roster/{unit_id}`
|
||||
|
||||
**New Response Fields** (lines 146-152):
|
||||
Returns all 7 SLM fields in the response, with empty string as default if null.
|
||||
|
||||
#### Updated Edit Unit Endpoint
|
||||
`POST /api/roster/edit/{unit_id}`
|
||||
|
||||
**New Parameters** (lines 177-183):
|
||||
Same 7 SLM-specific parameters as add endpoint.
|
||||
|
||||
**Unit Update** (lines 232-239):
|
||||
All SLM fields are updated when editing existing unit.
|
||||
|
||||
### 3. Database Schema
|
||||
|
||||
**File**: `backend/models.py`
|
||||
|
||||
The database schema already included SLM fields (no changes needed):
|
||||
- All fields are nullable to support multiple device types
|
||||
- Fields are only relevant when `device_type = "slm"`
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a New SLM Unit
|
||||
|
||||
1. Go to Fleet Roster page
|
||||
2. Click "Add Unit" or edit an existing unit
|
||||
3. Select "Sound Level Meter" from Device Type dropdown
|
||||
4. Fill in SLM-specific fields (Host, Port, Model, etc.)
|
||||
5. Save
|
||||
|
||||
### Converting Existing Unit to SLM
|
||||
|
||||
1. Open unit detail page
|
||||
2. Click "Edit Unit"
|
||||
3. Change Device Type to "Sound Level Meter"
|
||||
4. SLM fields section will appear
|
||||
5. Fill in required SLM configuration
|
||||
6. Save changes
|
||||
|
||||
### Field Visibility
|
||||
|
||||
The form automatically shows/hides relevant fields based on device type:
|
||||
- **Seismograph**: Shows calibration dates, modem deployment info
|
||||
- **Modem**: Shows IP, phone number, hardware model
|
||||
- **Sound Level Meter**: Shows host, port, model, serial, weightings, range
|
||||
|
||||
## Integration with SLMM Dashboard
|
||||
|
||||
Units with `device_type = "slm"` will:
|
||||
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
|
||||
- Be available for live monitoring and control
|
||||
- Use the configured `slm_host` and `slm_tcp_port` for device communication
|
||||
|
||||
## Testing
|
||||
|
||||
Test SLM units have been added via `add_test_slms.py`:
|
||||
- `nl43-001` - Deployed at construction site A
|
||||
- `nl43-002` - Deployed at construction site B
|
||||
- `nl53-001` - Deployed at residential area
|
||||
- `nl43-003` - Benched for calibration
|
||||
|
||||
You can edit any of these units to verify the form works correctly.
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `templates/unit_detail.html` - Added dropdown option, SLM fields section, updated JavaScript
|
||||
2. `backend/routers/roster_edit.py` - Added SLM parameters to add/edit/get endpoints
|
||||
3. `backend/models.py` - No changes (schema already supported SLM)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing seismograph and modem units are unaffected
|
||||
- All SLM fields are optional/nullable
|
||||
- Forms gracefully handle units with missing device_type (defaults to seismograph)
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Date**: January 5, 2026
|
||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md
|
||||
132
docs/DEV_DATABASE_SETUP.md
Normal file
132
docs/DEV_DATABASE_SETUP.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# DEV Database Setup Instructions
|
||||
|
||||
## Current Situation
|
||||
|
||||
The test SLM and modem data was accidentally added to the **PRODUCTION** database (`data/seismo_fleet.db`).
|
||||
|
||||
**Good news**: I've already removed it! The production database is clean.
|
||||
|
||||
**Issue**: The DEV database (`data-dev/seismo_fleet.db`) is:
|
||||
1. Owned by root (read-only for your user)
|
||||
2. Missing the SLM-specific columns in its schema
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Step 1: Fix DEV Database Permissions
|
||||
|
||||
Run this command to make the DEV database writable:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
sudo chown serversdown:serversdown data-dev/seismo_fleet.db
|
||||
sudo chmod 664 data-dev/seismo_fleet.db
|
||||
```
|
||||
|
||||
### Step 2: Migrate DEV Database Schema
|
||||
|
||||
Add the SLM columns to the DEV database:
|
||||
|
||||
```bash
|
||||
python3 scripts/migrate_dev_db.py
|
||||
```
|
||||
|
||||
This will add these columns to the `roster` table:
|
||||
- `slm_host`
|
||||
- `slm_tcp_port`
|
||||
- `slm_model`
|
||||
- `slm_serial_number`
|
||||
- `slm_frequency_weighting`
|
||||
- `slm_time_weighting`
|
||||
- `slm_measurement_range`
|
||||
- `slm_last_check`
|
||||
|
||||
### Step 3: Add Test Data to DEV
|
||||
|
||||
Now you can safely add test data to the DEV database:
|
||||
|
||||
```bash
|
||||
# Add test SLMs
|
||||
python3 scripts/add_test_slms.py
|
||||
|
||||
# Add test modems and assign to SLMs
|
||||
python3 scripts/add_test_modems.py
|
||||
```
|
||||
|
||||
This will create:
|
||||
- 4 test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
||||
- 4 test modem units (modem-001, modem-002, modem-003, modem-004)
|
||||
- Assign modems to the SLMs
|
||||
|
||||
## Production Database Status
|
||||
|
||||
✅ **Production database is CLEAN** - all test data has been removed.
|
||||
|
||||
The production database (`data/seismo_fleet.db`) is ready for real production use.
|
||||
|
||||
## Test Scripts
|
||||
|
||||
All test scripts have been updated to use the DEV database:
|
||||
|
||||
### Scripts Updated:
|
||||
1. `scripts/add_test_slms.py` - Now uses `data-dev/seismo_fleet.db`
|
||||
2. `scripts/add_test_modems.py` - Now uses `data-dev/seismo_fleet.db`
|
||||
|
||||
### Scripts Created:
|
||||
1. `scripts/remove_test_data_from_prod.py` - Removes test data from production (already run)
|
||||
2. `scripts/update_dev_db_schema.py` - Updates schema (doesn't work for SQLite ALTER)
|
||||
3. `scripts/migrate_dev_db.py` - Adds SLM columns to DEV database
|
||||
|
||||
All helper scripts are located in the `scripts/` directory. See [scripts/README.md](../scripts/README.md) for detailed usage instructions.
|
||||
|
||||
## Verification
|
||||
|
||||
After running the steps above, verify everything worked:
|
||||
|
||||
```bash
|
||||
# Check DEV database has test data
|
||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
nl43-001|sound_level_meter
|
||||
nl43-002|sound_level_meter
|
||||
nl53-001|sound_level_meter
|
||||
nl43-003|sound_level_meter
|
||||
modem-001|modem
|
||||
modem-002|modem
|
||||
modem-003|modem
|
||||
modem-004|modem
|
||||
```
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### When to Use DEV Database
|
||||
|
||||
To use the DEV database, set the environment variable:
|
||||
|
||||
```bash
|
||||
# Not implemented yet, but you could add this to database.py:
|
||||
export DATABASE_ENV=dev
|
||||
```
|
||||
|
||||
Or modify `backend/database.py` to check for an environment variable.
|
||||
|
||||
### Current Setup
|
||||
|
||||
Right now, the application always uses `data/seismo_fleet.db` (production).
|
||||
|
||||
For development/testing, you could:
|
||||
1. Point SFM to use the DEV database temporarily
|
||||
2. Or keep test data in production (not recommended)
|
||||
3. Or implement environment-based database selection
|
||||
|
||||
## Summary
|
||||
|
||||
- ✅ Production DB cleaned (no test data)
|
||||
- ⚠️ DEV DB needs permission fix (run sudo commands above)
|
||||
- ⚠️ DEV DB needs schema migration (run scripts/migrate_dev_db.py)
|
||||
- ✅ Test scripts updated to use DEV DB
|
||||
- ✅ All test data ready to be added to DEV DB
|
||||
|
||||
Run the commands above and you'll be all set!
|
||||
62
docs/FIX_DEV_PERMISSIONS.md
Normal file
62
docs/FIX_DEV_PERMISSIONS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Fix DEV Database Permissions
|
||||
|
||||
## The Problem
|
||||
|
||||
SQLite needs write access to both the database file AND the directory it's in (to create temporary files like journals and WAL files).
|
||||
|
||||
Currently:
|
||||
- ✅ Database file: `data-dev/seismo_fleet.db` - permissions fixed
|
||||
- ❌ Directory: `data-dev/` - still owned by root
|
||||
|
||||
## The Fix
|
||||
|
||||
Run this command to fix the directory ownership:
|
||||
|
||||
```bash
|
||||
sudo chown -R serversdown:serversdown /home/serversdown/sfm/seismo-fleet-manager/data-dev/
|
||||
```
|
||||
|
||||
Then run the migration again:
|
||||
|
||||
```bash
|
||||
python3 scripts/migrate_dev_db.py
|
||||
```
|
||||
|
||||
## Full Setup Commands
|
||||
|
||||
Here's the complete sequence:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
|
||||
# Fix directory ownership (includes all files inside)
|
||||
sudo chown -R serversdown:serversdown data-dev/
|
||||
|
||||
# Migrate schema
|
||||
python3 scripts/migrate_dev_db.py
|
||||
|
||||
# Add test data
|
||||
python3 scripts/add_test_slms.py
|
||||
python3 scripts/add_test_modems.py
|
||||
```
|
||||
|
||||
## Verify It Worked
|
||||
|
||||
After running the migration, you should see:
|
||||
|
||||
```
|
||||
Migrating DEV database to add SLM columns...
|
||||
============================================================
|
||||
✓ Added column: slm_host
|
||||
✓ Added column: slm_tcp_port
|
||||
✓ Added column: slm_model
|
||||
✓ Added column: slm_serial_number
|
||||
✓ Added column: slm_frequency_weighting
|
||||
✓ Added column: slm_time_weighting
|
||||
✓ Added column: slm_measurement_range
|
||||
✓ Added column: slm_last_check
|
||||
============================================================
|
||||
DEV database migration completed!
|
||||
```
|
||||
|
||||
Then the test data scripts should work without errors!
|
||||
375
docs/MODEM_INTEGRATION.md
Normal file
375
docs/MODEM_INTEGRATION.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Modem Integration System
|
||||
|
||||
## Overview
|
||||
|
||||
The modem integration system allows Sound Level Meters (SLMs) and Seismographs to be deployed with network connectivity modems. Instead of storing IP addresses directly on each device, units are assigned to modems which provide the network connection. This enables:
|
||||
|
||||
- Centralized modem management and tracking
|
||||
- IP address updates in one place
|
||||
- Future modem API integration for diagnostics
|
||||
- Proper asset tracking for network equipment
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Design
|
||||
|
||||
**Modem Units**: Stored as `RosterUnit` with `device_type = "modem"`
|
||||
|
||||
**Modem-Specific Fields**:
|
||||
- `ip_address`: Network IP address or hostname
|
||||
- `phone_number`: Cellular phone number (if applicable)
|
||||
- `hardware_model`: Modem hardware (e.g., "Raven XTV", "Sierra Wireless AirLink")
|
||||
|
||||
**Device Assignment**:
|
||||
- Both SLMs and Seismographs use `deployed_with_modem_id` to reference their modem
|
||||
- This is a foreign key to another `RosterUnit.id` where `device_type = "modem"`
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Create Modem Units**
|
||||
- Add modems as regular roster units with `device_type = "modem"`
|
||||
- Set IP address, phone number, and hardware model
|
||||
- Deploy or bench modems like any other asset
|
||||
|
||||
2. **Assign Devices to Modems**
|
||||
- When editing an SLM or Seismograph, select modem from dropdown
|
||||
- The `deployed_with_modem_id` field stores the modem ID
|
||||
- IP address is fetched from the assigned modem at runtime
|
||||
|
||||
3. **Runtime Resolution**
|
||||
- When SLM dashboard needs to connect to a device:
|
||||
1. Load SLM unit data
|
||||
2. Check `deployed_with_modem_id`
|
||||
3. Fetch modem unit
|
||||
4. Use modem's `ip_address` for connection
|
||||
5. Fallback to legacy `slm_host` if no modem assigned
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
#### Unit Detail Page (`templates/unit_detail.html`)
|
||||
|
||||
**SLM Fields** (lines 320-375):
|
||||
- Removed direct "Host (IP Address)" field
|
||||
- Added "Deployed With Modem" dropdown selector
|
||||
- Dropdown populated with all active modems via JavaScript
|
||||
- Shows modem ID, IP address, and hardware model in dropdown
|
||||
|
||||
**JavaScript** (lines 456-485):
|
||||
```javascript
|
||||
async function loadModemsList() {
|
||||
// Fetches all modems from /api/roster/modems
|
||||
// Populates both seismograph and SLM modem dropdowns
|
||||
// Shows format: "modem-001 (192.168.1.100) - Raven XTV"
|
||||
}
|
||||
```
|
||||
|
||||
#### SLM Dashboard (`backend/routers/slm_dashboard.py`)
|
||||
|
||||
**Live View Endpoint** (lines 84-148):
|
||||
```python
|
||||
# Get modem information if assigned
|
||||
modem = None
|
||||
modem_ip = None
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=unit.deployed_with_modem_id,
|
||||
device_type="modem"
|
||||
).first()
|
||||
if modem:
|
||||
modem_ip = modem.ip_address
|
||||
|
||||
# Fallback to direct slm_host (backward compatibility)
|
||||
if not modem_ip and unit.slm_host:
|
||||
modem_ip = unit.slm_host
|
||||
```
|
||||
|
||||
**Live View Template** (`templates/partials/slm_live_view.html`):
|
||||
- Displays modem information in header
|
||||
- Shows "via Modem: modem-001 (192.168.1.100)"
|
||||
- Warning if no modem assigned
|
||||
|
||||
### Backend Changes
|
||||
|
||||
#### New API Endpoint (`backend/routers/roster_edit.py`)
|
||||
|
||||
```python
|
||||
GET /api/roster/modems
|
||||
```
|
||||
|
||||
Returns list of all non-retired modem units:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "modem-001",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV",
|
||||
"deployed": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Used by frontend to populate modem selection dropdowns.
|
||||
|
||||
#### Database Schema (`backend/models.py`)
|
||||
|
||||
**Modem Assignment Field** (line 44):
|
||||
```python
|
||||
# Shared by seismographs and SLMs
|
||||
deployed_with_modem_id = Column(String, nullable=True)
|
||||
```
|
||||
|
||||
**Modem Fields** (lines 46-48):
|
||||
```python
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
```
|
||||
|
||||
**Legacy SLM Fields** (kept for backward compatibility):
|
||||
```python
|
||||
slm_host = Column(String, nullable=True) # Deprecated - use modem instead
|
||||
slm_tcp_port = Column(Integer, nullable=True) # Still used
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Modem Units
|
||||
|
||||
1. Go to Fleet Roster
|
||||
2. Click "Add Unit"
|
||||
3. Set Device Type to "Modem"
|
||||
4. Fill in:
|
||||
- Unit ID (e.g., "modem-001")
|
||||
- IP Address (e.g., "192.168.1.100")
|
||||
- Phone Number (if cellular)
|
||||
- Hardware Model (e.g., "Raven XTV")
|
||||
- Address/Coordinates (physical location)
|
||||
5. Set Deployed status
|
||||
6. Save
|
||||
|
||||
### Assigning Modem to SLM
|
||||
|
||||
1. Open SLM unit detail page
|
||||
2. Click "Edit Unit"
|
||||
3. Ensure Device Type is "Sound Level Meter"
|
||||
4. In "Deployed With Modem" dropdown, select modem
|
||||
5. Verify TCP Port (default 2255)
|
||||
6. Save
|
||||
|
||||
### Assigning Modem to Seismograph
|
||||
|
||||
Same process - both device types use the same modem selection field.
|
||||
|
||||
## Test Data
|
||||
|
||||
Use the included script to create test modems:
|
||||
|
||||
```bash
|
||||
python3 add_test_modems.py
|
||||
```
|
||||
|
||||
This creates:
|
||||
- **modem-001**: 192.168.1.100, Raven XTV → assigned to nl43-001
|
||||
- **modem-002**: 192.168.1.101, Raven XTV → assigned to nl43-002
|
||||
- **modem-003**: 192.168.1.102, Sierra Wireless → assigned to nl53-001
|
||||
- **modem-004**: Spare modem (not deployed)
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Operations
|
||||
|
||||
1. **Centralized IP Management**
|
||||
- Update modem IP once, affects all assigned devices
|
||||
- Easy to track which modem serves which devices
|
||||
- Inventory management for network equipment
|
||||
|
||||
2. **Asset Tracking**
|
||||
- Modems are first-class assets in the roster
|
||||
- Track deployment status, location, notes
|
||||
- Can bench/retire modems independently
|
||||
|
||||
3. **Future Capabilities**
|
||||
- Modem API integration (signal strength, data usage)
|
||||
- Automatic IP updates from DHCP/cellular network
|
||||
- Modem health monitoring
|
||||
- Remote modem diagnostics
|
||||
|
||||
### For Maintenance
|
||||
|
||||
1. **Easier Troubleshooting**
|
||||
- See which modem serves a device
|
||||
- Check modem status separately
|
||||
- Swap modems without reconfiguring devices
|
||||
|
||||
2. **Configuration Changes**
|
||||
- Change IP addresses system-wide
|
||||
- Move devices between modems
|
||||
- Test with backup modems
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
### For Existing SLMs with Direct IP
|
||||
|
||||
Legacy SLMs with `slm_host` set still work:
|
||||
- System checks `deployed_with_modem_id` first
|
||||
- Falls back to `slm_host` if no modem assigned
|
||||
- Logs fallback usage for visibility
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Create modem units for each IP address
|
||||
2. Assign SLMs to their modems
|
||||
3. System will use modem IP automatically
|
||||
4. Legacy `slm_host` can be cleared (optional)
|
||||
|
||||
Script `add_test_modems.py` demonstrates this:
|
||||
```python
|
||||
# Clear legacy field after modem assignment
|
||||
slm.slm_host = None
|
||||
slm.deployed_with_modem_id = "modem-001"
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Near-term
|
||||
|
||||
1. **Modem Status Dashboard**
|
||||
- List all modems with connection status
|
||||
- Show which devices use each modem
|
||||
- Signal strength, data usage indicators
|
||||
|
||||
2. **Automatic IP Discovery**
|
||||
- Query cellular provider API for modem IPs
|
||||
- Auto-update IP addresses in database
|
||||
- Alert on IP changes
|
||||
|
||||
3. **Modem Health Monitoring**
|
||||
- Ping modems periodically
|
||||
- Check cellular signal quality
|
||||
- Data usage tracking
|
||||
|
||||
### Long-term
|
||||
|
||||
1. **Modem API Integration**
|
||||
- Direct modem management (Raven, Sierra APIs)
|
||||
- Remote reboot capability
|
||||
- Configuration backup/restore
|
||||
- Firmware updates
|
||||
|
||||
2. **Network Topology View**
|
||||
- Graphical view of modem-device relationships
|
||||
- Network health visualization
|
||||
- Troubleshooting tools
|
||||
|
||||
3. **Multi-Modem Support**
|
||||
- Failover between modems
|
||||
- Load balancing
|
||||
- Automatic fallback on modem failure
|
||||
|
||||
## API Reference
|
||||
|
||||
### Get Modems List
|
||||
|
||||
**Endpoint**: `GET /api/roster/modems`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "modem-001",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV",
|
||||
"deployed": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Used By**:
|
||||
- Unit detail page (modem dropdown)
|
||||
- Future modem management dashboard
|
||||
|
||||
### Get Unit with Modem Info
|
||||
|
||||
**Endpoint**: `GET /api/roster/{unit_id}`
|
||||
|
||||
**Response** (for SLM):
|
||||
```json
|
||||
{
|
||||
"id": "nl43-001",
|
||||
"device_type": "slm",
|
||||
"deployed_with_modem_id": "modem-001",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then fetch modem separately or use dashboard endpoint which resolves it automatically.
|
||||
|
||||
### SLM Live View (with modem resolution)
|
||||
|
||||
**Endpoint**: `GET /api/slm-dashboard/live-view/{unit_id}`
|
||||
|
||||
**Process**:
|
||||
1. Loads SLM unit
|
||||
2. Resolves `deployed_with_modem_id` to modem unit
|
||||
3. Extracts `modem.ip_address`
|
||||
4. Uses IP to connect to SLMM backend
|
||||
5. Returns live view HTML with modem info
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Modified
|
||||
1. `backend/models.py` - Clarified modem assignment field
|
||||
2. `templates/unit_detail.html` - Added modem selector for SLMs
|
||||
3. `backend/routers/roster_edit.py` - Added modems list endpoint
|
||||
4. `backend/routers/slm_dashboard.py` - Modem resolution logic
|
||||
5. `templates/partials/slm_live_view.html` - Display modem info
|
||||
|
||||
### Created
|
||||
1. `add_test_modems.py` - Script to create test modems and assignments
|
||||
2. `docs/MODEM_INTEGRATION.md` - This documentation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SLM shows "No modem assigned"
|
||||
|
||||
**Cause**: Unit has no `deployed_with_modem_id` set
|
||||
|
||||
**Solution**:
|
||||
1. Edit the SLM unit
|
||||
2. Select a modem from dropdown
|
||||
3. Save
|
||||
|
||||
### Modem dropdown is empty
|
||||
|
||||
**Cause**: No modem units in database
|
||||
|
||||
**Solution**:
|
||||
1. Create modem units first
|
||||
2. Set `device_type = "modem"`
|
||||
3. Ensure they're not retired
|
||||
|
||||
### Can't connect to SLM
|
||||
|
||||
**Possible Causes**:
|
||||
1. Modem IP is incorrect
|
||||
2. Modem is offline
|
||||
3. SLM TCP port is wrong
|
||||
4. Network routing issue
|
||||
|
||||
**Debug Steps**:
|
||||
1. Check modem unit's IP address
|
||||
2. Ping the modem IP
|
||||
3. Check SLM's `slm_tcp_port` (default 2255)
|
||||
4. Review logs for connection errors
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Date**: January 5, 2026
|
||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md, DEVICE_TYPE_SLM_SUPPORT.md
|
||||
275
docs/SLM_CONFIGURATION.md
Normal file
275
docs/SLM_CONFIGURATION.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# SLM Configuration Interface
|
||||
|
||||
This document describes the SLM configuration interface added to the Sound Level Meters dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
Sound Level Meters can now be configured directly from the dashboard without needing to navigate to the unit detail page. A configuration button appears on each SLM unit card on hover, opening a modal with all configurable parameters.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Quick Access Configuration Button
|
||||
|
||||
- **Location**: Appears on each SLM unit card in the unit list
|
||||
- **Behavior**: Shows on hover (desktop) or always visible (mobile)
|
||||
- **Icon**: Gear/settings icon in top-right corner of unit card
|
||||
|
||||
### 2. Configuration Modal
|
||||
|
||||
The configuration modal provides a comprehensive interface for all SLM parameters:
|
||||
|
||||
#### Device Information
|
||||
- **Model**: Dropdown selection (NL-43, NL-53)
|
||||
- **Serial Number**: Text input for device serial number
|
||||
|
||||
#### Measurement Parameters
|
||||
- **Frequency Weighting**: A, C, or Z (Linear)
|
||||
- **Time Weighting**: Fast (125ms), Slow (1s), or Impulse
|
||||
- **Measurement Range**: 30-130 dB, 40-140 dB, or 50-140 dB
|
||||
|
||||
#### Network Configuration
|
||||
- **Assigned Modem**: Dropdown list of available modems
|
||||
- Shows modem ID and IP address
|
||||
- Option for "No modem (direct connection)"
|
||||
- **Direct IP Address**: Only shown when no modem assigned
|
||||
- **TCP Port**: Only shown when no modem assigned (default: 502)
|
||||
|
||||
### 3. Actions
|
||||
|
||||
The modal provides three action buttons:
|
||||
|
||||
- **Test Connection**: Tests network connectivity to the SLM
|
||||
- Uses current form values (not saved values)
|
||||
- Shows toast notification with results
|
||||
- Green: Connection successful
|
||||
- Yellow: Connection failed or device offline
|
||||
- Red: Test error
|
||||
|
||||
- **Cancel**: Closes modal without saving changes
|
||||
|
||||
- **Save Configuration**: Saves all changes to database
|
||||
- Shows success/error toast
|
||||
- Refreshes unit list on success
|
||||
- Auto-closes modal after 2 seconds
|
||||
|
||||
## Implementation
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### Unit List Partial
|
||||
**File**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
||||
|
||||
```html
|
||||
<!-- Configure button (appears on hover) -->
|
||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100...">
|
||||
<svg>...</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
#### Configuration Modal
|
||||
**File**: [templates/sound_level_meters.html](../templates/sound_level_meters.html#L73)
|
||||
|
||||
```html
|
||||
<!-- Configuration Modal -->
|
||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50...">
|
||||
<div id="config-modal-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Configuration Form
|
||||
**File**: [templates/partials/slm_config_form.html](../templates/partials/slm_config_form.html)
|
||||
|
||||
Form fields mapped to database columns:
|
||||
- `slm_model` → `unit.slm_model`
|
||||
- `slm_serial_number` → `unit.slm_serial_number`
|
||||
- `slm_frequency_weighting` → `unit.slm_frequency_weighting`
|
||||
- `slm_time_weighting` → `unit.slm_time_weighting`
|
||||
- `slm_measurement_range` → `unit.slm_measurement_range`
|
||||
- `deployed_with_modem_id` → `unit.deployed_with_modem_id`
|
||||
- `slm_host` → `unit.slm_host` (legacy, only if no modem)
|
||||
- `slm_tcp_port` → `unit.slm_tcp_port` (legacy, only if no modem)
|
||||
|
||||
### Backend Endpoints
|
||||
|
||||
#### GET /api/slm-dashboard/config/{unit_id}
|
||||
**File**: [backend/routers/slm_dashboard.py:184](../backend/routers/slm_dashboard.py#L184)
|
||||
|
||||
Returns configuration form HTML partial with current unit values pre-populated.
|
||||
|
||||
**Response**: HTML partial (slm_config_form.html)
|
||||
|
||||
#### POST /api/slm-dashboard/config/{unit_id}
|
||||
**File**: [backend/routers/slm_dashboard.py:203](../backend/routers/slm_dashboard.py#L203)
|
||||
|
||||
Saves configuration changes to database.
|
||||
|
||||
**Request**: Form data with configuration parameters
|
||||
|
||||
**Response**: JSON
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"unit_id": "nl43-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Updates all SLM-specific fields from form data
|
||||
- If modem is assigned: clears legacy `slm_host` and `slm_tcp_port`
|
||||
- If no modem: uses direct IP fields from form
|
||||
|
||||
### JavaScript Functions
|
||||
|
||||
#### openConfigModal(unitId)
|
||||
**File**: [templates/sound_level_meters.html:127](../templates/sound_level_meters.html#L127)
|
||||
|
||||
Opens configuration modal and loads form via HTMX.
|
||||
|
||||
```javascript
|
||||
function openConfigModal(unitId) {
|
||||
const modal = document.getElementById('config-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||
target: '#config-modal-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### closeConfigModal()
|
||||
**File**: [templates/sound_level_meters.html:136](../templates/sound_level_meters.html#L136)
|
||||
|
||||
Closes configuration modal.
|
||||
|
||||
#### handleConfigSave(event)
|
||||
**File**: [templates/partials/slm_config_form.html:109](../templates/partials/slm_config_form.html#L109)
|
||||
|
||||
Handles HTMX response after form submission:
|
||||
- Shows success/error toast
|
||||
- Refreshes unit list
|
||||
- Auto-closes modal after 2 seconds
|
||||
|
||||
#### testConnection(unitId)
|
||||
**File**: [templates/partials/slm_config_form.html:129](../templates/partials/slm_config_form.html#L129)
|
||||
|
||||
Tests connection to SLM unit:
|
||||
```javascript
|
||||
async function testConnection(unitId) {
|
||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'online') {
|
||||
// Show success toast
|
||||
} else {
|
||||
// Show warning toast
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### loadModemsForConfig()
|
||||
**File**: [templates/partials/slm_config_form.html:87](../templates/partials/slm_config_form.html#L87)
|
||||
|
||||
Loads available modems from `/api/roster/modems` and populates dropdown.
|
||||
|
||||
## User Workflow
|
||||
|
||||
### Configuring an SLM
|
||||
|
||||
1. Navigate to Sound Level Meters dashboard ([/sound-level-meters](../sound-level-meters))
|
||||
2. Hover over desired SLM unit card in the list
|
||||
3. Click the gear icon that appears in top-right corner
|
||||
4. Configuration modal opens with current values pre-filled
|
||||
5. Modify desired parameters:
|
||||
- Update model/serial if needed
|
||||
- Set measurement parameters (frequency/time weighting, range)
|
||||
- Choose network configuration:
|
||||
- **Option A**: Select a modem from dropdown (recommended)
|
||||
- **Option B**: Enter direct IP address and port
|
||||
6. (Optional) Click "Test Connection" to verify network settings
|
||||
7. Click "Save Configuration"
|
||||
8. Modal shows success message and auto-closes
|
||||
9. Unit list refreshes to show updated information
|
||||
|
||||
### Network Configuration Options
|
||||
|
||||
**Modem Assignment (Recommended)**:
|
||||
- Select modem from dropdown
|
||||
- IP address automatically resolved from modem's `ip_address` field
|
||||
- Direct IP/port fields hidden
|
||||
- Enables modem tracking and management
|
||||
|
||||
**Direct Connection (Legacy)**:
|
||||
- Select "No modem (direct connection)"
|
||||
- Enter IP address and TCP port manually
|
||||
- Direct IP/port fields become visible
|
||||
- Useful for temporary setups or non-modem connections
|
||||
|
||||
## Database Schema
|
||||
|
||||
The configuration interface updates these `roster` table columns:
|
||||
|
||||
```sql
|
||||
-- SLM-specific fields
|
||||
slm_model VARCHAR -- Device model (NL-43, NL-53)
|
||||
slm_serial_number VARCHAR -- Serial number
|
||||
slm_frequency_weighting VARCHAR -- A, C, or Z weighting
|
||||
slm_time_weighting VARCHAR -- Fast, Slow, or Impulse
|
||||
slm_measurement_range VARCHAR -- Measurement range (30-130, 40-140, 50-140)
|
||||
|
||||
-- Network configuration
|
||||
deployed_with_modem_id VARCHAR -- FK to modem unit (preferred method)
|
||||
slm_host VARCHAR -- Legacy direct IP (only if no modem)
|
||||
slm_tcp_port INTEGER -- Legacy TCP port (only if no modem)
|
||||
```
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Modal Behavior
|
||||
- **Opens**: Via configure button on unit card
|
||||
- **Closes**:
|
||||
- Cancel button
|
||||
- X button in header
|
||||
- Escape key
|
||||
- Clicking outside modal (on backdrop)
|
||||
- **Auto-close**: After successful save (2 second delay)
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Configuration button appears on hover
|
||||
- **Mobile**: Configuration button always visible
|
||||
- **Modal**: Responsive width, scrollable on small screens
|
||||
- **Form**: Two-column layout on desktop, single column on mobile
|
||||
|
||||
### Visual Feedback
|
||||
- **Loading**: Skeleton loader while form loads
|
||||
- **Saving**: HTMX handles form submission
|
||||
- **Success**: Green toast notification
|
||||
- **Error**: Red toast notification
|
||||
- **Testing**: Blue toast while testing, then green/yellow/red based on result
|
||||
|
||||
### Accessibility
|
||||
- **Keyboard**: Modal can be closed with Escape key
|
||||
- **Focus**: Modal traps focus when open
|
||||
- **Labels**: All form fields have proper labels
|
||||
- **Colors**: Sufficient contrast in dark/light modes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Bulk Configuration**: Configure multiple SLMs at once
|
||||
2. **Configuration Templates**: Save and apply configuration presets
|
||||
3. **Configuration History**: Track configuration changes over time
|
||||
4. **Remote Configuration**: Push configuration directly to device via SLMM
|
||||
5. **Validation**: Real-time validation of IP addresses and ports
|
||||
6. **Advanced Settings**: Additional NL-43/NL-53 specific parameters
|
||||
7. **Configuration Import/Export**: JSON/CSV configuration files
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - Main SLM dashboard
|
||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - SLM device type implementation
|
||||
333
docs/SOUND_LEVEL_METERS_DASHBOARD.md
Normal file
333
docs/SOUND_LEVEL_METERS_DASHBOARD.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Sound Level Meters Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
The Sound Level Meters dashboard is a new feature in SFM (soon to be rebranded as Terra-view) that provides real-time monitoring and control of Rion NL-43/NL-53 sound level meters through the SLMM backend integration.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dashboard Summary Statistics
|
||||
- **Total Units**: Count of all SLM devices in the system
|
||||
- **Deployed Units**: Active devices currently in the field
|
||||
- **Active Now**: Units that have checked in within the last hour
|
||||
- **Benched Units**: Devices not currently deployed
|
||||
|
||||
### 2. Unit List (Sidebar)
|
||||
- Searchable list of all deployed SLM units
|
||||
- Real-time status indicators:
|
||||
- 🟢 Green: Active (recently checked in)
|
||||
- ⚪ Gray: No check-in data
|
||||
- Quick unit information:
|
||||
- Device model (NL-43, NL-53, etc.)
|
||||
- Location/address
|
||||
- Network address (IP:port)
|
||||
- Click any unit to view its live data
|
||||
|
||||
### 3. Live View Panel
|
||||
|
||||
When a unit is selected, the live view panel displays:
|
||||
|
||||
#### Control Buttons
|
||||
- **Start**: Begin measurement
|
||||
- **Pause**: Pause current measurement
|
||||
- **Stop**: Stop measurement
|
||||
- **Reset**: Reset measurement data
|
||||
- **Start Live Stream**: Open WebSocket connection for real-time DRD data
|
||||
|
||||
#### Real-time Metrics
|
||||
- **Lp (Current)**: Instantaneous sound level in dB
|
||||
- **Leq (Average)**: Equivalent continuous sound level
|
||||
- **Lmax (Peak)**: Maximum sound level recorded
|
||||
- **Lmin**: Minimum sound level recorded
|
||||
|
||||
#### Live Chart
|
||||
- Real-time line chart showing Lp and Leq over time
|
||||
- 60-second rolling window (adjustable)
|
||||
- Chart.js-powered visualization with dark mode support
|
||||
- No animation for smooth real-time updates
|
||||
|
||||
#### Device Information
|
||||
- Battery level and power source
|
||||
- Frequency weighting (A, C, Z)
|
||||
- Time weighting (F, S, I)
|
||||
- SD card remaining space
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### Main Template
|
||||
**File**: `templates/sound_level_meters.html`
|
||||
|
||||
The main dashboard page that includes:
|
||||
- Page header and navigation integration
|
||||
- Stats summary section (auto-refreshes every 10s)
|
||||
- Two-column layout: unit list (left) + live view (right)
|
||||
- JavaScript functions for unit selection and WebSocket streaming
|
||||
|
||||
#### Partial Templates
|
||||
|
||||
1. **slm_stats.html** - Summary statistics cards
|
||||
- Auto-loads on page load
|
||||
- Refreshes every 10 seconds via HTMX
|
||||
|
||||
2. **slm_unit_list.html** - Searchable unit list
|
||||
- Auto-loads on page load
|
||||
- Refreshes every 10 seconds via HTMX
|
||||
- Supports search filtering
|
||||
|
||||
3. **slm_live_view.html** - Live data panel for selected unit
|
||||
- Loaded on-demand when unit is selected
|
||||
- Includes Chart.js for visualization
|
||||
- WebSocket connection for streaming data
|
||||
|
||||
4. **slm_live_view_error.html** - Error state display
|
||||
|
||||
### Backend Components
|
||||
|
||||
#### Router: `backend/routers/slm_dashboard.py`
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/stats
|
||||
```
|
||||
Returns HTML partial with summary statistics.
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/units?search={term}
|
||||
```
|
||||
Returns HTML partial with filtered unit list.
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/live-view/{unit_id}
|
||||
```
|
||||
Returns HTML partial with live view panel for specific unit.
|
||||
- Fetches unit details from database
|
||||
- Queries SLMM API for current measurement state
|
||||
- Queries SLMM API for live status (DOD data)
|
||||
|
||||
```python
|
||||
POST /api/slm-dashboard/control/{unit_id}/{action}
|
||||
```
|
||||
Sends control commands to SLMM backend.
|
||||
- Valid actions: start, stop, pause, resume, reset
|
||||
- Proxies to `http://localhost:8100/api/nl43/{unit_id}/{action}`
|
||||
|
||||
### Integration with SLMM
|
||||
|
||||
The dashboard communicates with the SLMM backend service running on port 8100:
|
||||
|
||||
**REST API Calls:**
|
||||
- `GET /api/nl43/{unit_id}/measurement-state` - Check if measuring
|
||||
- `GET /api/nl43/{unit_id}/live` - Get current DOD data
|
||||
- `POST /api/nl43/{unit_id}/start|stop|pause|resume|reset` - Control commands
|
||||
|
||||
**WebSocket Streaming:**
|
||||
- `WS /api/nl43/{unit_id}/live` - Real-time DRD data stream
|
||||
- Proxied through SFM at `/api/slmm/{unit_id}/live`
|
||||
- Streams continuous measurement data for live charting
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Table**: `roster`
|
||||
|
||||
SLM-specific fields in the RosterUnit model:
|
||||
|
||||
```python
|
||||
device_type = "slm" # Distinguishes SLMs from seismographs
|
||||
slm_host = String # Device IP or hostname
|
||||
slm_tcp_port = Integer # TCP control port (default 2255)
|
||||
slm_model = String # NL-43, NL-53, etc.
|
||||
slm_serial_number = String # Device serial number
|
||||
slm_frequency_weighting = String # A, C, or Z weighting
|
||||
slm_time_weighting = String # F (Fast), S (Slow), I (Impulse)
|
||||
slm_measurement_range = String # e.g., "30-130 dB"
|
||||
slm_last_check = DateTime # Last communication timestamp
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
The Sound Level Meters page is accessible from:
|
||||
- **URL**: `/sound-level-meters`
|
||||
- **Sidebar**: "Sound Level Meters" menu item (between Fleet Roster and Projects)
|
||||
- **Icon**: Speaker/sound wave SVG icon
|
||||
|
||||
## Real-time Updates
|
||||
|
||||
The dashboard uses three mechanisms for real-time updates:
|
||||
|
||||
1. **HTMX Polling** (10-second intervals)
|
||||
- Summary statistics
|
||||
- Unit list
|
||||
- Ensures data freshness even without user interaction
|
||||
|
||||
2. **On-Demand Loading** (HTMX)
|
||||
- Live view panel loads when unit is selected
|
||||
- Control button responses
|
||||
|
||||
3. **WebSocket Streaming** (continuous)
|
||||
- Real-time DRD data for live charting
|
||||
- User-initiated via "Start Live Stream" button
|
||||
- Automatically closed on page unload or unit change
|
||||
|
||||
## Measurement Duration Tracking
|
||||
|
||||
**Important**: The NL-43/NL-53 devices do not expose measurement duration via their API. Elapsed time and interval counts are only visible on the device's on-screen display (OSD).
|
||||
|
||||
**Solution**: Track measurement start time in your application when calling the `/start` endpoint:
|
||||
|
||||
```javascript
|
||||
// When starting measurement
|
||||
const startTime = new Date();
|
||||
localStorage.setItem(`slm_${unitId}_start`, startTime.toISOString());
|
||||
|
||||
// Calculate elapsed time
|
||||
const startTime = new Date(localStorage.getItem(`slm_${unitId}_start`));
|
||||
const elapsed = (new Date() - startTime) / 1000; // seconds
|
||||
```
|
||||
|
||||
**Future Enhancement**: SLMM backend could store measurement start times in a database table to track duration across sessions.
|
||||
|
||||
## Testing
|
||||
|
||||
### Add Test Data
|
||||
|
||||
Use the included script to add test SLM units:
|
||||
|
||||
```bash
|
||||
python3 add_test_slms.py
|
||||
```
|
||||
|
||||
This creates:
|
||||
- 3 deployed test units (nl43-001, nl43-002, nl53-001)
|
||||
- 1 benched unit (nl43-003)
|
||||
|
||||
### Running the Dashboard
|
||||
|
||||
1. Start SLMM backend (port 8100):
|
||||
```bash
|
||||
cd /home/serversdown/slmm
|
||||
uvicorn main:app --host 0.0.0.0 --port 8100
|
||||
```
|
||||
|
||||
2. Start SFM (port 8000):
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
3. Access dashboard:
|
||||
```
|
||||
http://localhost:8000/sound-level-meters
|
||||
```
|
||||
|
||||
### Testing Without Physical Devices
|
||||
|
||||
The dashboard will work without physical NL-43 devices connected:
|
||||
- Unit list will display based on database records
|
||||
- Live view will show connection errors (gracefully handled)
|
||||
- Mock data can be added to SLMM for testing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Near-term
|
||||
1. **Measurement Duration Tracking**
|
||||
- Add database table to track measurement sessions
|
||||
- Display elapsed time in live view
|
||||
- Store start/stop timestamps
|
||||
|
||||
2. **Historical Data View**
|
||||
- Chart historical Leq intervals
|
||||
- Export measurement data
|
||||
- Comparison between units
|
||||
|
||||
3. **Alerts & Thresholds**
|
||||
- Configurable sound level alerts
|
||||
- Email/SMS notifications when thresholds exceeded
|
||||
- Visual indicators on dashboard
|
||||
|
||||
### Long-term
|
||||
1. **Map View**
|
||||
- Display all SLMs on a map (like seismographs)
|
||||
- Click map markers to view live data
|
||||
- Color-coded by current sound level
|
||||
|
||||
2. **Batch Operations**
|
||||
- Start/stop multiple units simultaneously
|
||||
- Synchronized measurements
|
||||
- Group configurations
|
||||
|
||||
3. **Advanced Analytics**
|
||||
- Noise compliance reports
|
||||
- Statistical summaries
|
||||
- Trend analysis
|
||||
|
||||
## Integration with Terra-view
|
||||
|
||||
When SFM is rebranded to Terra-view:
|
||||
|
||||
1. **Multi-Module Dashboard**
|
||||
- Sound Level Meters module (this dashboard)
|
||||
- Seismograph Fleet Manager module (existing)
|
||||
- Future monitoring modules
|
||||
|
||||
2. **Project Management**
|
||||
- Link SLMs to projects
|
||||
- Combined project view with seismographs + SLMs
|
||||
- Project-level reporting
|
||||
|
||||
3. **Unified Navigation**
|
||||
- Top-level module switcher
|
||||
- Consistent UI/UX across modules
|
||||
- Shared authentication and settings
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### HTMX Integration
|
||||
The dashboard extensively uses HTMX for dynamic updates without full page reloads:
|
||||
- `hx-get`: Fetch and swap content
|
||||
- `hx-trigger`: Auto-refresh intervals
|
||||
- `hx-swap`: Content replacement strategy
|
||||
- `hx-target`: Specify update target
|
||||
|
||||
### Dark Mode Support
|
||||
All components support dark mode:
|
||||
- Chart.js colors adapt to theme
|
||||
- Tailwind dark: classes throughout
|
||||
- Automatic theme detection
|
||||
|
||||
### Performance Considerations
|
||||
- WebSocket connections are per-unit (only one active at a time)
|
||||
- Chart data limited to 60 points (1 minute) to prevent memory bloat
|
||||
- Polling intervals balanced for responsiveness vs server load
|
||||
- Lazy loading of live view panel (only when unit selected)
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### New Files
|
||||
- `templates/sound_level_meters.html`
|
||||
- `templates/partials/slm_stats.html`
|
||||
- `templates/partials/slm_unit_list.html`
|
||||
- `templates/partials/slm_live_view.html`
|
||||
- `templates/partials/slm_live_view_error.html`
|
||||
- `backend/routers/slm_dashboard.py`
|
||||
- `add_test_slms.py`
|
||||
- `docs/SOUND_LEVEL_METERS_DASHBOARD.md`
|
||||
|
||||
### Modified Files
|
||||
- `backend/main.py` - Added route and router import
|
||||
- `templates/base.html` - Added navigation menu item
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check SLMM API documentation: `/home/serversdown/slmm/docs/API.md`
|
||||
- Review SFM changelog: `CHANGELOG.md`
|
||||
- Submit issues to project repository
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Created**: January 2026
|
||||
**Last Updated**: January 5, 2026
|
||||
546
docs/archive/PROJECTS_SYSTEM_IMPLEMENTATION.md
Normal file
546
docs/archive/PROJECTS_SYSTEM_IMPLEMENTATION.md
Normal file
@@ -0,0 +1,546 @@
|
||||
# Projects System Implementation - Terra-View
|
||||
|
||||
## Overview
|
||||
|
||||
The Projects system has been successfully scaffolded in Terra-View. This document provides a complete overview of what has been built, how it works, and what needs to be completed.
|
||||
|
||||
## ✅ Completed Components
|
||||
|
||||
### 1. Database Schema
|
||||
|
||||
**Location**: `/backend/models.py`
|
||||
|
||||
Seven new tables have been added:
|
||||
|
||||
- **ProjectType**: Template definitions for project types (Sound, Vibration, Combined)
|
||||
- **Project**: Top-level project organization with type reference
|
||||
- **MonitoringLocation**: Generic locations (NRLs for sound, monitoring points for vibration)
|
||||
- **UnitAssignment**: Links devices to locations
|
||||
- **ScheduledAction**: Automated recording control schedules
|
||||
- **RecordingSession**: Tracks actual recording/monitoring sessions
|
||||
- **DataFile**: File references for downloaded data
|
||||
|
||||
**Key Features**:
|
||||
- Type-aware design (project_type_id determines features)
|
||||
- Flexible metadata fields (JSON columns for type-specific data)
|
||||
- Denormalized fields for efficient queries
|
||||
- Proper indexing on foreign keys
|
||||
|
||||
### 2. Service Layer
|
||||
|
||||
#### SLMM Client (`/backend/services/slmm_client.py`)
|
||||
- Clean wrapper for all SLMM API operations
|
||||
- Methods for: start/stop/pause/resume recording, get status, configure devices
|
||||
- Error handling with custom exceptions
|
||||
- Singleton pattern for easy access
|
||||
|
||||
#### Device Controller (`/backend/services/device_controller.py`)
|
||||
- Routes commands to appropriate backend (SLMM for SLMs, SFM for seismographs)
|
||||
- Unified interface across device types
|
||||
- Ready for future SFM implementation
|
||||
|
||||
#### Scheduler Service (`/backend/services/scheduler.py`)
|
||||
- Background task that checks for pending scheduled actions every 60 seconds
|
||||
- Executes actions by calling device controller
|
||||
- Creates/updates recording sessions
|
||||
- Tracks execution status and errors
|
||||
- Manual execution support for testing
|
||||
|
||||
### 3. API Routers
|
||||
|
||||
#### Projects Router (`/backend/routers/projects.py`)
|
||||
Endpoints:
|
||||
- `GET /api/projects/list` - Project list with stats
|
||||
- `GET /api/projects/stats` - Overview statistics
|
||||
- `POST /api/projects/create` - Create new project
|
||||
- `GET /api/projects/{id}` - Get project details
|
||||
- `PUT /api/projects/{id}` - Update project
|
||||
- `DELETE /api/projects/{id}` - Archive project
|
||||
- `GET /api/projects/{id}/dashboard` - Project dashboard data
|
||||
- `GET /api/projects/types/list` - Get project type templates
|
||||
|
||||
#### Project Locations Router (`/backend/routers/project_locations.py`)
|
||||
Endpoints:
|
||||
- `GET /api/projects/{id}/locations` - List locations
|
||||
- `POST /api/projects/{id}/locations/create` - Create location
|
||||
- `PUT /api/projects/{id}/locations/{location_id}` - Update location
|
||||
- `DELETE /api/projects/{id}/locations/{location_id}` - Delete location
|
||||
- `GET /api/projects/{id}/assignments` - List unit assignments
|
||||
- `POST /api/projects/{id}/locations/{location_id}/assign` - Assign unit
|
||||
- `POST /api/projects/{id}/assignments/{assignment_id}/unassign` - Unassign unit
|
||||
- `GET /api/projects/{id}/available-units` - Get units available for assignment
|
||||
|
||||
#### Scheduler Router (`/backend/routers/scheduler.py`)
|
||||
Endpoints:
|
||||
- `GET /api/projects/{id}/scheduler/actions` - List scheduled actions
|
||||
- `POST /api/projects/{id}/scheduler/actions/create` - Create action
|
||||
- `POST /api/projects/{id}/scheduler/schedule-session` - Schedule recording session
|
||||
- `PUT /api/projects/{id}/scheduler/actions/{action_id}` - Update action
|
||||
- `POST /api/projects/{id}/scheduler/actions/{action_id}/cancel` - Cancel action
|
||||
- `DELETE /api/projects/{id}/scheduler/actions/{action_id}` - Delete action
|
||||
- `POST /api/projects/{id}/scheduler/actions/{action_id}/execute` - Manual execution
|
||||
- `GET /api/projects/{id}/scheduler/status` - Scheduler status
|
||||
- `POST /api/projects/{id}/scheduler/execute-pending` - Trigger pending executions
|
||||
|
||||
### 4. Frontend
|
||||
|
||||
#### Main Page
|
||||
**Location**: `/templates/projects/overview.html`
|
||||
|
||||
Features:
|
||||
- Summary statistics cards (projects, locations, assignments, sessions)
|
||||
- Tabbed interface (All, Active, Completed, Archived)
|
||||
- Project cards grid layout
|
||||
- Create project modal with two-step flow:
|
||||
1. Select project type (Sound/Vibration/Combined)
|
||||
2. Fill project details
|
||||
- HTMX-powered dynamic updates
|
||||
|
||||
#### Navigation
|
||||
**Location**: `/templates/base.html` (updated)
|
||||
- "Projects" link added to sidebar
|
||||
- Active state highlighting
|
||||
|
||||
### 5. Application Integration
|
||||
|
||||
**Location**: `/backend/main.py`
|
||||
|
||||
- Routers registered
|
||||
- Page route added (`/projects`)
|
||||
- Scheduler service starts on application startup
|
||||
- Scheduler stops on application shutdown
|
||||
|
||||
### 6. Database Initialization
|
||||
|
||||
**Script**: `/backend/init_projects_db.py`
|
||||
|
||||
- Creates all project tables
|
||||
- Populates ProjectType with default templates
|
||||
- ✅ Successfully executed - database is ready
|
||||
|
||||
---
|
||||
|
||||
## 📁 File Organization
|
||||
|
||||
```
|
||||
terra-view/
|
||||
├── backend/
|
||||
│ ├── models.py [✅ Updated]
|
||||
│ ├── init_projects_db.py [✅ Created]
|
||||
│ ├── main.py [✅ Updated]
|
||||
│ ├── routers/
|
||||
│ │ ├── projects.py [✅ Created]
|
||||
│ │ ├── project_locations.py [✅ Created]
|
||||
│ │ └── scheduler.py [✅ Created]
|
||||
│ └── services/
|
||||
│ ├── slmm_client.py [✅ Created]
|
||||
│ ├── device_controller.py [✅ Created]
|
||||
│ └── scheduler.py [✅ Created]
|
||||
├── templates/
|
||||
│ ├── base.html [✅ Updated]
|
||||
│ ├── projects/
|
||||
│ │ └── overview.html [✅ Created]
|
||||
│ └── partials/
|
||||
│ └── projects/ [📁 Created, empty]
|
||||
└── data/
|
||||
└── seismo_fleet.db [✅ Tables created]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔨 What Still Needs to be Built
|
||||
|
||||
### 1. Frontend Templates (Partials)
|
||||
|
||||
**Directory**: `/templates/partials/projects/`
|
||||
|
||||
**Required Files**:
|
||||
|
||||
#### `project_stats.html`
|
||||
Stats cards for overview page:
|
||||
- Total/Active/Completed projects
|
||||
- Total locations
|
||||
- Assigned units
|
||||
- Active sessions
|
||||
|
||||
#### `project_list.html`
|
||||
Project cards grid:
|
||||
- Project name, type, status
|
||||
- Location count, unit count
|
||||
- Active session indicator
|
||||
- Link to project dashboard
|
||||
|
||||
#### `project_dashboard.html`
|
||||
Main project dashboard panel with tabs:
|
||||
- Summary stats
|
||||
- Active locations and assignments
|
||||
- Upcoming scheduled actions
|
||||
- Recent sessions
|
||||
|
||||
#### `location_list.html`
|
||||
Location cards/table:
|
||||
- Location name, type, coordinates
|
||||
- Assigned unit (if any)
|
||||
- Session count
|
||||
- Assign/unassign button
|
||||
|
||||
#### `assignment_list.html`
|
||||
Unit assignment table:
|
||||
- Unit ID, device type
|
||||
- Location name
|
||||
- Assignment dates
|
||||
- Status
|
||||
- Unassign button
|
||||
|
||||
#### `scheduler_agenda.html`
|
||||
Calendar/agenda view:
|
||||
- Scheduled actions sorted by time
|
||||
- Action type (start/stop/download)
|
||||
- Location and unit
|
||||
- Status indicator
|
||||
- Cancel/execute buttons
|
||||
|
||||
### 2. Project Dashboard Page
|
||||
|
||||
**Location**: `/templates/projects/project_dashboard.html`
|
||||
|
||||
Full project detail page with:
|
||||
- Header with project name, type, status
|
||||
- Tab navigation (Dashboard, Scheduler, Locations, Units, Data, Settings)
|
||||
- Tab content areas
|
||||
- Modals for adding locations, scheduling sessions
|
||||
|
||||
### 3. Additional UI Components
|
||||
|
||||
- Project type selection cards (with icons)
|
||||
- Location creation modal
|
||||
- Unit assignment modal
|
||||
- Schedule session modal (with date/time picker)
|
||||
- Data file browser
|
||||
|
||||
### 4. SLMM Enhancements
|
||||
|
||||
**Location**: `/slmm/app/routers.py` (SLMM repo)
|
||||
|
||||
New endpoint needed:
|
||||
```python
|
||||
POST /api/nl43/{unit_id}/ftp/download
|
||||
```
|
||||
|
||||
This should:
|
||||
- Accept destination_path and files list
|
||||
- Connect to SLM via FTP
|
||||
- Download specified files
|
||||
- Save to Terra-View's `data/Projects/` directory
|
||||
- Return file list with metadata
|
||||
|
||||
### 5. SFM Client (Future)
|
||||
|
||||
**Location**: `/backend/services/sfm_client.py` (to be created)
|
||||
|
||||
Similar to SLMM client, but for seismographs:
|
||||
- Get seismograph status
|
||||
- Start/stop recording
|
||||
- Download data files
|
||||
- Integrate with device controller
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Testing the System
|
||||
|
||||
### 1. Start Terra-View
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/tmi/terra-view
|
||||
# Start Terra-View (however you normally start it)
|
||||
```
|
||||
|
||||
Verify in logs:
|
||||
```
|
||||
Starting scheduler service...
|
||||
Scheduler service started
|
||||
```
|
||||
|
||||
### 2. Navigate to Projects
|
||||
|
||||
Open browser: `http://localhost:8001/projects`
|
||||
|
||||
You should see:
|
||||
- Summary stats cards (all zeros initially)
|
||||
- Tabs (All Projects, Active, Completed, Archived)
|
||||
- "New Project" button
|
||||
|
||||
### 3. Create a Project
|
||||
|
||||
1. Click "New Project"
|
||||
2. Select a project type (e.g., "Sound Monitoring")
|
||||
3. Fill in details:
|
||||
- Name: "Test Sound Project"
|
||||
- Client: "Test Client"
|
||||
- Start Date: Today
|
||||
4. Submit
|
||||
|
||||
### 4. Test API Endpoints
|
||||
|
||||
```bash
|
||||
# Get project types
|
||||
curl http://localhost:8001/api/projects/types/list
|
||||
|
||||
# Get projects list
|
||||
curl http://localhost:8001/api/projects/list
|
||||
|
||||
# Get project stats
|
||||
curl http://localhost:8001/api/projects/stats
|
||||
```
|
||||
|
||||
### 5. Test Scheduler Status
|
||||
|
||||
```bash
|
||||
curl http://localhost:8001/api/projects/{project_id}/scheduler/status
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Dataflow Examples
|
||||
|
||||
### Creating and Scheduling a Recording Session
|
||||
|
||||
1. **User creates project** → Project record in DB
|
||||
2. **User adds NRL** → MonitoringLocation record
|
||||
3. **User assigns SLM to NRL** → UnitAssignment record
|
||||
4. **User schedules recording** → 2 ScheduledAction records (start + stop)
|
||||
5. **Scheduler runs every minute** → Checks for pending actions
|
||||
6. **Start action time arrives** → Scheduler calls SLMM via device controller
|
||||
7. **SLMM sends TCP command to SLM** → Recording starts
|
||||
8. **RecordingSession created** → Tracks the session
|
||||
9. **Stop action time arrives** → Scheduler stops recording
|
||||
10. **Session updated** → stopped_at, duration_seconds filled
|
||||
11. **User triggers download** → Files copied to `data/Projects/{project_id}/sound/{nrl_name}/`
|
||||
12. **DataFile records created** → Track file references
|
||||
|
||||
---
|
||||
|
||||
## 🎨 UI Design Patterns
|
||||
|
||||
### Established Patterns (from SLM dashboard):
|
||||
|
||||
1. **Stats Cards**: 4-column grid, auto-refresh every 30s
|
||||
2. **Sidebar Lists**: Searchable, filterable, auto-refresh
|
||||
3. **Main Panel**: Large central area for details
|
||||
4. **Modals**: Centered, overlay background
|
||||
5. **HTMX**: All dynamic updates, minimal JavaScript
|
||||
6. **Tailwind**: Consistent styling with dark mode support
|
||||
|
||||
### Color Scheme:
|
||||
|
||||
- Primary: `seismo-orange` (#f48b1c)
|
||||
- Secondary: `seismo-navy` (#142a66)
|
||||
- Accent: `seismo-burgundy` (#7d234d)
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
- `SLMM_BASE_URL`: SLMM backend URL (default: http://localhost:8100)
|
||||
- `ENVIRONMENT`: "development" or "production"
|
||||
|
||||
### Scheduler Settings
|
||||
|
||||
Located in `/backend/services/scheduler.py`:
|
||||
- `check_interval`: 60 seconds (adjust as needed)
|
||||
|
||||
---
|
||||
|
||||
## 📚 Next Steps
|
||||
|
||||
### Immediate (Get Basic UI Working):
|
||||
1. Create partial templates (stats, lists)
|
||||
2. Test creating projects via UI
|
||||
3. Implement project dashboard page
|
||||
|
||||
### Short-term (Core Features):
|
||||
4. Add location management UI
|
||||
5. Add unit assignment UI
|
||||
6. Add scheduler UI (agenda view)
|
||||
|
||||
### Medium-term (Data Flow):
|
||||
7. Implement SLMM download endpoint
|
||||
8. Test full recording workflow
|
||||
9. Add file browser for downloaded data
|
||||
|
||||
### Long-term (Complete System):
|
||||
10. Implement SFM client for seismographs
|
||||
11. Add data visualization
|
||||
12. Add project reporting
|
||||
13. Add user authentication
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Known Issues / TODOs
|
||||
|
||||
1. **Partial templates missing**: Need to create HTML templates for all partials
|
||||
2. **SLMM download endpoint**: Needs implementation in SLMM backend
|
||||
3. **Project dashboard page**: Not yet created
|
||||
4. **SFM integration**: Placeholder only, needs real implementation
|
||||
5. **File download tracking**: DataFile records not yet created after downloads
|
||||
6. **Error handling**: Need better user-facing error messages
|
||||
7. **Validation**: Form validation could be improved
|
||||
8. **Testing**: No automated tests yet
|
||||
|
||||
---
|
||||
|
||||
## 📖 API Documentation
|
||||
|
||||
### Project Type Object
|
||||
```json
|
||||
{
|
||||
"id": "sound_monitoring",
|
||||
"name": "Sound Monitoring",
|
||||
"description": "...",
|
||||
"icon": "volume-2",
|
||||
"supports_sound": true,
|
||||
"supports_vibration": false
|
||||
}
|
||||
```
|
||||
|
||||
### Project Object
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"name": "Project Name",
|
||||
"description": "...",
|
||||
"project_type_id": "sound_monitoring",
|
||||
"status": "active",
|
||||
"client_name": "Client Inc",
|
||||
"site_address": "123 Main St",
|
||||
"site_coordinates": "40.7128,-74.0060",
|
||||
"start_date": "2024-01-15",
|
||||
"end_date": null,
|
||||
"created_at": "2024-01-15T10:00:00",
|
||||
"updated_at": "2024-01-15T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### MonitoringLocation Object
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"project_id": "uuid",
|
||||
"location_type": "sound",
|
||||
"name": "NRL-001",
|
||||
"description": "...",
|
||||
"coordinates": "40.7128,-74.0060",
|
||||
"address": "123 Main St",
|
||||
"location_metadata": "{...}",
|
||||
"created_at": "2024-01-15T10:00:00"
|
||||
}
|
||||
```
|
||||
|
||||
### UnitAssignment Object
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"unit_id": "nl43-001",
|
||||
"location_id": "uuid",
|
||||
"project_id": "uuid",
|
||||
"device_type": "sound_level_meter",
|
||||
"assigned_at": "2024-01-15T10:00:00",
|
||||
"assigned_until": null,
|
||||
"status": "active",
|
||||
"notes": "..."
|
||||
}
|
||||
```
|
||||
|
||||
### ScheduledAction Object
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
"project_id": "uuid",
|
||||
"location_id": "uuid",
|
||||
"unit_id": "nl43-001",
|
||||
"action_type": "start",
|
||||
"device_type": "sound_level_meter",
|
||||
"scheduled_time": "2024-01-16T08:00:00",
|
||||
"executed_at": null,
|
||||
"execution_status": "pending",
|
||||
"module_response": null,
|
||||
"error_message": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🎓 Architecture Decisions
|
||||
|
||||
### Why Project Types?
|
||||
Allows the system to scale to different monitoring scenarios (air quality, multi-hazard, etc.) without code changes. Just add a new ProjectType record and the UI adapts.
|
||||
|
||||
### Why Generic MonitoringLocation?
|
||||
Instead of separate NRL and MonitoringPoint tables, one table with a `location_type` discriminator keeps the schema clean and allows for combined projects.
|
||||
|
||||
### Why Denormalized Fields?
|
||||
Fields like `project_id` in UnitAssignment (already have via location) enable faster queries without joins.
|
||||
|
||||
### Why Scheduler in Terra-View?
|
||||
Terra-View is the orchestration layer. SLMM only handles device communication. Keeping scheduling logic in Terra-View allows for complex workflows across multiple device types.
|
||||
|
||||
### Why JSON Metadata Columns?
|
||||
Type-specific fields (like ambient_conditions for sound projects) don't apply to all location types. JSON columns provide flexibility without cluttering the schema.
|
||||
|
||||
---
|
||||
|
||||
## 💡 Tips for Continuing Development
|
||||
|
||||
1. **Follow Existing Patterns**: Look at the SLM dashboard code for reference
|
||||
2. **Use HTMX Aggressively**: Minimize JavaScript, let HTMX handle updates
|
||||
3. **Keep Routers Thin**: Move business logic to service layer
|
||||
4. **Return HTML Partials**: Most endpoints should return HTML, not JSON
|
||||
5. **Test Incrementally**: Build one partial at a time and test in browser
|
||||
6. **Check Logs**: Scheduler logs execution attempts
|
||||
7. **Use Browser DevTools**: Network tab shows HTMX requests
|
||||
|
||||
---
|
||||
|
||||
## 📞 Support
|
||||
|
||||
For questions or issues:
|
||||
1. Check this document first
|
||||
2. Review existing dashboards (SLM, Seismographs) for patterns
|
||||
3. Check logs for scheduler execution details
|
||||
4. Test API endpoints with curl to isolate issues
|
||||
|
||||
---
|
||||
|
||||
## ✅ Checklist for Completion
|
||||
|
||||
- [x] Database schema designed
|
||||
- [x] Models created
|
||||
- [x] Migration script run successfully
|
||||
- [x] Service layer complete (SLMM client, device controller, scheduler)
|
||||
- [x] API routers created (projects, locations, scheduler)
|
||||
- [x] Navigation updated
|
||||
- [x] Main overview page created
|
||||
- [x] Routes registered in main.py
|
||||
- [x] Scheduler service integrated
|
||||
- [ ] Partial templates created
|
||||
- [ ] Project dashboard page created
|
||||
- [ ] Location management UI
|
||||
- [ ] Unit assignment UI
|
||||
- [ ] Scheduler UI (agenda view)
|
||||
- [ ] SLMM download endpoint implemented
|
||||
- [ ] Full workflow tested end-to-end
|
||||
- [ ] SFM client implemented (future)
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-01-12
|
||||
|
||||
**Database Status**: ✅ Initialized
|
||||
|
||||
**Backend Status**: ✅ Complete
|
||||
|
||||
**Frontend Status**: 🟡 Partial (overview page only)
|
||||
|
||||
**Ready for Testing**: ✅ Yes (basic functionality)
|
||||
17
docs/archive/README.md
Normal file
17
docs/archive/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# Terra-View Documentation Archive
|
||||
|
||||
This directory contains old documentation files that are no longer actively maintained but preserved for historical reference.
|
||||
|
||||
## Archived Documents
|
||||
|
||||
### PROJECTS_SYSTEM_IMPLEMENTATION.md
|
||||
Early implementation notes for the projects system. Superseded by current documentation in main docs directory.
|
||||
|
||||
### .aider.chat.history.md
|
||||
AI assistant chat history from development sessions. Contains context and decision-making process.
|
||||
|
||||
## Note
|
||||
|
||||
These documents may contain outdated information. For current documentation, see:
|
||||
- [Main README](../../README.md)
|
||||
- [Active Documentation](../)
|
||||
@@ -6,3 +6,5 @@ python-multipart==0.0.6
|
||||
jinja2==3.1.2
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
httpx==0.25.2
|
||||
openpyxl==3.1.2
|
||||
|
||||
20
scripts/README.md
Normal file
20
scripts/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Terra-View Utility Scripts
|
||||
|
||||
This directory contains utility scripts for database operations, testing, and maintenance.
|
||||
|
||||
## Scripts
|
||||
|
||||
### create_test_db.py
|
||||
Generate a realistic test database with sample data.
|
||||
|
||||
Usage: python scripts/create_test_db.py
|
||||
|
||||
### rename_unit.py
|
||||
Rename a unit ID across all tables.
|
||||
|
||||
Usage: python scripts/rename_unit.py <old_id> <new_id>
|
||||
|
||||
### sync_slms_to_slmm.py
|
||||
Manually sync all SLM devices from Terra-View to SLMM.
|
||||
|
||||
Usage: python scripts/sync_slms_to_slmm.py
|
||||
39
scripts/add_slm_ftp_port.py
Normal file
39
scripts/add_slm_ftp_port.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add slm_ftp_port column to roster table for FTP data retrieval port
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
import os
|
||||
|
||||
# Determine database based on environment
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
if ENVIRONMENT == "development":
|
||||
DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
else:
|
||||
DB_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
def add_ftp_port_column():
|
||||
print(f"Adding slm_ftp_port column to {DB_URL}...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
# Try to add the column
|
||||
conn.execute(text("ALTER TABLE roster ADD COLUMN slm_ftp_port INTEGER"))
|
||||
conn.commit()
|
||||
print("✓ Added column: slm_ftp_port (INTEGER)")
|
||||
except Exception as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
print(" Column slm_ftp_port already exists, skipping")
|
||||
else:
|
||||
print(f"✗ Error adding slm_ftp_port: {e}")
|
||||
raise
|
||||
|
||||
print("=" * 60)
|
||||
print("Migration completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_ftp_port_column()
|
||||
105
scripts/add_test_modems.py
Executable file
105
scripts/add_test_modems.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add test modem units and assign them to SLMs in DEV database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
from datetime import datetime
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def add_test_modems():
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# Test modems
|
||||
test_modems = [
|
||||
{
|
||||
"id": "modem-001",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Raven XTV modem for Construction Site A",
|
||||
"address": "123 Main St, Construction Site A",
|
||||
"coordinates": "34.0522,-118.2437",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV"
|
||||
},
|
||||
{
|
||||
"id": "modem-002",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Raven XTV modem for Construction Site B",
|
||||
"address": "456 Oak Ave, Construction Site B",
|
||||
"coordinates": "34.0622,-118.2537",
|
||||
"ip_address": "192.168.1.101",
|
||||
"phone_number": "+1-555-0101",
|
||||
"hardware_model": "Raven XTV"
|
||||
},
|
||||
{
|
||||
"id": "modem-003",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Sierra Wireless modem for Residential Area",
|
||||
"address": "789 Elm St, Residential Area",
|
||||
"coordinates": "34.0722,-118.2637",
|
||||
"ip_address": "192.168.1.102",
|
||||
"phone_number": "+1-555-0102",
|
||||
"hardware_model": "Sierra Wireless AirLink"
|
||||
},
|
||||
{
|
||||
"id": "modem-004",
|
||||
"device_type": "modem",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"note": "Spare modem - not deployed",
|
||||
"ip_address": None,
|
||||
"phone_number": "+1-555-0103",
|
||||
"hardware_model": "Raven XTV"
|
||||
}
|
||||
]
|
||||
|
||||
for modem_data in test_modems:
|
||||
# Check if modem already exists
|
||||
existing = db.query(RosterUnit).filter_by(id=modem_data["id"]).first()
|
||||
if existing:
|
||||
print(f"Modem {modem_data['id']} already exists, skipping...")
|
||||
continue
|
||||
|
||||
modem = RosterUnit(**modem_data)
|
||||
db.add(modem)
|
||||
print(f"Added {modem_data['id']}")
|
||||
|
||||
# Assign modems to existing SLMs
|
||||
slm_modem_assignments = {
|
||||
"nl43-001": "modem-001",
|
||||
"nl43-002": "modem-002",
|
||||
"nl53-001": "modem-003"
|
||||
}
|
||||
|
||||
for slm_id, modem_id in slm_modem_assignments.items():
|
||||
slm = db.query(RosterUnit).filter_by(id=slm_id).first()
|
||||
if slm:
|
||||
slm.deployed_with_modem_id = modem_id
|
||||
# Remove legacy slm_host since we're using modems now
|
||||
slm.slm_host = None
|
||||
print(f"Assigned {slm_id} to {modem_id}")
|
||||
else:
|
||||
print(f"SLM {slm_id} not found, skipping assignment...")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print("\nTest modems added and assigned to SLMs successfully in DEV database (data-dev/seismo_fleet.db)!")
|
||||
print("\nModem assignments:")
|
||||
for slm_id, modem_id in slm_modem_assignments.items():
|
||||
print(f" {slm_id} → {modem_id}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_test_modems()
|
||||
106
scripts/add_test_slms.py
Executable file
106
scripts/add_test_slms.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add test Sound Level Meter units to the DEV database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
from datetime import datetime
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def add_test_slms():
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
test_slms = [
|
||||
{
|
||||
"id": "nl43-001",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at construction site A",
|
||||
"address": "123 Main St, Construction Site A",
|
||||
"coordinates": "34.0522,-118.2437",
|
||||
"slm_host": "192.168.1.100",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123456",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl43-002",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at construction site B",
|
||||
"address": "456 Oak Ave, Construction Site B",
|
||||
"coordinates": "34.0622,-118.2537",
|
||||
"slm_host": "192.168.1.101",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123457",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "S",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl53-001",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at residential monitoring",
|
||||
"address": "789 Elm St, Residential Area",
|
||||
"coordinates": "34.0722,-118.2637",
|
||||
"slm_host": "192.168.1.102",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-53",
|
||||
"slm_serial_number": "234567",
|
||||
"slm_frequency_weighting": "C",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "25-140 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl43-003",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"note": "Benched for calibration",
|
||||
"address": None,
|
||||
"coordinates": None,
|
||||
"slm_host": None,
|
||||
"slm_tcp_port": None,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123458",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": None
|
||||
}
|
||||
]
|
||||
|
||||
for slm_data in test_slms:
|
||||
# Check if unit already exists
|
||||
existing = db.query(RosterUnit).filter_by(id=slm_data["id"]).first()
|
||||
if existing:
|
||||
print(f"Unit {slm_data['id']} already exists, skipping...")
|
||||
continue
|
||||
|
||||
unit = RosterUnit(**slm_data)
|
||||
db.add(unit)
|
||||
print(f"Added {slm_data['id']}")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print("\nTest SLM units added successfully to DEV database (data-dev/seismo_fleet.db)!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_test_slms()
|
||||
46
scripts/migrate_dev_db.py
Normal file
46
scripts/migrate_dev_db.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate DEV database to add SLM-specific columns
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def migrate_dev_database():
|
||||
print("Migrating DEV database to add SLM columns...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
# SLM columns to add
|
||||
slm_columns = [
|
||||
("slm_host", "VARCHAR"),
|
||||
("slm_tcp_port", "INTEGER"),
|
||||
("slm_model", "VARCHAR"),
|
||||
("slm_serial_number", "VARCHAR"),
|
||||
("slm_frequency_weighting", "VARCHAR"),
|
||||
("slm_time_weighting", "VARCHAR"),
|
||||
("slm_measurement_range", "VARCHAR"),
|
||||
("slm_last_check", "DATETIME"),
|
||||
]
|
||||
|
||||
with engine.connect() as conn:
|
||||
for column_name, column_type in slm_columns:
|
||||
try:
|
||||
# Try to add the column
|
||||
conn.execute(text(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}"))
|
||||
conn.commit()
|
||||
print(f"✓ Added column: {column_name}")
|
||||
except Exception as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
print(f" Column {column_name} already exists, skipping")
|
||||
else:
|
||||
print(f"✗ Error adding {column_name}: {e}")
|
||||
|
||||
print("=" * 60)
|
||||
print("DEV database migration completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_dev_database()
|
||||
45
scripts/remove_test_data_from_prod.py
Normal file
45
scripts/remove_test_data_from_prod.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remove test SLMs and modems from PRODUCTION database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
|
||||
# PRODUCTION database
|
||||
PROD_DB_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
def remove_test_data():
|
||||
engine = create_engine(PROD_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# IDs to remove
|
||||
test_slm_ids = ["nl43-001", "nl43-002", "nl53-001", "nl43-003"]
|
||||
test_modem_ids = ["modem-001", "modem-002", "modem-003", "modem-004"]
|
||||
|
||||
all_test_ids = test_slm_ids + test_modem_ids
|
||||
|
||||
removed = []
|
||||
for unit_id in all_test_ids:
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if unit:
|
||||
db.delete(unit)
|
||||
removed.append(unit_id)
|
||||
print(f"Removed {unit_id}")
|
||||
|
||||
if removed:
|
||||
db.commit()
|
||||
print(f"\nRemoved {len(removed)} test units from PRODUCTION database")
|
||||
else:
|
||||
print("No test units found in production database")
|
||||
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Removing test data from PRODUCTION database...")
|
||||
print("=" * 60)
|
||||
remove_test_data()
|
||||
print("=" * 60)
|
||||
print("Done! Production database is clean.")
|
||||
138
scripts/rename_unit.py
Normal file
138
scripts/rename_unit.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to rename a unit ID in the database.
|
||||
This updates the unit across all tables with proper foreign key handling.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
DATABASE_URL = "sqlite:///data/sfm.db"
|
||||
|
||||
def rename_unit(old_id: str, new_id: str):
|
||||
"""
|
||||
Rename a unit ID across all relevant tables.
|
||||
|
||||
Args:
|
||||
old_id: Current unit ID (e.g., "SLM4301")
|
||||
new_id: New unit ID (e.g., "SLM-43-01")
|
||||
"""
|
||||
engine = create_engine(DATABASE_URL)
|
||||
Session = sessionmaker(bind=engine)
|
||||
session = Session()
|
||||
|
||||
try:
|
||||
# Check if old unit exists
|
||||
result = session.execute(
|
||||
text("SELECT id, device_type FROM roster WHERE id = :old_id"),
|
||||
{"old_id": old_id}
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
print(f"❌ Error: Unit '{old_id}' not found in roster")
|
||||
return False
|
||||
|
||||
device_type = result[1]
|
||||
print(f"✓ Found unit '{old_id}' (device_type: {device_type})")
|
||||
|
||||
# Check if new ID already exists
|
||||
result = session.execute(
|
||||
text("SELECT id FROM roster WHERE id = :new_id"),
|
||||
{"new_id": new_id}
|
||||
).fetchone()
|
||||
|
||||
if result:
|
||||
print(f"❌ Error: Unit ID '{new_id}' already exists")
|
||||
return False
|
||||
|
||||
print(f"\n🔄 Renaming '{old_id}' → '{new_id}'...\n")
|
||||
|
||||
# Update roster table (primary)
|
||||
session.execute(
|
||||
text("UPDATE roster SET id = :new_id WHERE id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
print(f" ✓ Updated roster")
|
||||
|
||||
# Update emitters table
|
||||
result = session.execute(
|
||||
text("UPDATE emitters SET id = :new_id WHERE id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated emitters ({result.rowcount} rows)")
|
||||
|
||||
# Update unit_history table
|
||||
result = session.execute(
|
||||
text("UPDATE unit_history SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated unit_history ({result.rowcount} rows)")
|
||||
|
||||
# Update deployed_with_modem_id references
|
||||
result = session.execute(
|
||||
text("UPDATE roster SET deployed_with_modem_id = :new_id WHERE deployed_with_modem_id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated modem references ({result.rowcount} rows)")
|
||||
|
||||
# Update unit_assignments table (if exists)
|
||||
try:
|
||||
result = session.execute(
|
||||
text("UPDATE unit_assignments SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated unit_assignments ({result.rowcount} rows)")
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
|
||||
# Update recording_sessions table (if exists)
|
||||
try:
|
||||
result = session.execute(
|
||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
||||
{"new_id": new_id, "old_id": old_id}
|
||||
)
|
||||
if result.rowcount > 0:
|
||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
||||
except Exception:
|
||||
pass # Table may not exist
|
||||
|
||||
# Commit all changes
|
||||
session.commit()
|
||||
print(f"\n✅ Successfully renamed unit '{old_id}' to '{new_id}'")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
session.rollback()
|
||||
print(f"\n❌ Error during rename: {e}")
|
||||
return False
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 3:
|
||||
print("Usage: python rename_unit.py <old_id> <new_id>")
|
||||
print("Example: python rename_unit.py SLM4301 SLM-43-01")
|
||||
sys.exit(1)
|
||||
|
||||
old_id = sys.argv[1]
|
||||
new_id = sys.argv[2]
|
||||
|
||||
print(f"Unit Renaming Tool")
|
||||
print(f"=" * 50)
|
||||
print(f"Old ID: {old_id}")
|
||||
print(f"New ID: {new_id}")
|
||||
print(f"=" * 50)
|
||||
|
||||
confirm = input(f"\nAre you sure you want to rename '{old_id}' to '{new_id}'? (yes/no): ")
|
||||
if confirm.lower() != 'yes':
|
||||
print("❌ Rename cancelled")
|
||||
sys.exit(0)
|
||||
|
||||
success = rename_unit(old_id, new_id)
|
||||
sys.exit(0 if success else 1)
|
||||
67
scripts/sync_slms_to_slmm.py
Executable file
67
scripts/sync_slms_to_slmm.py
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-time script to sync existing SLM devices from Terra-View roster to SLMM cache.
|
||||
Run this after implementing the automatic sync to backfill existing devices.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_all_slms():
|
||||
"""Sync all SLM devices from Terra-View roster to SLMM cache."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get all SLM devices from Terra-View (source of truth)
|
||||
slm_devices = db.query(RosterUnit).filter_by(
|
||||
device_type="slm"
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster")
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for device in slm_devices:
|
||||
logger.info(f"\nProcessing: {device.id}")
|
||||
logger.info(f" Host: {device.slm_host}")
|
||||
logger.info(f" TCP Port: {device.slm_tcp_port}")
|
||||
logger.info(f" Modem: {device.deployed_with_modem_id}")
|
||||
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=device.id,
|
||||
host=device.slm_host,
|
||||
tcp_port=device.slm_tcp_port,
|
||||
ftp_port=device.slm_ftp_port,
|
||||
deployed_with_modem_id=device.deployed_with_modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✓ {device.id}: {result['message']}")
|
||||
success_count += 1
|
||||
else:
|
||||
logger.error(f"✗ {device.id}: {result['message']}")
|
||||
failed_count += 1
|
||||
|
||||
logger.info(f"\n{'='*60}")
|
||||
logger.info(f"Cache sync complete: {success_count} succeeded, {failed_count} failed")
|
||||
logger.info(f"{'='*60}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(sync_all_slms())
|
||||
39
scripts/update_dev_db_schema.py
Normal file
39
scripts/update_dev_db_schema.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update DEV database schema to match current models
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from backend.models import Base
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def update_schema():
|
||||
print("Updating DEV database schema...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
# Create all tables (will update existing tables with new columns)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Inspect to see what we have
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
print(f"Tables in DEV database: {tables}")
|
||||
|
||||
if 'roster' in tables:
|
||||
columns = [col['name'] for col in inspector.get_columns('roster')]
|
||||
print(f"\nColumns in roster table:")
|
||||
for col in sorted(columns):
|
||||
print(f" - {col}")
|
||||
|
||||
print("=" * 60)
|
||||
print("DEV database schema updated successfully!")
|
||||
print("\nNote: SQLite doesn't support ALTER COLUMN, so existing")
|
||||
print("columns won't be modified, but new columns will be added.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_schema()
|
||||
@@ -110,10 +110,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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
Fleet Roster
|
||||
Devices
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
|
||||
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
|
||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||
</svg>
|
||||
@@ -180,7 +194,7 @@
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||
</svg>
|
||||
<span>Roster</span>
|
||||
<span>Devices</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -360,10 +374,10 @@
|
||||
</script>
|
||||
|
||||
<!-- Offline Database -->
|
||||
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||
<script src="/static/offline-db.js?v=0.4.3"></script>
|
||||
|
||||
<!-- Mobile JavaScript -->
|
||||
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||
<script src="/static/mobile.js?v=0.4.3"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -57,6 +57,27 @@
|
||||
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||
<div class="flex justify-between items-center mb-1">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1.5 text-blue-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>
|
||||
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||
</div>
|
||||
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||
</div>
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<div class="flex items-center">
|
||||
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||
</div>
|
||||
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||
@@ -343,6 +364,21 @@ function updateDashboard(event) {
|
||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||
|
||||
// ===== Device type counts =====
|
||||
let seismoCount = 0;
|
||||
let slmCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.retired) return; // Don't count retired units
|
||||
const deviceType = unit.device_type || 'seismograph';
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoCount++;
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
slmCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('seismo-count').textContent = seismoCount;
|
||||
document.getElementById('slm-count').textContent = slmCount;
|
||||
|
||||
// ===== Alerts =====
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
// Only show alerts for deployed units that are MISSING (not pending)
|
||||
|
||||
563
templates/nrl_detail.html
Normal file
563
templates/nrl_detail.html
Normal file
@@ -0,0 +1,563 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ location.name }} - NRL Detail{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Breadcrumb Navigation -->
|
||||
<div class="mb-6">
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
Projects
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ project.name }}
|
||||
</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
{{ location.name }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
Noise Recording Location • {{ project.name }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{% if assigned_unit %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
|
||||
<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="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
Unit Assigned
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
No Unit Assigned
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Navigation -->
|
||||
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex space-x-6">
|
||||
<button onclick="switchTab('overview')"
|
||||
data-tab="overview"
|
||||
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
|
||||
Overview
|
||||
</button>
|
||||
<button onclick="switchTab('settings')"
|
||||
data-tab="settings"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Settings
|
||||
</button>
|
||||
{% if assigned_unit %}
|
||||
<button onclick="switchTab('command')"
|
||||
data-tab="command"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Command Center
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="switchTab('sessions')"
|
||||
data-tab="sessions"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Recording Sessions
|
||||
</button>
|
||||
<button onclick="switchTab('data')"
|
||||
data-tab="data"
|
||||
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
|
||||
Data Files
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content -->
|
||||
<div id="tab-content">
|
||||
<!-- Overview Tab -->
|
||||
<div id="overview-tab" class="tab-panel">
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Location Details Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location Details</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
|
||||
</div>
|
||||
{% if location.description %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if location.address %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if location.coordinates %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
|
||||
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignment Card -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Assignment</h2>
|
||||
{% if assigned_unit %}
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Unit</div>
|
||||
<div class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
<a href="/slm/{{ assigned_unit.id }}?from_project={{ project_id }}&from_nrl={{ location_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
||||
{{ assigned_unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% if assigned_unit.slm_model %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.slm_model }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if assignment %}
|
||||
<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>
|
||||
{% if assignment.notes %}
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div class="pt-2">
|
||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
||||
Unassign Unit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
||||
<button onclick="openAssignModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
Assign a Unit
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
||||
<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">Total Sessions</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ session_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 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="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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Data Files</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ file_count }}</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
||||
<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="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">Active Session</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
||||
{% if active_session %}
|
||||
<span class="text-green-600 dark:text-green-400">Recording</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">Idle</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div id="settings-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
||||
|
||||
<form id="location-settings-form" class="space-y-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||
<input type="text" id="settings-name" value="{{ location.name }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<textarea id="settings-description" rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
||||
placeholder="40.7128,-74.0060"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||
</div>
|
||||
|
||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
||||
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">
|
||||
Save Changes
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Center Tab -->
|
||||
{% if assigned_unit %}
|
||||
<div id="command-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
SLM Command Center - {{ assigned_unit.id }}
|
||||
</h2>
|
||||
|
||||
<div id="slm-command-center"
|
||||
hx-get="/api/slm-dashboard/live-view/{{ assigned_unit.id if assigned_unit else '' }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||
<p>Loading command center...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Recording Sessions Tab -->
|
||||
<div id="sessions-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
||||
{% if assigned_unit %}
|
||||
<button onclick="openScheduleModal()"
|
||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
Schedule Session
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div id="sessions-list"
|
||||
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/sessions"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Data Files Tab -->
|
||||
<div id="data-tab" class="tab-panel hidden">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span class="font-medium">{{ file_count }}</span> files
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="data-files-list"
|
||||
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/files"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">Loading data files...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assign Unit Modal -->
|
||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a sound level meter to this location</p>
|
||||
</div>
|
||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="assign-form" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
||||
<select id="assign-unit-id" name="unit_id"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
||||
<option value="">Loading units...</option>
|
||||
</select>
|
||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units for this location type.</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="assign-notes" name="notes" rows="2"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-2">
|
||||
<button type="button" onclick="closeAssignModal()"
|
||||
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">
|
||||
Assign Unit
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const projectId = "{{ project_id }}";
|
||||
const locationId = "{{ location_id }}";
|
||||
|
||||
// Tab switching
|
||||
function switchTab(tabName) {
|
||||
// Hide all tab panels
|
||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
||||
panel.classList.add('hidden');
|
||||
});
|
||||
|
||||
// Reset all tab buttons
|
||||
document.querySelectorAll('.tab-button').forEach(button => {
|
||||
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
});
|
||||
|
||||
// Show selected tab panel
|
||||
const panel = document.getElementById(`${tabName}-tab`);
|
||||
if (panel) {
|
||||
panel.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Highlight selected tab button
|
||||
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
||||
if (button) {
|
||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
}
|
||||
}
|
||||
|
||||
// Location settings form submission
|
||||
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const payload = {
|
||||
name: document.getElementById('settings-name').value.trim(),
|
||||
description: document.getElementById('settings-description').value.trim() || null,
|
||||
address: document.getElementById('settings-address').value.trim() || null,
|
||||
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||
method: 'PUT',
|
||||
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 update location');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('settings-error');
|
||||
errorEl.textContent = err.message || 'Failed to update location.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Assign modal functions
|
||||
function openAssignModal() {
|
||||
const modal = document.getElementById('assign-modal');
|
||||
modal.classList.remove('hidden');
|
||||
loadAvailableUnits();
|
||||
}
|
||||
|
||||
function closeAssignModal() {
|
||||
document.getElementById('assign-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function loadAvailableUnits() {
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=sound`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load available units');
|
||||
}
|
||||
const data = await response.json();
|
||||
const select = document.getElementById('assign-unit-id');
|
||||
select.innerHTML = '<option value="">Select a unit</option>';
|
||||
|
||||
if (!data.length) {
|
||||
document.getElementById('assign-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
data.forEach(unit => {
|
||||
const option = document.createElement('option');
|
||||
option.value = unit.id;
|
||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to load units.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const unitId = document.getElementById('assign-unit-id').value;
|
||||
const notes = document.getElementById('assign-notes').value.trim();
|
||||
|
||||
if (!unitId) {
|
||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
||||
document.getElementById('assign-error').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('unit_id', unitId);
|
||||
formData.append('notes', notes);
|
||||
|
||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to assign unit');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
const errorEl = document.getElementById('assign-error');
|
||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
||||
errorEl.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
async function unassignUnit(assignmentId) {
|
||||
if (!confirm('Unassign this unit from the location?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to unassign unit');
|
||||
}
|
||||
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err.message || 'Failed to unassign unit.');
|
||||
}
|
||||
}
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeAssignModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Click outside to close modal
|
||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeAssignModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
87
templates/partials/alerts/alert_dropdown.html
Normal file
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
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>
|
||||
453
templates/partials/devices_table.html
Normal file
453
templates/partials/devices_table.html
Normal file
@@ -0,0 +1,453 @@
|
||||
<!-- Desktop Table View -->
|
||||
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||
<tr>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||
<div class="flex items-center gap-1">
|
||||
Status
|
||||
<span class="sort-indicator" data-column="status"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||
<div class="flex items-center gap-1">
|
||||
Unit ID
|
||||
<span class="sort-indicator" data-column="id"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||
<div class="flex items-center gap-1">
|
||||
Type
|
||||
<span class="sort-indicator" data-column="type"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Details
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||
<div class="flex items-center gap-1">
|
||||
Last Seen
|
||||
<span class="sort-indicator" data-column="last_seen"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||
<div class="flex items-center gap-1">
|
||||
Age
|
||||
<span class="sort-indicator" data-column="age"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||
<div class="flex items-center gap-1">
|
||||
Note
|
||||
<span class="sort-indicator" data-column="note"></span>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-id="{{ unit.id }}"
|
||||
data-type="{{ unit.device_type }}"
|
||||
data-last-seen="{{ unit.last_seen }}"
|
||||
data-age="{{ unit.age }}"
|
||||
data-note="{{ unit.note if unit.note else '' }}">
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="flex items-center space-x-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% else %}
|
||||
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.deployed %}
|
||||
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% elif unit.device_type == 'slm' %}
|
||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
||||
SLM
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
{% if unit.ip_address %}
|
||||
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||
{% endif %}
|
||||
{% if unit.phone_number %}
|
||||
<div>{{ unit.phone_number }}</div>
|
||||
{% endif %}
|
||||
{% if unit.hardware_model %}
|
||||
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if unit.next_calibration_due %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<div>
|
||||
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm
|
||||
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||
{% else %}text-gray-500 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{{ unit.age }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||
{{ unit.note if unit.note else '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<div class="flex justify-end gap-2">
|
||||
<button onclick="editUnit('{{ unit.id }}')"
|
||||
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% if unit.deployed %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||
<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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||
<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="M5 13l4 4L19 7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||
<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="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>
|
||||
</button>
|
||||
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||
<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"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<!-- Last updated indicator -->
|
||||
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile Card View -->
|
||||
<div class="md:hidden space-y-3">
|
||||
{% for unit in units %}
|
||||
<div class="unit-card device-card"
|
||||
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||
data-device-type="{{ unit.device_type }}"
|
||||
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||
data-health="{{ unit.status }}"
|
||||
data-unit-id="{{ unit.id }}"
|
||||
data-age="{{ unit.age }}">
|
||||
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div class="flex items-center gap-2">
|
||||
{% if unit.status == 'OK' %}
|
||||
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||
{% elif unit.status == 'Pending' %}
|
||||
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||
{% elif unit.status == 'Missing' %}
|
||||
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||
{% else %}
|
||||
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||
{% endif %}
|
||||
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||
</div>
|
||||
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||
{% endif %}">
|
||||
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Type Badge -->
|
||||
<div class="mb-2">
|
||||
{% if unit.device_type == 'modem' %}
|
||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||
Modem
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||
Seismograph
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
{% if unit.address %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.address }}
|
||||
</div>
|
||||
{% elif unit.coordinates %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.coordinates }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project ID -->
|
||||
{% if unit.project_id %}
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
🏗️ {{ unit.project_id }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Last Seen -->
|
||||
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||
🕐 {{ unit.age }}
|
||||
</div>
|
||||
|
||||
<!-- Deployed/Benched Indicator -->
|
||||
<div class="mt-2">
|
||||
{% if unit.deployed %}
|
||||
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||
⚡ Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||
📦 Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Tap Hint -->
|
||||
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
Tap for details
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<!-- Mobile Last Updated -->
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unit Detail Modal -->
|
||||
<div id="unitModal" class="unit-modal">
|
||||
<!-- Backdrop -->
|
||||
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div class="unit-modal-content">
|
||||
<!-- Handle Bar (Mobile Only) -->
|
||||
<div class="modal-handle"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 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>
|
||||
|
||||
<!-- Content -->
|
||||
<div id="modalContent" class="p-6">
|
||||
<!-- Content will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||
Edit Unit
|
||||
</button>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||
Deploy/Bench
|
||||
</button>
|
||||
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sort-indicator::after {
|
||||
content: '⇅';
|
||||
opacity: 0.3;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sort-indicator.asc::after {
|
||||
content: '↑';
|
||||
opacity: 1;
|
||||
}
|
||||
.sort-indicator.desc::after {
|
||||
content: '↓';
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
// Update timestamp
|
||||
const timestampElement = document.getElementById('last-updated');
|
||||
if (timestampElement) {
|
||||
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||
if (timestampMobileElement) {
|
||||
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||
}
|
||||
|
||||
// Keep a lightweight status map around for the mobile modal
|
||||
const rosterUnits = {{ units | tojson }};
|
||||
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||
acc[unit.id] = {
|
||||
status: unit.status || 'Unknown',
|
||||
age: unit.age || 'N/A',
|
||||
last: unit.last_seen || 'Never'
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Sorting state
|
||||
let currentSort = { column: null, direction: 'asc' };
|
||||
|
||||
function sortTable(column) {
|
||||
const tbody = document.getElementById('roster-tbody');
|
||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||
|
||||
// Determine sort direction
|
||||
if (currentSort.column === column) {
|
||||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||
} else {
|
||||
currentSort.column = column;
|
||||
currentSort.direction = 'asc';
|
||||
}
|
||||
|
||||
// Sort rows
|
||||
rows.sort((a, b) => {
|
||||
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||
|
||||
// Special handling for different column types
|
||||
if (column === 'age') {
|
||||
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||
aVal = parseAge(aVal);
|
||||
bVal = parseAge(bVal);
|
||||
} else if (column === 'status') {
|
||||
// Sort by status priority: Missing > Pending > OK
|
||||
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||
} else if (column === 'last_seen') {
|
||||
// Sort by date
|
||||
aVal = new Date(aVal).getTime() || 0;
|
||||
bVal = new Date(bVal).getTime() || 0;
|
||||
} else {
|
||||
// String comparison (case-insensitive)
|
||||
aVal = aVal.toLowerCase();
|
||||
bVal = bVal.toLowerCase();
|
||||
}
|
||||
|
||||
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Re-append rows in sorted order
|
||||
rows.forEach(row => tbody.appendChild(row));
|
||||
|
||||
// Update sort indicators
|
||||
updateSortIndicators();
|
||||
}
|
||||
|
||||
function parseAge(ageStr) {
|
||||
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||
if (!ageStr) return 0;
|
||||
|
||||
let totalMinutes = 0;
|
||||
const weeks = ageStr.match(/(\d+)w/);
|
||||
const days = ageStr.match(/(\d+)d/);
|
||||
const hours = ageStr.match(/(\d+)h/);
|
||||
const minutes = ageStr.match(/(\d+)m/);
|
||||
|
||||
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||
|
||||
return totalMinutes;
|
||||
}
|
||||
|
||||
function updateSortIndicators() {
|
||||
// Clear all indicators
|
||||
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||
indicator.className = 'sort-indicator';
|
||||
});
|
||||
|
||||
// Set current indicator
|
||||
if (currentSort.column) {
|
||||
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||
if (indicator) {
|
||||
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
30
templates/partials/projects/assignment_list.html
Normal file
30
templates/partials/projects/assignment_list.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!-- Project Assignments List -->
|
||||
{% if assignments %}
|
||||
<div class="space-y-3">
|
||||
{% for item in assignments %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p class="font-semibold text-gray-900 dark:text-white">{{ item.unit.id if item.unit else item.assignment.unit_id }}</p>
|
||||
{% if item.location %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Location: {{ item.location.name }}</p>
|
||||
{% endif %}
|
||||
<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 %}
|
||||
</p>
|
||||
</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">
|
||||
Unassign
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No active assignments</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
620
templates/partials/projects/ftp_browser.html
Normal file
620
templates/partials/projects/ftp_browser.html
Normal file
@@ -0,0 +1,620 @@
|
||||
<!-- FTP File Browser for SLMs v2.0 - Folder Download Support -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
|
||||
|
||||
{% if units %}
|
||||
<div class="space-y-6">
|
||||
{% for unit_item in units %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<!-- Unit Header -->
|
||||
<div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ unit_item.unit.id }}
|
||||
</h3>
|
||||
{% if unit_item.location %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
@ {{ unit_item.location.name }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<span id="ftp-status-{{ unit_item.unit.id }}" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
||||
Checking...
|
||||
</span>
|
||||
</div>
|
||||
<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 }}')"
|
||||
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"
|
||||
disabled>
|
||||
Enable FTP
|
||||
</button>
|
||||
<button onclick="disableFTP('{{ unit_item.unit.id }}')"
|
||||
id="disable-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
||||
disabled>
|
||||
Disable FTP
|
||||
</button>
|
||||
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
|
||||
id="browse-ftp-{{ unit_item.unit.id }}"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
|
||||
disabled>
|
||||
Browse Files
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FTP File List -->
|
||||
<div id="ftp-files-{{ unit_item.unit.id }}" class="hidden" data-location-id="{{ unit_item.location.id if unit_item.location else '' }}">
|
||||
<div class="p-4">
|
||||
<div class="flex items-center gap-2 mb-3">
|
||||
<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="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>
|
||||
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL-43</span>
|
||||
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
|
||||
class="ml-auto text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div id="ftp-file-list-{{ unit_item.unit.id }}" class="space-y-1">
|
||||
<!-- Files will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="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>
|
||||
<p>No units assigned to this project</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function checkFTPStatus(unitId) {
|
||||
const statusSpan = document.getElementById(`ftp-status-${unitId}`);
|
||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
||||
const browseBtn = document.getElementById(`browse-ftp-${unitId}`);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.ftp_enabled) {
|
||||
statusSpan.textContent = 'FTP Enabled';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
||||
enableBtn.disabled = true;
|
||||
disableBtn.disabled = false;
|
||||
browseBtn.disabled = false;
|
||||
} else {
|
||||
statusSpan.textContent = 'FTP Disabled';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
||||
enableBtn.disabled = false;
|
||||
disableBtn.disabled = true;
|
||||
browseBtn.disabled = true;
|
||||
}
|
||||
} catch (error) {
|
||||
statusSpan.textContent = 'Error';
|
||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
||||
console.error('Error checking FTP status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableFTP(unitId) {
|
||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
||||
enableBtn.disabled = true;
|
||||
enableBtn.textContent = 'Enabling...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await checkFTPStatus(unitId);
|
||||
// Auto-load files after enabling
|
||||
setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
|
||||
} else {
|
||||
alert('Failed to enable FTP');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error enabling FTP: ' + error);
|
||||
} finally {
|
||||
enableBtn.textContent = 'Enable FTP';
|
||||
enableBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function disableFTP(unitId) {
|
||||
if (!confirm('Disable FTP on this unit? This will close the FTP connection.')) return;
|
||||
|
||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
||||
disableBtn.disabled = true;
|
||||
disableBtn.textContent = 'Disabling...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await checkFTPStatus(unitId);
|
||||
// Hide file list
|
||||
document.getElementById(`ftp-files-${unitId}`).classList.add('hidden');
|
||||
} else {
|
||||
alert('Failed to disable FTP');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error disabling FTP: ' + error);
|
||||
} finally {
|
||||
disableBtn.textContent = 'Disable FTP';
|
||||
disableBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFTPFiles(unitId, path) {
|
||||
const fileListDiv = document.getElementById(`ftp-file-list-${unitId}`);
|
||||
const filesContainer = document.getElementById(`ftp-files-${unitId}`);
|
||||
const currentPathSpan = document.getElementById(`current-path-${unitId}`);
|
||||
|
||||
// Show loading
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">Loading files...</div>';
|
||||
filesContainer.classList.remove('hidden');
|
||||
currentPathSpan.textContent = path;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.files || data.files.length === 0) {
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">No files found</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: directories first, then files
|
||||
const sorted = data.files.sort((a, b) => {
|
||||
if (a.is_dir && !b.is_dir) return -1;
|
||||
if (!a.is_dir && b.is_dir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
function escapeForAttribute(str) {
|
||||
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getFileIcon(file) {
|
||||
if (file.is_dir) {
|
||||
return '<svg class="w-5 h-5 mr-3 text-blue-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>';
|
||||
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||
return '<svg class="w-5 h-5 mr-3 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||
return '<svg class="w-5 h-5 mr-3 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></svg>';
|
||||
}
|
||||
return '<svg class="w-5 h-5 mr-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||
}
|
||||
|
||||
// Render file list with collapsible folders
|
||||
let html = '<div class="space-y-1">';
|
||||
for (const file of sorted) {
|
||||
const icon = getFileIcon(file);
|
||||
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
|
||||
const folderId = 'proj-folder-' + file.path.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
|
||||
if (file.is_dir) {
|
||||
// Collapsible folder row
|
||||
html += `
|
||||
<div class="border border-gray-200 dark:border-gray-600 rounded mb-1">
|
||||
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer"
|
||||
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(file.path)}', '${folderId}', this)">
|
||||
<div class="flex items-center flex-1">
|
||||
<svg class="w-4 h-4 mr-2 text-gray-400 transition-transform folder-chevron" 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>
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-4">
|
||||
<span class="text-xs text-gray-500 hidden sm:inline">${file.modified || ''}</span>
|
||||
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
|
||||
title="Download folder from device to server and add to project database">
|
||||
<svg class="w-3 h-3 mr-1" 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 & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${folderId}" class="hidden pl-6 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors">
|
||||
<div class="flex items-center flex-1 min-w-0">
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 flex-shrink-0 ml-4">
|
||||
<span class="text-xs text-gray-500 hidden sm:inline">${sizeStr}</span>
|
||||
<span class="text-xs text-gray-500 hidden md:inline">${file.modified || ''}</span>
|
||||
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(file.path)}', '${escapeForAttribute(file.name)}')"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors flex items-center"
|
||||
title="Download file from device to server and add to project database">
|
||||
<svg class="w-3 h-3 mr-1" 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 & Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
fileListDiv.innerHTML = html;
|
||||
} catch (error) {
|
||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
|
||||
console.error('Error loading FTP files:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle folder expand/collapse and load contents for Project FTP browser
|
||||
async function toggleFTPFolderProject(unitId, folderPath, folderId, headerElement) {
|
||||
const contentDiv = document.getElementById(folderId);
|
||||
const chevron = headerElement.querySelector('.folder-chevron');
|
||||
const statusSpan = headerElement.querySelector('.folder-status');
|
||||
|
||||
if (!contentDiv) return;
|
||||
|
||||
const isExpanded = !contentDiv.classList.contains('hidden');
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
contentDiv.classList.add('hidden');
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
// Expand and load contents if not already loaded
|
||||
contentDiv.classList.remove('hidden');
|
||||
chevron.style.transform = 'rotate(90deg)';
|
||||
|
||||
// Check if already loaded
|
||||
if (contentDiv.dataset.loaded === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show loading state
|
||||
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 flex items-center"><svg class="w-4 h-4 mr-2 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>Loading...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(folderPath)}`);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status !== 'ok' && !result.files) {
|
||||
throw new Error(result.detail || 'Failed to list files');
|
||||
}
|
||||
|
||||
const files = result.files || [];
|
||||
|
||||
if (files.length === 0) {
|
||||
contentDiv.innerHTML = '<div class="text-sm text-gray-500 py-2 italic">Empty folder</div>';
|
||||
statusSpan.textContent = '(empty)';
|
||||
contentDiv.dataset.loaded = 'true';
|
||||
return;
|
||||
}
|
||||
|
||||
// Sort: directories first, then by name
|
||||
files.sort((a, b) => {
|
||||
if (a.is_dir && !b.is_dir) return -1;
|
||||
if (!a.is_dir && b.is_dir) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
// Update status with file count
|
||||
const dirCount = files.filter(f => f.is_dir).length;
|
||||
const fileCount = files.length - dirCount;
|
||||
let statusText = [];
|
||||
if (dirCount > 0) statusText.push(`${dirCount} folder${dirCount > 1 ? 's' : ''}`);
|
||||
if (fileCount > 0) statusText.push(`${fileCount} file${fileCount > 1 ? 's' : ''}`);
|
||||
statusSpan.textContent = `(${statusText.join(', ')})`;
|
||||
|
||||
function escapeForAttribute(str) {
|
||||
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = str;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function getFileIcon(file) {
|
||||
if (file.is_dir) {
|
||||
return '<svg class="w-4 h-4 mr-2 text-blue-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>';
|
||||
} else if (file.name.toLowerCase().endsWith('.csv')) {
|
||||
return '<svg class="w-4 h-4 mr-2 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path></svg>';
|
||||
} else if (file.name.toLowerCase().match(/\.(txt|log)$/)) {
|
||||
return '<svg class="w-4 h-4 mr-2 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></svg>';
|
||||
}
|
||||
return '<svg class="w-4 h-4 mr-2 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path></svg>';
|
||||
}
|
||||
|
||||
let html = '<div class="space-y-1 mt-2">';
|
||||
|
||||
files.forEach(file => {
|
||||
const fullPath = file.path || `${folderPath}/${file.name}`;
|
||||
const icon = getFileIcon(file);
|
||||
const sizeText = file.size ? formatFileSize(file.size) : '';
|
||||
const subFolderId = 'proj-folder-' + fullPath.replace(/[^a-zA-Z0-9]/g, '-');
|
||||
|
||||
if (file.is_dir) {
|
||||
// Nested collapsible folder
|
||||
html += `
|
||||
<div class="border border-gray-200 dark:border-gray-600 rounded">
|
||||
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors cursor-pointer text-sm"
|
||||
onclick="toggleFTPFolderProject('${unitId}', '${escapeForAttribute(fullPath)}', '${subFolderId}', this)">
|
||||
<div class="flex items-center flex-1">
|
||||
<svg class="w-3 h-3 mr-2 text-gray-400 transition-transform folder-chevron" 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>
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white font-medium">${escapeHtml(file.name)}</span>
|
||||
<span class="ml-2 text-xs text-gray-400 folder-status"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors flex items-center"
|
||||
title="Download folder to server">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${subFolderId}" class="hidden pl-4 pr-2 pb-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div class="text-sm text-gray-500 py-2">Click to load contents...</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
html += `
|
||||
<div class="flex items-center justify-between p-2 hover:bg-gray-50 dark:hover:bg-gray-700 rounded transition-colors text-sm">
|
||||
<div class="flex items-center flex-1 min-w-0">
|
||||
${icon}
|
||||
<span class="text-gray-900 dark:text-white truncate">${escapeHtml(file.name)}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 flex-shrink-0 ml-2">
|
||||
<span class="text-xs text-gray-500 hidden sm:inline">${sizeText}</span>
|
||||
<button onclick="downloadToServer('${unitId}', '${escapeForAttribute(fullPath)}', '${escapeForAttribute(file.name)}')"
|
||||
class="px-2 py-1 bg-seismo-orange hover:bg-seismo-navy text-white text-xs rounded transition-colors"
|
||||
title="Download to server">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
contentDiv.innerHTML = html;
|
||||
contentDiv.dataset.loaded = 'true';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load folder contents:', error);
|
||||
contentDiv.innerHTML = `<div class="text-sm text-red-500 py-2">Error: ${error.message}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFTPFile(unitId, remotePath, fileName) {
|
||||
const btn = event.target;
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Downloading...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
remote_path: remotePath
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert('Download failed: ' + (errorData.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading file: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Download';
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFTPFolder(unitId, remotePath, folderName) {
|
||||
const btn = event.target;
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
remote_path: remotePath
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const blob = await response.blob();
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${folderName}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(url);
|
||||
document.body.removeChild(a);
|
||||
|
||||
// Show success message
|
||||
alert(`✓ Folder "${folderName}" downloaded successfully as ZIP file!`);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
alert('Folder download failed: ' + (errorData.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading folder: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadFolderToServer(unitId, remotePath, folderName) {
|
||||
const btn = event.target;
|
||||
const originalHTML = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
|
||||
|
||||
// Get location_id from the unit's data attribute
|
||||
const unitContainer = btn.closest('[id^="ftp-files-"]');
|
||||
const locationId = unitContainer.dataset.locationId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-folder-to-server`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
unit_id: unitId,
|
||||
remote_path: remotePath,
|
||||
location_id: locationId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
alert(`✓ Folder "${folderName}" downloaded successfully!\n\n${data.file_count} files extracted\nTotal size: ${formatFileSize(data.total_size)}\n\nFiles are now available in the Project Files section below.`);
|
||||
|
||||
// Refresh the unified files list
|
||||
htmx.trigger('#unified-files', 'refresh');
|
||||
} else {
|
||||
alert('Folder download to server failed: ' + (data.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading folder to server: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHTML;
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadToServer(unitId, remotePath, fileName) {
|
||||
const btn = event.target;
|
||||
const originalText = btn.innerHTML;
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 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>Downloading...';
|
||||
|
||||
// Get location_id from the unit's data attribute
|
||||
const unitContainer = btn.closest('[id^="ftp-files-"]');
|
||||
const locationId = unitContainer.dataset.locationId;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-to-server`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
unit_id: unitId,
|
||||
remote_path: remotePath,
|
||||
location_id: locationId
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
alert(`✓ ${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
|
||||
|
||||
// Refresh the unified files list
|
||||
htmx.trigger('#unified-files', 'refresh');
|
||||
} else {
|
||||
alert('Download to server failed: ' + (data.detail || 'Unknown error'));
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Error downloading to server: ' + error);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalText;
|
||||
}
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
||||
}
|
||||
|
||||
// Check FTP status for all units on load
|
||||
// Use setTimeout to ensure DOM elements exist when HTMX loads this partial
|
||||
setTimeout(function() {
|
||||
{% for unit_item in units %}
|
||||
checkFTPStatus('{{ unit_item.unit.id }}');
|
||||
{% endfor %}
|
||||
}, 100);
|
||||
</script>
|
||||
|
||||
<!-- Include the unified SLM Settings Modal -->
|
||||
{% include 'partials/slm_settings_modal.html' %}
|
||||
69
templates/partials/projects/location_list.html
Normal file
69
templates/partials/projects/location_list.html
Normal file
@@ -0,0 +1,69 @@
|
||||
<!-- Project Locations List -->
|
||||
{% if locations %}
|
||||
<div class="space-y-3">
|
||||
{% for item in locations %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
|
||||
class="font-semibold text-gray-900 dark:text-white hover:text-seismo-orange truncate">
|
||||
{{ item.location.name }}
|
||||
</a>
|
||||
{% if item.location.location_type %}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
{{ item.location.location_type|capitalize }}
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if item.location.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
|
||||
{% endif %}
|
||||
{% if item.location.address %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
|
||||
{% endif %}
|
||||
{% if item.location.coordinates %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.coordinates }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.assignment %}
|
||||
<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">
|
||||
Unassign
|
||||
</button>
|
||||
{% else %}
|
||||
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
|
||||
Assign
|
||||
</button>
|
||||
{% endif %}
|
||||
<button data-location='{{ {"id": item.location.id, "name": item.location.name, "description": item.location.description, "address": item.location.address, "coordinates": item.location.coordinates, "location_type": item.location.location_type} | tojson }}'
|
||||
onclick="openEditLocationModal(this)"
|
||||
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
Edit
|
||||
</button>
|
||||
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
|
||||
<span>Sessions: {{ item.session_count }}</span>
|
||||
{% if item.assignment and item.assigned_unit %}
|
||||
<span>Assigned: {{ item.assigned_unit.id }}</span>
|
||||
{% else %}
|
||||
<span>No active assignment</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No locations added yet</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
95
templates/partials/projects/project_dashboard.html
Normal file
95
templates/partials/projects/project_dashboard.html
Normal file
@@ -0,0 +1,95 @@
|
||||
<!-- Project Dashboard -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-900 dark:text-white">{{ project.name }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
|
||||
{% if project_type %}
|
||||
{{ project_type.name }}
|
||||
{% else %}
|
||||
Project
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
{% if project.status == 'active' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif project.status == 'completed' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Completed</span>
|
||||
{% elif project.status == 'archived' %}
|
||||
<span class="px-3 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Archived</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if project.description %}
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-4 max-w-3xl">{{ project.description }}</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ locations | length }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Assigned Units</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ assigned_units | length }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Active Sessions</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ active_sessions | length }}</p>
|
||||
</div>
|
||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Completed Sessions</p>
|
||||
<p class="text-2xl font-semibold text-gray-900 dark:text-white">{{ completed_sessions_count }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
NRLs
|
||||
{% else %}
|
||||
Locations
|
||||
{% endif %}
|
||||
</h3>
|
||||
<button onclick="openLocationModal('{% if project_type and project_type.id == 'sound_monitoring' %}sound{% elif project_type and project_type.id == 'vibration_monitoring' %}vibration{% else %}{% endif %}')" class="text-sm text-seismo-orange hover:text-seismo-navy">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
Add NRL
|
||||
{% else %}
|
||||
Add Location
|
||||
{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
<div id="project-locations"
|
||||
hx-get="/api/projects/{{ project.id }}/locations{% if project_type and project_type.id == 'sound_monitoring' %}?location_type=sound{% endif %}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="animate-pulse space-y-3">
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
<div class="bg-gray-200 dark:bg-gray-700 h-16 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Upcoming Actions</h3>
|
||||
{% if upcoming_actions %}
|
||||
<div class="space-y-3">
|
||||
{% for action in upcoming_actions %}
|
||||
<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="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.scheduled_time.strftime('%Y-%m-%d %H:%M') }}</p>
|
||||
{% if action.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ action.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">No scheduled actions.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
30
templates/partials/projects/project_header.html
Normal file
30
templates/partials/projects/project_header.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<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>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
{{ project.status|title }}
|
||||
</span>
|
||||
{% if project_type %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</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>
|
||||
97
templates/partials/projects/project_list.html
Normal file
97
templates/partials/projects/project_list.html
Normal file
@@ -0,0 +1,97 @@
|
||||
<!-- Project List Grid -->
|
||||
{% if projects %}
|
||||
{% for item in projects %}
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<a href="/projects/{{ item.project.id }}" class="block p-6">
|
||||
<!-- Project Header -->
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
{{ item.project.name }}
|
||||
</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 flex items-center">
|
||||
{% if item.project_type %}
|
||||
{% if item.project_type.id == 'sound_monitoring' %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
{% elif item.project_type.id == 'vibration_monitoring' %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
</svg>
|
||||
{% else %}
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"></path>
|
||||
</svg>
|
||||
{% endif %}
|
||||
{{ item.project_type.name }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status Badge -->
|
||||
{% if item.project.status == 'active' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400 rounded-full">
|
||||
Active
|
||||
</span>
|
||||
{% elif item.project.status == 'completed' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
{% elif item.project.status == 'archived' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-400 rounded-full">
|
||||
Archived
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Project Description -->
|
||||
{% if item.project.description %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4 line-clamp-2">
|
||||
{{ item.project.description }}
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Project Stats -->
|
||||
<div class="grid grid-cols-3 gap-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Locations</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.location_count }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Units</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">{{ item.unit_count }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Active</p>
|
||||
<p class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if item.active_session_count > 0 %}
|
||||
<span class="text-green-600 dark:text-green-400">{{ item.active_session_count }}</span>
|
||||
{% else %}
|
||||
{{ item.active_session_count }}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Client Info -->
|
||||
{% if item.project.client_name %}
|
||||
<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">
|
||||
Client: <span class="font-medium text-gray-700 dark:text-gray-300">{{ item.project.client_name }}</span>
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="col-span-full flex flex-col items-center justify-center py-12 text-gray-400 dark:text-gray-500">
|
||||
<svg class="w-16 h-16 mb-4" 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>
|
||||
<p class="text-lg font-medium">No projects found</p>
|
||||
<p class="text-sm mt-1">Create your first project to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
41
templates/partials/projects/project_list_compact.html
Normal file
41
templates/partials/projects/project_list_compact.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!-- Compact Project List -->
|
||||
{% if projects %}
|
||||
{% for item in projects %}
|
||||
<a href="/projects/{{ item.project.id }}" class="block 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">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">
|
||||
{{ item.project.name }}
|
||||
</h3>
|
||||
{% if item.project.client_name %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Client: {{ item.project.client_name }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.project.status == 'active' %}
|
||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
|
||||
{% elif item.project.status == 'completed' %}
|
||||
<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">Completed</span>
|
||||
{% elif item.project.status == 'archived' %}
|
||||
<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">Archived</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span>{{ item.location_count }} locations</span>
|
||||
<span>{{ item.unit_count }} units</span>
|
||||
<span>{{ item.active_session_count }} active</span>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No active sound monitoring projects</p>
|
||||
<p class="text-sm mt-1">Create a project to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
58
templates/partials/projects/project_stats.html
Normal file
58
templates/partials/projects/project_stats.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- Project Statistics Cards -->
|
||||
<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-500 dark:text-gray-400">Total Projects</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_projects }}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-seismo-orange/10 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="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>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-500 dark:text-gray-400">Active Projects</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400">{{ active_projects }}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-green-100 dark:bg-green-900/30 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>
|
||||
</div>
|
||||
|
||||
<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-500 dark:text-gray-400">Total Locations</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ total_locations }}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-blue-100 dark:bg-blue-900/30 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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-500 dark:text-gray-400">Active Sessions</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ active_sessions }}</p>
|
||||
</div>
|
||||
<div class="p-3 bg-purple-100 dark:bg-purple-900/30 rounded-lg">
|
||||
<svg class="w-8 h-8 text-purple-600 dark:text-purple-400" 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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
58
templates/partials/projects/project_type_cards.html
Normal file
58
templates/partials/projects/project_type_cards.html
Normal file
@@ -0,0 +1,58 @@
|
||||
<!-- Project Type Selection Cards -->
|
||||
{% for pt in project_types %}
|
||||
<button onclick="selectProjectType('{{ pt.id }}', '{{ pt.name }}')"
|
||||
class="bg-white dark:bg-slate-700 hover:bg-gray-50 dark:hover:bg-slate-600 border-2 border-gray-200 dark:border-gray-600 hover:border-seismo-orange rounded-lg p-6 text-left transition-all">
|
||||
<!-- Icon -->
|
||||
<div class="mb-4">
|
||||
{% if pt.id == 'sound_monitoring' %}
|
||||
<div class="w-12 h-12 bg-seismo-orange/10 rounded-lg flex items-center justify-center">
|
||||
<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="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% elif pt.id == 'vibration_monitoring' %}
|
||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 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="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>
|
||||
</div>
|
||||
{% elif pt.id == 'combined' %}
|
||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
{{ pt.name }}
|
||||
</h3>
|
||||
|
||||
<!-- Description -->
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
{{ pt.description }}
|
||||
</p>
|
||||
|
||||
<!-- Features -->
|
||||
<div class="space-y-1">
|
||||
{% if pt.supports_sound %}
|
||||
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Sound Level Meters
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if pt.supports_vibration %}
|
||||
<div class="flex items-center text-xs text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-4 h-4 mr-2 text-green-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Seismographs
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</button>
|
||||
{% endfor %}
|
||||
151
templates/partials/projects/recurring_schedule_list.html
Normal file
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.strftime('%Y-%m-%d %H:%M') }} {{ item.schedule.timezone }}
|
||||
</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
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
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>
|
||||
149
templates/partials/projects/schedule_list.html
Normal file
149
templates/partials/projects/schedule_list.html
Normal file
@@ -0,0 +1,149 @@
|
||||
<!-- Scheduled Actions List -->
|
||||
{% if schedules %}
|
||||
<div class="space-y-4">
|
||||
{% for item in schedules %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
{{ item.schedule.action_type }}
|
||||
</h4>
|
||||
{% 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">
|
||||
Pending
|
||||
</span>
|
||||
{% 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">
|
||||
Completed
|
||||
</span>
|
||||
{% 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">
|
||||
Failed
|
||||
</span>
|
||||
{% 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">
|
||||
Cancelled
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if item.location %}
|
||||
<div>
|
||||
<span class="text-xs 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 %}
|
||||
|
||||
<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 %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Executed:</span>
|
||||
<span class="ml-1">{{ item.schedule.executed_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.schedule.description %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{{ item.schedule.description }}
|
||||
</p>
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.schedule.execution_status == 'pending' %}
|
||||
<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">
|
||||
Execute Now
|
||||
</button>
|
||||
<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">
|
||||
Cancel
|
||||
</button>
|
||||
{% 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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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-gray-500 dark:text-gray-400 mb-2">No scheduled actions yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Create schedules to automate tasks</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function executeSchedule(scheduleId) {
|
||||
if (!confirm('Execute this scheduled action now?')) return;
|
||||
|
||||
fetch(`/api/projects/{{ project_id }}/schedules/${scheduleId}/execute`, {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
htmx.trigger('#project-schedules', 'refresh');
|
||||
} else {
|
||||
alert('Error: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error executing schedule: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function cancelSchedule(scheduleId) {
|
||||
if (!confirm('Cancel this scheduled action?')) return;
|
||||
|
||||
fetch(`/api/projects/{{ project_id }}/schedules/${scheduleId}/cancel`, {
|
||||
method: 'POST',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
htmx.trigger('#project-schedules', 'refresh');
|
||||
} else {
|
||||
alert('Error: ' + (data.message || 'Unknown error'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
alert('Error cancelling schedule: ' + error);
|
||||
});
|
||||
}
|
||||
|
||||
function viewScheduleDetails(scheduleId) {
|
||||
// TODO: Implement schedule details modal
|
||||
alert('Schedule details coming soon: ' + scheduleId);
|
||||
}
|
||||
</script>
|
||||
107
templates/partials/projects/session_list.html
Normal file
107
templates/partials/projects/session_list.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!-- Recording Sessions List -->
|
||||
{% if sessions %}
|
||||
<div class="space-y-4">
|
||||
{% for item in sessions %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
Session {{ item.session.id[:8] }}...
|
||||
</h4>
|
||||
{% if item.session.status == 'recording' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
||||
Recording
|
||||
</span>
|
||||
{% elif item.session.status == 'completed' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
||||
Completed
|
||||
</span>
|
||||
{% elif item.session.status == 'paused' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">
|
||||
Paused
|
||||
</span>
|
||||
{% elif item.session.status == 'failed' %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300 rounded-full">
|
||||
Failed
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if item.unit %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500 dark:text-gray-500">Unit:</span>
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
||||
{{ item.unit.id }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{% if item.session.stopped_at %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Ended:</span>
|
||||
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if item.session.duration_seconds %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Duration:</span>
|
||||
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.session.notes %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{{ item.session.notes }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{% if item.session.status == 'recording' %}
|
||||
<button onclick="stopRecording('{{ item.session.id }}')"
|
||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||
Stop
|
||||
</button>
|
||||
{% endif %}
|
||||
<button onclick="viewSession('{{ item.session.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>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function viewSession(sessionId) {
|
||||
// TODO: Implement session detail modal or page
|
||||
alert('Session details coming soon: ' + sessionId);
|
||||
}
|
||||
|
||||
function stopRecording(sessionId) {
|
||||
if (!confirm('Stop this recording session?')) return;
|
||||
|
||||
// TODO: Implement stop recording API call
|
||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
||||
}
|
||||
</script>
|
||||
315
templates/partials/projects/unified_files.html
Normal file
315
templates/partials/projects/unified_files.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!-- Unified Files View - Database + Filesystem - Collapsible Sessions -->
|
||||
{% if sessions %}
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for session_data in sessions %}
|
||||
{% set session = session_data.session %}
|
||||
{% set unit = session_data.unit %}
|
||||
{% set location = session_data.location %}
|
||||
{% set files = session_data.files %}
|
||||
|
||||
{% if files %}
|
||||
<!-- Session Container -->
|
||||
<div class="session-container">
|
||||
<!-- Session Header - Clickable to expand/collapse -->
|
||||
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-900/50 cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-800/50 transition-colors"
|
||||
onclick="toggleSession('session-{{ session.id }}', this)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<!-- Chevron indicator -->
|
||||
<svg class="w-4 h-4 text-gray-400 transition-transform session-chevron" 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>
|
||||
<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<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' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit %}{{ unit.id }}{% else %}Unknown Unit{% endif %}
|
||||
{% if location %} @ {{ location.name }}{% endif %}
|
||||
<span class="mx-2">•</span>
|
||||
{{ files|length }} file{{ 's' if files|length != 1 else '' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-1 text-xs rounded-full
|
||||
{% if session.status == 'recording' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
|
||||
{% elif session.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
|
||||
{% elif session.status == 'paused' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ session.status or 'unknown' }}
|
||||
</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>
|
||||
|
||||
<!-- Files List - Hidden by default -->
|
||||
<div id="session-{{ session.id }}" class="hidden px-6 py-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<div class="space-y-1">
|
||||
{% for file_data in files %}
|
||||
{% set file = file_data.file %}
|
||||
{% set exists = file_data.exists_on_disk %}
|
||||
{% set metadata = file_data.metadata %}
|
||||
|
||||
<div class="flex items-center gap-3 px-4 py-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg transition-colors group">
|
||||
<!-- File Icon -->
|
||||
{% if file.file_type == 'audio' %}
|
||||
<svg class="w-6 h-6 text-blue-500" 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>
|
||||
{% elif file.file_type == 'archive' %}
|
||||
<svg class="w-6 h-6 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'log' %}
|
||||
<svg class="w-6 h-6 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
{% elif file.file_type == 'image' %}
|
||||
<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>
|
||||
</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 %}
|
||||
<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>
|
||||
</svg>
|
||||
{% endif %}
|
||||
|
||||
<!-- File Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ file.file_path.split('/')[-1] if file.file_path else 'Unknown' }}
|
||||
</div>
|
||||
{% if not exists %}
|
||||
<span class="px-2 py-0.5 text-xs bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded">
|
||||
Missing on disk
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||
<!-- File Type Badge -->
|
||||
<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
|
||||
{% 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 == '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
|
||||
{% else %}bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300{% endif %}">
|
||||
{{ file.file_type or 'unknown' }}
|
||||
</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 -->
|
||||
<span class="mx-1">•</span>
|
||||
{% if file.file_size_bytes %}
|
||||
{% if file.file_size_bytes < 1024 %}
|
||||
{{ file.file_size_bytes }} B
|
||||
{% elif file.file_size_bytes < 1048576 %}
|
||||
{{ "%.1f"|format(file.file_size_bytes / 1024) }} KB
|
||||
{% elif file.file_size_bytes < 1073741824 %}
|
||||
{{ "%.1f"|format(file.file_size_bytes / 1048576) }} MB
|
||||
{% else %}
|
||||
{{ "%.2f"|format(file.file_size_bytes / 1073741824) }} GB
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown size
|
||||
{% endif %}
|
||||
|
||||
<!-- Download Time -->
|
||||
{% if file.downloaded_at %}
|
||||
<span class="mx-1">•</span>
|
||||
{{ file.downloaded_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Source Info from Metadata -->
|
||||
{% if metadata.unit_id %}
|
||||
<span class="mx-1">•</span>
|
||||
from {{ metadata.unit_id }}
|
||||
{% endif %}
|
||||
|
||||
<!-- Checksum Indicator -->
|
||||
{% if file.checksum %}
|
||||
<span class="mx-1" title="SHA256: {{ file.checksum[:16] }}...">
|
||||
<svg class="w-3 h-3 inline text-green-600" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
{% if exists %}
|
||||
<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 }}')"
|
||||
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">
|
||||
<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
|
||||
</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>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="px-6 py-12 text-center">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No files downloaded yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">
|
||||
Use the FTP browser above to download files from your sound level meters
|
||||
</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function toggleSession(sessionId, headerElement) {
|
||||
const contentDiv = document.getElementById(sessionId);
|
||||
const chevron = headerElement.querySelector('.session-chevron');
|
||||
|
||||
if (!contentDiv) return;
|
||||
|
||||
const isExpanded = !contentDiv.classList.contains('hidden');
|
||||
|
||||
if (isExpanded) {
|
||||
// Collapse
|
||||
contentDiv.classList.add('hidden');
|
||||
chevron.style.transform = 'rotate(0deg)';
|
||||
} else {
|
||||
// Expand
|
||||
contentDiv.classList.remove('hidden');
|
||||
chevron.style.transform = 'rotate(90deg)';
|
||||
}
|
||||
}
|
||||
|
||||
function downloadFile(fileId) {
|
||||
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>
|
||||
99
templates/partials/projects/unit_list.html
Normal file
99
templates/partials/projects/unit_list.html
Normal file
@@ -0,0 +1,99 @@
|
||||
<!-- Assigned Units List -->
|
||||
{% if units %}
|
||||
<div class="space-y-4">
|
||||
{% for item in units %}
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h4 class="font-semibold text-gray-900 dark:text-white">
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}" class="hover:text-seismo-orange">
|
||||
{{ item.unit.id }}
|
||||
</a>
|
||||
</h4>
|
||||
{% if item.active_session %}
|
||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
||||
Recording
|
||||
</span>
|
||||
{% else %}
|
||||
<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">
|
||||
Available
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if item.location %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">
|
||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
||||
NRL:
|
||||
{% else %}
|
||||
Location:
|
||||
{% endif %}
|
||||
</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 %}
|
||||
|
||||
{% if item.unit.slm_model %}
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Model:</span>
|
||||
<span class="ml-1">{{ item.unit.slm_model }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Sessions:</span>
|
||||
<span class="ml-1">{{ item.session_count }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span class="text-xs text-gray-500">Files:</span>
|
||||
<span class="ml-1">{{ item.file_count }}</span>
|
||||
</div>
|
||||
|
||||
{% if item.assignment.assigned_at %}
|
||||
<div class="col-span-2">
|
||||
<span class="text-xs text-gray-500">Assigned:</span>
|
||||
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if item.unit.note %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
{{ item.unit.note }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}"
|
||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||
View Unit
|
||||
</a>
|
||||
{% if item.location %}
|
||||
<a href="/projects/{{ project_id }}/nrl/{{ item.location.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">
|
||||
View NRL
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<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>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No units assigned yet</p>
|
||||
<p class="text-sm text-gray-400 dark:text-gray-500">Assign units to locations to get started</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
56
templates/partials/seismo_stats.html
Normal file
56
templates/partials/seismo_stats.html
Normal file
@@ -0,0 +1,56 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<!-- Total Seismographs -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-blue-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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployed -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Benched -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- With Modem -->
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
|
||||
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
|
||||
{% if without_modem > 0 %}
|
||||
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<svg class="w-12 h-12 text-blue-500" 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>
|
||||
97
templates/partials/seismo_unit_list.html
Normal file
97
templates/partials/seismo_unit_list.html
Normal file
@@ -0,0 +1,97 @@
|
||||
{% if units %}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full">
|
||||
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for unit in units %}
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.id }}
|
||||
</a>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
{% if unit.deployed %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Deployed
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Benched
|
||||
</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||
{% if unit.address %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||
{% elif unit.coordinates %}
|
||||
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||
{% if unit.note %}
|
||||
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||
{% else %}
|
||||
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View Details →
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if search %}
|
||||
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="text-center py-12">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-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>
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
||||
{% if search %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
||||
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
||||
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||
Clear search
|
||||
</button>
|
||||
{% else %}
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
288
templates/partials/slm_config_form.html
Normal file
288
templates/partials/slm_config_form.html
Normal file
@@ -0,0 +1,288 @@
|
||||
<form id="slm-config-form"
|
||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleConfigSave(event)">
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||
</div>
|
||||
|
||||
<!-- Model & Serial -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select model...</option>
|
||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>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" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., SN123456">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency & Time Weighting -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>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 name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurement Range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select range...</option>
|
||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||
<div class="flex gap-2">
|
||||
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 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">
|
||||
<option value="">No modem (direct connection)</option>
|
||||
<!-- Options loaded via JavaScript -->
|
||||
</select>
|
||||
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
|
||||
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 {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
|
||||
title="Ping modem to test 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 a modem for network access, or leave blank for direct IP connection</p>
|
||||
</div>
|
||||
|
||||
<!-- Port Configuration (always visible) -->
|
||||
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '2255' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="2255">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</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" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="21">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
|
||||
</div>
|
||||
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
|
||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="192.168.1.100">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onclick="closeConfigModal()"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
|
||||
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">
|
||||
Test SLM Connection
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Load modems list for dropdown
|
||||
async function loadModemsForConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/roster/modems');
|
||||
const modems = await response.json();
|
||||
|
||||
const select = document.getElementById('config-modem-select');
|
||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||
|
||||
// Keep the "No modem" option
|
||||
modems.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
|
||||
option.textContent = modem.id + ipText;
|
||||
if (modem.id === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load modems:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direct IP field and test modem button based on modem selection
|
||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||
const directIpField = document.getElementById('direct-ip-field');
|
||||
const testModemBtn = document.getElementById('test-modem-btn');
|
||||
|
||||
if (this.value === '') {
|
||||
directIpField.classList.remove('hidden');
|
||||
testModemBtn.disabled = true;
|
||||
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||
} else {
|
||||
directIpField.classList.add('hidden');
|
||||
testModemBtn.disabled = false;
|
||||
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle save response
|
||||
function handleConfigSave(event) {
|
||||
if (event.detail.successful) {
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Configuration saved successfully!';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
closeConfigModal();
|
||||
// Refresh the unit list
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Failed to save configuration';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to modem (health ping)
|
||||
async function testModemConnection() {
|
||||
const modemSelect = document.getElementById('config-modem-select');
|
||||
const modemId = modemSelect.value;
|
||||
|
||||
if (!modemId) {
|
||||
alert('Please select a modem first');
|
||||
return;
|
||||
}
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Pinging modem...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'success') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
const ipAddr = data.ip_address || modemId;
|
||||
const respTime = data.response_time || 'N/A';
|
||||
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 4000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to SLM
|
||||
async function testSLMConnection(unitId) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Testing SLM connection...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/slmm/' + unitId + '/status');
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'online') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = '⚠ SLM connection failed or device offline';
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Load modems on page load
|
||||
loadModemsForConfig();
|
||||
</script>
|
||||
215
templates/partials/slm_config_form_old.html
Normal file
215
templates/partials/slm_config_form_old.html
Normal file
@@ -0,0 +1,215 @@
|
||||
<form id="slm-config-form"
|
||||
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="handleConfigSave(event)">
|
||||
|
||||
<div class="mb-6">
|
||||
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||
</div>
|
||||
|
||||
<!-- Model & Serial -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select model...</option>
|
||||
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>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" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||
placeholder="e.g., SN123456">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Frequency & Time Weighting -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>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 name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select...</option>
|
||||
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Measurement Range -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">Select range...</option>
|
||||
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Network Configuration -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<option value="">No modem (direct connection)</option>
|
||||
<!-- Options loaded via JavaScript -->
|
||||
</select>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||
</div>
|
||||
|
||||
<!-- Legacy direct connection (shown only if no modem selected) -->
|
||||
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||
<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">Direct IP Address</label>
|
||||
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="192.168.1.100">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '' }}"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||
placeholder="502">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex justify-end gap-3 mt-6">
|
||||
<button type="button" onclick="closeConfigModal()"
|
||||
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button" onclick="testConnection('{{ unit.id }}')"
|
||||
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">
|
||||
Test Connection
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||
Save Configuration
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
// Load modems list for dropdown
|
||||
async function loadModemsForConfig() {
|
||||
try {
|
||||
const response = await fetch('/api/roster/modems');
|
||||
const modems = await response.json();
|
||||
|
||||
const select = document.getElementById('config-modem-select');
|
||||
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||
|
||||
// Keep the "No modem" option
|
||||
modems.forEach(modem => {
|
||||
const option = document.createElement('option');
|
||||
option.value = modem.id;
|
||||
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
|
||||
if (modem.id === currentValue) {
|
||||
option.selected = true;
|
||||
}
|
||||
select.appendChild(option);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load modems:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle direct connection fields based on modem selection
|
||||
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||
const directFields = document.getElementById('direct-connection-fields');
|
||||
if (this.value === '') {
|
||||
directFields.classList.remove('hidden');
|
||||
} else {
|
||||
directFields.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle save response
|
||||
function handleConfigSave(event) {
|
||||
if (event.detail.successful) {
|
||||
// Show success message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Configuration saved successfully!';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
closeConfigModal();
|
||||
// Refresh the unit list
|
||||
htmx.trigger('#slm-list', 'load');
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error message
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Failed to save configuration';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Test connection to SLM
|
||||
async function testConnection(unitId) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
toast.textContent = 'Testing connection...';
|
||||
document.body.appendChild(toast);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
toast.remove();
|
||||
|
||||
const resultToast = document.createElement('div');
|
||||
if (response.ok && data.status === 'online') {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
|
||||
} else {
|
||||
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
resultToast.textContent = `Connection failed or device offline`;
|
||||
}
|
||||
document.body.appendChild(resultToast);
|
||||
|
||||
setTimeout(() => {
|
||||
resultToast.remove();
|
||||
}, 3000);
|
||||
} catch (error) {
|
||||
toast.remove();
|
||||
const errorToast = document.createElement('div');
|
||||
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||
errorToast.textContent = '✗ Connection test failed';
|
||||
document.body.appendChild(errorToast);
|
||||
|
||||
setTimeout(() => {
|
||||
errorToast.remove();
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// Load modems on page load
|
||||
loadModemsForConfig();
|
||||
</script>
|
||||
105
templates/partials/slm_controls.html
Normal file
105
templates/partials/slm_controls.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<!-- Status Bar -->
|
||||
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
||||
<div class="flex justify-between items-center">
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
||||
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
||||
{% if measurement_state %}
|
||||
{{ measurement_state }}
|
||||
{% if is_measuring %}
|
||||
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
Unknown
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
||||
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{{ battery_level or '--' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
||||
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if is_measuring %}disabled{% endif %}>
|
||||
<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="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>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
||||
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||
{% if not is_measuring %}disabled{% endif %}>
|
||||
<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="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 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="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
||||
hx-swap="none"
|
||||
hx-confirm="Are you sure you want to reset the measurement data?"
|
||||
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 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="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>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
||||
hx-swap="none"
|
||||
hx-indicator="#live-spinner"
|
||||
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg id="live-spinner" class="htmx-indicator w-4 h-4 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>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Get Live Data
|
||||
</button>
|
||||
|
||||
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
||||
hx-swap="none"
|
||||
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
Store Data
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
||||
70
templates/partials/slm_device_list.html
Normal file
70
templates/partials/slm_device_list.html
Normal file
@@ -0,0 +1,70 @@
|
||||
<!-- SLM Device List -->
|
||||
{% if units %}
|
||||
{% for unit in units %}
|
||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
||||
<div class="absolute top-3 right-3 flex gap-2">
|
||||
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||
title="View live chart">
|
||||
<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 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>
|
||||
</button>
|
||||
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
|
||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
||||
title="Configure {{ unit.id }}">
|
||||
<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="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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a href="/slm/{{ unit.id }}" class="block">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
||||
{% if unit.slm_model %}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if unit.address %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
||||
{% elif unit.location %}
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if unit.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 not unit.deployed %}
|
||||
<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 unit.measurement_state == "Start" %}
|
||||
<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">Measuring</span>
|
||||
{% elif unit.is_recent %}
|
||||
<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">Active</span>
|
||||
{% else %}
|
||||
<span class="shrink-0 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">Idle</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if unit.slm_last_check %}
|
||||
Last check: {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
No recent check-in
|
||||
{% endif %}
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No sound level meters found</p>
|
||||
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
2176
templates/partials/slm_live_view.html
Normal file
2176
templates/partials/slm_live_view.html
Normal file
File diff suppressed because it is too large
Load Diff
438
templates/partials/slm_live_view.html.backup
Normal file
438
templates/partials/slm_live_view.html.backup
Normal file
@@ -0,0 +1,438 @@
|
||||
<!-- Live View Panel for {{ unit.id }} -->
|
||||
<div class="h-full flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
|
||||
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
|
||||
</p>
|
||||
{% if modem %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
|
||||
</p>
|
||||
{% elif modem_ip %}
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
Direct: {{ modem_ip }}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
|
||||
⚠️ No modem assigned or IP configured
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Measurement Status Badge -->
|
||||
<div>
|
||||
{% if is_measuring %}
|
||||
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||
Measuring
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||
Stopped
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Control Buttons -->
|
||||
<div class="flex gap-2 mb-6">
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
||||
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" 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>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'pause')"
|
||||
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
Pause
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'stop')"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button onclick="controlUnit('{{ unit.id }}', 'reset')"
|
||||
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" 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>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
|
||||
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
Start Live Stream
|
||||
</button>
|
||||
|
||||
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
|
||||
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||
</svg>
|
||||
Stop Live Stream
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Current Metrics -->
|
||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
||||
<p id="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
||||
<p id="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
||||
<p id="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live Chart -->
|
||||
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||
<canvas id="liveChart"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- Device Info -->
|
||||
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Power:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
|
||||
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
|
||||
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script>
|
||||
// Initialize Chart.js for live data visualization
|
||||
function initializeChart() {
|
||||
// Wait for Chart.js to load
|
||||
if (typeof Chart === 'undefined') {
|
||||
console.log('Waiting for Chart.js to load...');
|
||||
setTimeout(initializeChart, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Chart.js loaded, version:', Chart.version);
|
||||
|
||||
const canvas = document.getElementById('liveChart');
|
||||
if (!canvas) {
|
||||
console.error('Chart canvas not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Canvas found:', canvas);
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
||||
console.log('Destroying existing chart');
|
||||
window.liveChart.destroy();
|
||||
}
|
||||
|
||||
const ctx = canvas.getContext('2d');
|
||||
console.log('Creating new chart...');
|
||||
|
||||
// Dark mode detection
|
||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||
|
||||
window.liveChart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: [],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Lp (Instantaneous)',
|
||||
data: [],
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
},
|
||||
{
|
||||
label: 'Leq (Equivalent)',
|
||||
data: [],
|
||||
borderColor: 'rgb(34, 197, 94)',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
tension: 0.3,
|
||||
borderWidth: 2,
|
||||
pointRadius: 0
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
grid: {
|
||||
color: gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: textColor,
|
||||
maxTicksLimit: 10
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Sound Level (dB)',
|
||||
color: textColor
|
||||
},
|
||||
grid: {
|
||||
color: gridColor
|
||||
},
|
||||
ticks: {
|
||||
color: textColor
|
||||
},
|
||||
min: 30,
|
||||
max: 130
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
labels: {
|
||||
color: textColor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Chart created successfully:', window.liveChart);
|
||||
}
|
||||
|
||||
// Initialize chart when DOM is ready
|
||||
console.log('Executing initializeChart...');
|
||||
initializeChart();
|
||||
|
||||
// WebSocket management (use global scope to avoid redeclaration)
|
||||
if (typeof window.currentWebSocket === 'undefined') {
|
||||
window.currentWebSocket = null;
|
||||
}
|
||||
|
||||
function initLiveDataStream(unitId) {
|
||||
// Close existing connection if any
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
}
|
||||
|
||||
// Reset chart data
|
||||
if (window.chartData) {
|
||||
window.chartData.timestamps = [];
|
||||
window.chartData.lp = [];
|
||||
window.chartData.leq = [];
|
||||
}
|
||||
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||
window.liveChart.data.labels = [];
|
||||
window.liveChart.data.datasets[0].data = [];
|
||||
window.liveChart.data.datasets[1].data = [];
|
||||
window.liveChart.update();
|
||||
}
|
||||
|
||||
// WebSocket URL for SLMM backend via proxy
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||
|
||||
window.currentWebSocket = new WebSocket(wsUrl);
|
||||
|
||||
window.currentWebSocket.onopen = function() {
|
||||
console.log('WebSocket connected');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'none';
|
||||
if (stopBtn) stopBtn.style.display = 'flex';
|
||||
};
|
||||
|
||||
window.currentWebSocket.onmessage = function(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('WebSocket data received:', data);
|
||||
updateLiveMetrics(data);
|
||||
updateLiveChart(data);
|
||||
} catch (error) {
|
||||
console.error('Error parsing WebSocket message:', error);
|
||||
}
|
||||
};
|
||||
|
||||
window.currentWebSocket.onerror = function(error) {
|
||||
console.error('WebSocket error:', error);
|
||||
};
|
||||
|
||||
window.currentWebSocket.onclose = function() {
|
||||
console.log('WebSocket closed');
|
||||
// Toggle button visibility
|
||||
const startBtn = document.getElementById('start-stream-btn');
|
||||
const stopBtn = document.getElementById('stop-stream-btn');
|
||||
if (startBtn) startBtn.style.display = 'flex';
|
||||
if (stopBtn) stopBtn.style.display = 'none';
|
||||
};
|
||||
}
|
||||
|
||||
function stopLiveDataStream() {
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
window.currentWebSocket = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Update metrics display
|
||||
function updateLiveMetrics(data) {
|
||||
if (document.getElementById('live-lp')) {
|
||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||
}
|
||||
if (document.getElementById('live-leq')) {
|
||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmax')) {
|
||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||
}
|
||||
if (document.getElementById('live-lmin')) {
|
||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||
}
|
||||
if (document.getElementById('live-lpeak')) {
|
||||
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||
}
|
||||
}
|
||||
|
||||
// Chart data storage (use global scope to avoid redeclaration)
|
||||
if (typeof window.chartData === 'undefined') {
|
||||
window.chartData = {
|
||||
timestamps: [],
|
||||
lp: [],
|
||||
leq: []
|
||||
};
|
||||
}
|
||||
|
||||
// Update live chart
|
||||
function updateLiveChart(data) {
|
||||
const now = new Date();
|
||||
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||
|
||||
// Keep only last 60 data points
|
||||
if (window.chartData.timestamps.length > 60) {
|
||||
window.chartData.timestamps.shift();
|
||||
window.chartData.lp.shift();
|
||||
window.chartData.leq.shift();
|
||||
}
|
||||
|
||||
// Update chart if available
|
||||
if (window.liveChart) {
|
||||
window.liveChart.data.labels = window.chartData.timestamps;
|
||||
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||
window.liveChart.update('none');
|
||||
}
|
||||
}
|
||||
|
||||
// Control function
|
||||
async function controlUnit(unitId, action) {
|
||||
try {
|
||||
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
const result = await response.json();
|
||||
|
||||
if (result.status === 'ok') {
|
||||
// Reload the live view to update status
|
||||
setTimeout(() => {
|
||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||
target: '#live-view-panel',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}, 500);
|
||||
} else {
|
||||
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Failed to control unit: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
8
templates/partials/slm_live_view_error.html
Normal file
8
templates/partials/slm_live_view_error.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<!-- Error State for Live View -->
|
||||
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
|
||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<p class="text-lg font-medium">Error Loading Unit</p>
|
||||
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||
</div>
|
||||
534
templates/partials/slm_settings_modal.html
Normal file
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>
|
||||
61
templates/partials/slm_stats.html
Normal file
61
templates/partials/slm_stats.html
Normal file
@@ -0,0 +1,61 @@
|
||||
<!-- Summary stat cards -->
|
||||
<!-- Total Units 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 Units</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="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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deployed Units 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">Deployed</p>
|
||||
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_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>
|
||||
</div>
|
||||
|
||||
<!-- Active Now 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">Active Now</p>
|
||||
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
|
||||
</div>
|
||||
|
||||
<!-- Benched Units 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>
|
||||
73
templates/partials/slm_unit_list.html
Normal file
73
templates/partials/slm_unit_list.html
Normal file
@@ -0,0 +1,73 @@
|
||||
<!-- SLM Unit List -->
|
||||
{% if units %}
|
||||
{% for unit in units %}
|
||||
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
|
||||
<!-- Configure button (appears on hover) -->
|
||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
|
||||
title="Configure {{ unit.id }}">
|
||||
<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="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>
|
||||
</button>
|
||||
|
||||
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="font-semibold">{{ unit.id }}</span>
|
||||
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
|
||||
{% if unit.slm_last_check %}
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
|
||||
{% else %}
|
||||
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="text-sm space-y-1">
|
||||
{% if unit.slm_model %}
|
||||
<div class="flex items-center">
|
||||
<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="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>
|
||||
{{ unit.slm_model }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.address %}
|
||||
<div class="flex items-center">
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="truncate">{{ unit.address }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if unit.deployed_with_modem_id %}
|
||||
<div class="flex items-center font-mono text-xs">
|
||||
<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="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>
|
||||
{{ unit.deployed_with_modem_id }}
|
||||
</div>
|
||||
{% elif unit.slm_host %}
|
||||
<div class="flex items-center font-mono text-xs">
|
||||
<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="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>
|
||||
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<div class="text-center py-8 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="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||
</svg>
|
||||
<p>No sound level meters found</p>
|
||||
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
1244
templates/projects/detail.html
Normal file
1244
templates/projects/detail.html
Normal file
File diff suppressed because it is too large
Load Diff
249
templates/projects/overview.html
Normal file
249
templates/projects/overview.html
Normal file
@@ -0,0 +1,249 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Projects - Terra-View{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Projects</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage monitoring projects, locations, and schedules</p>
|
||||
</div>
|
||||
<button onclick="showCreateProjectModal()"
|
||||
class="px-6 py-3 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium transition-colors">
|
||||
<svg class="w-5 h-5 inline-block mr-2" 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>
|
||||
New Project
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary Stats -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||
hx-get="/api/projects/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>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="flex space-x-8 px-6" aria-label="Tabs">
|
||||
<button onclick="switchTab('all')"
|
||||
id="tab-all"
|
||||
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
|
||||
All Projects
|
||||
</button>
|
||||
<button onclick="switchTab('active')"
|
||||
id="tab-active"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
Active
|
||||
</button>
|
||||
<button onclick="switchTab('completed')"
|
||||
id="tab-completed"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
Completed
|
||||
</button>
|
||||
<button onclick="switchTab('archived')"
|
||||
id="tab-archived"
|
||||
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
|
||||
Archived
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Projects List -->
|
||||
<div id="projects-list"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
hx-get="/api/projects/list"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Loading skeletons -->
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-64 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-64 rounded-xl"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-64 rounded-xl"></div>
|
||||
</div>
|
||||
|
||||
<!-- Create Project Modal -->
|
||||
<div id="createProjectModal" 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-y-auto">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Create New Project</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a project type and configure settings</p>
|
||||
</div>
|
||||
|
||||
<div class="p-6" id="createProjectContent">
|
||||
<!-- Step 1: Project Type Selection (initially shown) -->
|
||||
<div id="projectTypeSelection">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Choose Project Type</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4"
|
||||
hx-get="/api/projects/types/list"
|
||||
hx-trigger="load"
|
||||
hx-target="this"
|
||||
hx-swap="innerHTML">
|
||||
<!-- Project type cards will be loaded here -->
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Project Details Form (hidden initially) -->
|
||||
<div id="projectDetailsForm" class="hidden">
|
||||
<button onclick="backToTypeSelection()"
|
||||
class="mb-4 text-seismo-orange hover:text-seismo-navy">
|
||||
← Back to project types
|
||||
</button>
|
||||
|
||||
<form id="createProjectFormElement"
|
||||
hx-post="/api/projects/create"
|
||||
hx-swap="none">
|
||||
<input type="hidden" id="project_type_id" name="project_type_id">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Project Name *
|
||||
</label>
|
||||
<input type="text"
|
||||
name="name"
|
||||
required
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Description
|
||||
</label>
|
||||
<textarea name="description"
|
||||
rows="3"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Client Name
|
||||
</label>
|
||||
<input type="text"
|
||||
name="client_name"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Address
|
||||
</label>
|
||||
<input type="text"
|
||||
name="site_address"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Start Date
|
||||
</label>
|
||||
<input type="date"
|
||||
name="start_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
End Date (Optional)
|
||||
</label>
|
||||
<input type="date"
|
||||
name="end_date"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Site Coordinates (Optional)
|
||||
</label>
|
||||
<input type="text"
|
||||
name="site_coordinates"
|
||||
placeholder="40.7128,-74.0060"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end space-x-3">
|
||||
<button type="button"
|
||||
onclick="hideCreateProjectModal()"
|
||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit"
|
||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
||||
Create Project
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Tab switching
|
||||
function switchTab(status) {
|
||||
// Update tab styling
|
||||
document.querySelectorAll('.tab-button').forEach(btn => {
|
||||
btn.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
||||
btn.classList.add('border-transparent', 'text-gray-500');
|
||||
});
|
||||
|
||||
const activeTab = document.getElementById(`tab-${status}`);
|
||||
activeTab.classList.remove('border-transparent', 'text-gray-500');
|
||||
activeTab.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||
|
||||
// Load projects for this status
|
||||
const statusParam = status === 'all' ? '' : `?status=${status}`;
|
||||
htmx.ajax('GET', `/api/projects/list${statusParam}`, {target: '#projects-list'});
|
||||
}
|
||||
|
||||
// Modal controls
|
||||
function showCreateProjectModal() {
|
||||
document.getElementById('createProjectModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideCreateProjectModal() {
|
||||
document.getElementById('createProjectModal').classList.add('hidden');
|
||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
||||
}
|
||||
|
||||
function selectProjectType(typeId, typeName) {
|
||||
document.getElementById('project_type_id').value = typeId;
|
||||
document.getElementById('projectTypeSelection').classList.add('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function backToTypeSelection() {
|
||||
document.getElementById('projectTypeSelection').classList.remove('hidden');
|
||||
document.getElementById('projectDetailsForm').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Handle form submission success
|
||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.elt.id === 'createProjectFormElement' && event.detail.successful) {
|
||||
hideCreateProjectModal();
|
||||
// Refresh project list
|
||||
htmx.ajax('GET', '/api/projects/list', {target: '#projects-list'});
|
||||
// Show success message
|
||||
alert('Project created successfully!');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
309
templates/report_preview.html
Normal file
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
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 %}
|
||||
@@ -1,20 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %}
|
||||
{% block title %}Devices - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Devices</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage all devices in your fleet</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white 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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Add Unit
|
||||
Add Device
|
||||
</button>
|
||||
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -31,69 +31,67 @@
|
||||
<!-- Loading placeholder -->
|
||||
</div>
|
||||
|
||||
<!-- Fleet Roster with Tabs -->
|
||||
<!-- Devices View with Filters -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="mb-4">
|
||||
<!-- Filter Controls -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<!-- Search Bar -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="roster-search"
|
||||
placeholder="Search by Unit ID, Type, or Note..."
|
||||
id="device-search"
|
||||
placeholder="Search by Device ID, Type, or Note..."
|
||||
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
|
||||
onkeyup="filterRosterTable()">
|
||||
onkeyup="filterDevices()">
|
||||
<svg class="absolute left-3 top-3 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>
|
||||
|
||||
<!-- Filter Pills -->
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- Device Type Filter -->
|
||||
<div class="flex gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Type:</span>
|
||||
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
|
||||
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
|
||||
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
|
||||
<button class="filter-btn filter-device-type" data-value="slm">SLMs</button>
|
||||
</div>
|
||||
|
||||
<!-- Status Filter -->
|
||||
<div class="flex gap-2">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Status:</span>
|
||||
<button class="filter-btn filter-status active-filter" data-value="all">All</button>
|
||||
<button class="filter-btn filter-status" data-value="deployed">Deployed</button>
|
||||
<button class="filter-btn filter-status" data-value="benched">Benched</button>
|
||||
<button class="filter-btn filter-status" data-value="retired">Retired</button>
|
||||
<button class="filter-btn filter-status" data-value="ignored">Ignored</button>
|
||||
</div>
|
||||
|
||||
<!-- Health Status Filter (for non-retired/ignored devices) -->
|
||||
<div class="flex gap-2" id="health-filter-group">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300 self-center">Health:</span>
|
||||
<button class="filter-btn filter-health active-filter" data-value="all">All</button>
|
||||
<button class="filter-btn filter-health" data-value="ok">OK</button>
|
||||
<button class="filter-btn filter-health" data-value="pending">Pending</button>
|
||||
<button class="filter-btn filter-health" data-value="missing">Missing</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Results Count -->
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing <span id="visible-count">0</span> of <span id="total-count">0</span> devices
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium roster-tab-button active-roster-tab"
|
||||
data-endpoint="/partials/roster-deployed"
|
||||
hx-get="/partials/roster-deployed"
|
||||
hx-target="#roster-content"
|
||||
hx-swap="innerHTML">
|
||||
Deployed
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||
data-endpoint="/partials/roster-benched"
|
||||
hx-get="/partials/roster-benched"
|
||||
hx-target="#roster-content"
|
||||
hx-swap="innerHTML">
|
||||
Benched
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||
data-endpoint="/partials/roster-retired"
|
||||
hx-get="/partials/roster-retired"
|
||||
hx-target="#roster-content"
|
||||
hx-swap="innerHTML">
|
||||
Retired
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||
data-endpoint="/partials/roster-ignored"
|
||||
hx-get="/partials/roster-ignored"
|
||||
hx-target="#roster-content"
|
||||
hx-swap="innerHTML">
|
||||
Ignored
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Target -->
|
||||
<div id="roster-content"
|
||||
hx-get="/partials/roster-deployed"
|
||||
<!-- Device List Container -->
|
||||
<div id="device-content"
|
||||
hx-get="/partials/devices-all"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading roster data...</p>
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading devices...</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -114,9 +112,9 @@
|
||||
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
|
||||
<input type="text" name="id" required
|
||||
<input type="text" name="id" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
|
||||
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="BE1234">
|
||||
placeholder="BE1234 or MODEM-001 (no spaces)">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
|
||||
@@ -124,6 +122,7 @@
|
||||
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="seismograph">Seismograph</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="sound_level_meter">Sound Level Meter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -186,6 +185,54 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
||||
<input type="text" name="slm_model" placeholder="NL-43"
|
||||
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">Host/IP Address</label>
|
||||
<input type="text" name="slm_host" placeholder="192.168.1.100"
|
||||
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">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" placeholder="2255"
|
||||
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">FTP Port</label>
|
||||
<input type="number" name="slm_ftp_port" placeholder="21"
|
||||
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">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" placeholder="SN123456"
|
||||
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">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting"
|
||||
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="A">A-weighting</option>
|
||||
<option value="C">C-weighting</option>
|
||||
<option value="Z">Z-weighting (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||
<select name="slm_time_weighting"
|
||||
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="F">F (Fast)</option>
|
||||
<option value="S">S (Slow)</option>
|
||||
<option value="I">I (Impulse)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
||||
@@ -241,6 +288,7 @@
|
||||
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="seismograph">Seismograph</option>
|
||||
<option value="modem">Modem</option>
|
||||
<option value="sound_level_meter">Sound Level Meter</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
@@ -304,6 +352,56 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sound Level Meter-specific fields -->
|
||||
<div id="editSlmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
||||
<input type="text" name="slm_model" id="editSlmModel" placeholder="NL-43"
|
||||
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">Host/IP Address</label>
|
||||
<input type="text" name="slm_host" id="editSlmHost" placeholder="192.168.1.100"
|
||||
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">TCP Port</label>
|
||||
<input type="number" name="slm_tcp_port" id="editSlmTcpPort" placeholder="2255"
|
||||
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">FTP Port</label>
|
||||
<input type="number" name="slm_ftp_port" id="editSlmFtpPort" placeholder="21"
|
||||
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">Serial Number</label>
|
||||
<input type="text" name="slm_serial_number" id="editSlmSerialNumber" placeholder="SN123456"
|
||||
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">Frequency Weighting</label>
|
||||
<select name="slm_frequency_weighting" id="editSlmFrequencyWeighting"
|
||||
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 set</option>
|
||||
<option value="A">A-weighting</option>
|
||||
<option value="C">C-weighting</option>
|
||||
<option value="Z">Z-weighting (Flat)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||
<select name="slm_time_weighting" id="editSlmTimeWeighting"
|
||||
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 set</option>
|
||||
<option value="F">F (Fast)</option>
|
||||
<option value="S">S (Slow)</option>
|
||||
<option value="I">I (Impulse)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
||||
@@ -325,6 +423,12 @@
|
||||
<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
|
||||
</button>
|
||||
<button type="button" onclick="openRenameUnitModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
||||
</svg>
|
||||
Rename
|
||||
</button>
|
||||
<button type="button" onclick="closeEditUnitModal()" 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 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
@@ -333,6 +437,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Rename Unit Modal -->
|
||||
<div id="renameUnitModal" 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-2xl font-bold text-gray-900 dark:text-white">Rename Unit</h2>
|
||||
<button onclick="closeRenameUnitModal()" 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="renameUnitForm" class="p-6 space-y-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important: Renaming Changes All References</p>
|
||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
||||
This will update the unit ID everywhere including history, assignments, and sessions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Unit ID</label>
|
||||
<input type="text" id="renameOldId" readonly
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed font-mono">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Unit ID *</label>
|
||||
<input type="text" id="renameNewId" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
|
||||
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-blue-500 font-mono"
|
||||
placeholder="Enter new unit ID (no spaces)">
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
|
||||
Rename Unit
|
||||
</button>
|
||||
<button type="button" onclick="closeRenameUnitModal()" 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 transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div id="importModal" 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-lg w-full mx-4">
|
||||
@@ -388,17 +545,45 @@
|
||||
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||
const seismoFields = document.getElementById('seismographFields');
|
||||
const modemFields = document.getElementById('modemFields');
|
||||
const slmFields = document.getElementById('slmFields');
|
||||
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoFields.classList.remove('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.add('hidden');
|
||||
// Enable seismograph fields, disable others
|
||||
setFieldsDisabled(seismoFields, false);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
toggleModemPairing(); // Check if modem pairing should be shown
|
||||
} else {
|
||||
} else if (deviceType === 'modem') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.remove('hidden');
|
||||
slmFields.classList.add('hidden');
|
||||
// Enable modem fields, disable others
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, false);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.remove('hidden');
|
||||
// Enable SLM fields, disable others
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, false);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to disable/enable all inputs in a container
|
||||
function setFieldsDisabled(container, disabled) {
|
||||
if (!container) return;
|
||||
const inputs = container.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
input.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle modem pairing field visibility (only for deployed seismographs)
|
||||
function toggleModemPairing() {
|
||||
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||
@@ -473,7 +658,29 @@
|
||||
// Show success message
|
||||
alert('Unit added successfully!');
|
||||
} else {
|
||||
alert('Error adding unit. Please check the form and try again.');
|
||||
// Log detailed error information
|
||||
console.error('Error adding unit:', {
|
||||
status: event.detail.xhr.status,
|
||||
response: event.detail.xhr.responseText,
|
||||
headers: event.detail.xhr.getAllResponseHeaders()
|
||||
});
|
||||
|
||||
// Try to parse error message from response
|
||||
let errorMsg = 'Error adding unit. Please check the form and try again.';
|
||||
try {
|
||||
const response = JSON.parse(event.detail.xhr.responseText);
|
||||
if (response.detail) {
|
||||
if (typeof response.detail === 'string') {
|
||||
errorMsg = response.detail;
|
||||
} else if (Array.isArray(response.detail)) {
|
||||
errorMsg = response.detail.map(err => `${err.loc?.join('.')}: ${err.msg}`).join('\n');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Could not parse error response:', e);
|
||||
}
|
||||
|
||||
alert(errorMsg);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -492,14 +699,30 @@
|
||||
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||
const seismoFields = document.getElementById('editSeismographFields');
|
||||
const modemFields = document.getElementById('editModemFields');
|
||||
const slmFields = document.getElementById('editSlmFields');
|
||||
|
||||
if (deviceType === 'seismograph') {
|
||||
seismoFields.classList.remove('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.add('hidden');
|
||||
setFieldsDisabled(seismoFields, false);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
toggleEditModemPairing();
|
||||
} else {
|
||||
} else if (deviceType === 'modem') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.remove('hidden');
|
||||
slmFields.classList.add('hidden');
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, false);
|
||||
setFieldsDisabled(slmFields, true);
|
||||
} else if (deviceType === 'sound_level_meter') {
|
||||
seismoFields.classList.add('hidden');
|
||||
modemFields.classList.add('hidden');
|
||||
slmFields.classList.remove('hidden');
|
||||
setFieldsDisabled(seismoFields, true);
|
||||
setFieldsDisabled(modemFields, true);
|
||||
setFieldsDisabled(slmFields, false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,6 +772,15 @@
|
||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||
|
||||
// SLM fields
|
||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
||||
document.getElementById('editSlmHost').value = unit.slm_host || '';
|
||||
document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || '';
|
||||
document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || '';
|
||||
document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || '';
|
||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
||||
|
||||
// Store unit ID for form submission
|
||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||
|
||||
@@ -821,33 +1053,285 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Filter roster table based on search input
|
||||
function filterRosterTable() {
|
||||
const searchInput = document.getElementById('roster-search').value.toLowerCase();
|
||||
const table = document.querySelector('#roster-content table tbody');
|
||||
// ===== DEVICE FILTERING SYSTEM =====
|
||||
|
||||
if (!table) return;
|
||||
// Current active filters
|
||||
let activeFilters = {
|
||||
deviceType: 'all',
|
||||
status: 'all',
|
||||
health: 'all',
|
||||
search: ''
|
||||
};
|
||||
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
// Initialize filter button click handlers
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Device type filter buttons
|
||||
document.querySelectorAll('.filter-device-type').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Update active state
|
||||
document.querySelectorAll('.filter-device-type').forEach(b => b.classList.remove('active-filter'));
|
||||
this.classList.add('active-filter');
|
||||
|
||||
for (let row of rows) {
|
||||
const cells = row.getElementsByTagName('td');
|
||||
if (cells.length === 0) continue; // Skip header or empty rows
|
||||
// Update filter value
|
||||
activeFilters.deviceType = this.dataset.value;
|
||||
|
||||
const unitId = cells[1]?.textContent?.toLowerCase() || '';
|
||||
const unitType = cells[2]?.textContent?.toLowerCase() || '';
|
||||
const note = cells[6]?.textContent?.toLowerCase() || '';
|
||||
// Apply filters
|
||||
filterDevices();
|
||||
});
|
||||
});
|
||||
|
||||
const matches = unitId.includes(searchInput) ||
|
||||
unitType.includes(searchInput) ||
|
||||
note.includes(searchInput);
|
||||
// Status filter buttons
|
||||
document.querySelectorAll('.filter-status').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Update active state
|
||||
document.querySelectorAll('.filter-status').forEach(b => b.classList.remove('active-filter'));
|
||||
this.classList.add('active-filter');
|
||||
|
||||
row.style.display = matches ? '' : 'none';
|
||||
// Update filter value
|
||||
activeFilters.status = this.dataset.value;
|
||||
|
||||
// Toggle health filter visibility (hide for retired/ignored)
|
||||
const healthGroup = document.getElementById('health-filter-group');
|
||||
if (this.dataset.value === 'retired' || this.dataset.value === 'ignored') {
|
||||
healthGroup.style.display = 'none';
|
||||
} else {
|
||||
healthGroup.style.display = 'flex';
|
||||
}
|
||||
|
||||
// Apply filters
|
||||
filterDevices();
|
||||
});
|
||||
});
|
||||
|
||||
// Health status filter buttons
|
||||
document.querySelectorAll('.filter-health').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
// Update active state
|
||||
document.querySelectorAll('.filter-health').forEach(b => b.classList.remove('active-filter'));
|
||||
this.classList.add('active-filter');
|
||||
|
||||
// Update filter value
|
||||
activeFilters.health = this.dataset.value;
|
||||
|
||||
// Apply filters
|
||||
filterDevices();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Main filter function - filters devices based on all active criteria
|
||||
function filterDevices() {
|
||||
const searchInput = document.getElementById('device-search')?.value.toLowerCase() || '';
|
||||
activeFilters.search = searchInput;
|
||||
|
||||
const table = document.querySelector('#device-content table tbody');
|
||||
const cards = document.querySelectorAll('#device-content .device-card'); // For mobile view
|
||||
|
||||
let visibleCount = 0;
|
||||
let totalCount = 0;
|
||||
|
||||
// Filter table rows (desktop view)
|
||||
if (table) {
|
||||
const rows = table.getElementsByTagName('tr');
|
||||
totalCount = rows.length;
|
||||
|
||||
for (let row of rows) {
|
||||
const cells = row.getElementsByTagName('td');
|
||||
if (cells.length === 0) continue;
|
||||
|
||||
// Extract row data (adjust indices based on your table structure)
|
||||
const status = cells[0]?.querySelector('.status-badge')?.textContent?.toLowerCase() || '';
|
||||
const deviceId = cells[1]?.textContent?.toLowerCase() || '';
|
||||
const deviceType = cells[2]?.textContent?.toLowerCase() || '';
|
||||
const note = cells[6]?.textContent?.toLowerCase() || '';
|
||||
|
||||
// Get data attributes for filtering
|
||||
const rowDeviceType = row.dataset.deviceType || '';
|
||||
const rowStatus = row.dataset.status || '';
|
||||
const rowHealth = row.dataset.health || '';
|
||||
|
||||
// Apply filters
|
||||
const matchesSearch = !searchInput ||
|
||||
deviceId.includes(searchInput) ||
|
||||
deviceType.includes(searchInput) ||
|
||||
note.includes(searchInput);
|
||||
|
||||
const matchesDeviceType = activeFilters.deviceType === 'all' ||
|
||||
rowDeviceType === activeFilters.deviceType;
|
||||
|
||||
const matchesStatus = activeFilters.status === 'all' ||
|
||||
rowStatus === activeFilters.status;
|
||||
|
||||
const matchesHealth = activeFilters.health === 'all' ||
|
||||
rowHealth === activeFilters.health ||
|
||||
activeFilters.status === 'retired' ||
|
||||
activeFilters.status === 'ignored';
|
||||
|
||||
const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth;
|
||||
|
||||
row.style.display = isVisible ? '' : 'none';
|
||||
if (isVisible) visibleCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Filter cards (mobile view)
|
||||
if (cards.length > 0) {
|
||||
totalCount = cards.length;
|
||||
visibleCount = 0;
|
||||
|
||||
cards.forEach(card => {
|
||||
const cardDeviceType = card.dataset.deviceType || '';
|
||||
const cardStatus = card.dataset.status || '';
|
||||
const cardHealth = card.dataset.health || '';
|
||||
const cardText = card.textContent.toLowerCase();
|
||||
|
||||
const matchesSearch = !searchInput || cardText.includes(searchInput);
|
||||
const matchesDeviceType = activeFilters.deviceType === 'all' || cardDeviceType === activeFilters.deviceType;
|
||||
const matchesStatus = activeFilters.status === 'all' || cardStatus === activeFilters.status;
|
||||
const matchesHealth = activeFilters.health === 'all' || cardHealth === activeFilters.health;
|
||||
|
||||
const isVisible = matchesSearch && matchesDeviceType && matchesStatus && matchesHealth;
|
||||
|
||||
card.style.display = isVisible ? '' : 'none';
|
||||
if (isVisible) visibleCount++;
|
||||
});
|
||||
}
|
||||
|
||||
// Update count display
|
||||
document.getElementById('visible-count').textContent = visibleCount;
|
||||
document.getElementById('total-count').textContent = totalCount;
|
||||
}
|
||||
|
||||
// Legacy function name for compatibility
|
||||
function filterRosterTable() {
|
||||
filterDevices();
|
||||
}
|
||||
|
||||
// Rename Unit Modal Functions
|
||||
function openRenameUnitModal() {
|
||||
const currentUnitId = document.getElementById('editUnitId').value;
|
||||
document.getElementById('renameOldId').value = currentUnitId;
|
||||
document.getElementById('renameNewId').value = '';
|
||||
document.getElementById('renameUnitModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeRenameUnitModal() {
|
||||
document.getElementById('renameUnitModal').classList.add('hidden');
|
||||
document.getElementById('renameUnitForm').reset();
|
||||
}
|
||||
|
||||
// Handle Rename Unit form submission
|
||||
document.getElementById('renameUnitForm').addEventListener('submit', async function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const oldId = document.getElementById('renameOldId').value;
|
||||
const newId = document.getElementById('renameNewId').value.trim();
|
||||
|
||||
if (!newId) {
|
||||
alert('Please enter a new unit ID');
|
||||
return;
|
||||
}
|
||||
|
||||
if (oldId === newId) {
|
||||
alert('New unit ID must be different from the current ID');
|
||||
return;
|
||||
}
|
||||
|
||||
// Final confirmation
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to rename '${oldId}' to '${newId}'?\n\n` +
|
||||
`This will update:\n` +
|
||||
`• Unit roster entry\n` +
|
||||
`• All history records\n` +
|
||||
`• Project assignments\n` +
|
||||
`• Recording sessions\n` +
|
||||
`• Modem references\n\n` +
|
||||
`This action cannot be undone.`
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('old_id', oldId);
|
||||
formData.append('new_id', newId);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/roster/rename', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok && result.success) {
|
||||
alert(`✓ Successfully renamed unit from '${oldId}' to '${newId}'`);
|
||||
closeRenameUnitModal();
|
||||
closeEditUnitModal();
|
||||
// Reload the page to show updated unit ID
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert(`Error: ${result.detail || result.message || 'Failed to rename unit'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error renaming unit: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-open edit modal if ?edit=UNIT_ID query parameter is present
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const editUnitId = urlParams.get('edit');
|
||||
if (editUnitId) {
|
||||
// Wait a bit for the page to fully load, then open the edit modal
|
||||
setTimeout(() => {
|
||||
editUnit(editUnitId);
|
||||
}, 500);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* Filter Button Styles */
|
||||
.filter-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
background-color: #f3f4f6; /* gray-100 */
|
||||
color: #6b7280; /* gray-500 */
|
||||
border: 1px solid #e5e7eb; /* gray-200 */
|
||||
cursor: pointer;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background-color: #e5e7eb; /* gray-200 */
|
||||
color: #374151; /* gray-700 */
|
||||
}
|
||||
.filter-btn.active-filter {
|
||||
background-color: #f48b1c; /* seismo-orange */
|
||||
color: white;
|
||||
border-color: #f48b1c;
|
||||
}
|
||||
|
||||
/* Dark mode filter buttons */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.filter-btn {
|
||||
background-color: #374151; /* gray-700 */
|
||||
color: #9ca3af; /* gray-400 */
|
||||
border-color: #4b5563; /* gray-600 */
|
||||
}
|
||||
.filter-btn:hover {
|
||||
background-color: #4b5563; /* gray-600 */
|
||||
color: #e5e7eb; /* gray-200 */
|
||||
}
|
||||
.filter-btn.active-filter {
|
||||
background-color: #f48b1c;
|
||||
color: white;
|
||||
border-color: #f48b1c;
|
||||
}
|
||||
}
|
||||
|
||||
/* Legacy tab button styles (keeping for modals and other uses) */
|
||||
.roster-tab-button {
|
||||
color: #6b7280; /* gray-500 */
|
||||
border-bottom: 2px solid transparent;
|
||||
|
||||
76
templates/seismographs.html
Normal file
76
templates/seismographs.html
Normal file
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh stats every 30 seconds -->
|
||||
<div hx-get="/api/seismo-dashboard/stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML"
|
||||
class="mb-8">
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Seismograph List -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||
|
||||
<!-- Search Box -->
|
||||
<div class="relative">
|
||||
<input
|
||||
type="text"
|
||||
id="seismo-search"
|
||||
placeholder="Search seismographs..."
|
||||
class="w-full sm:w-64 px-4 py-2 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-blue-500 focus:border-transparent"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#seismo-units-list"
|
||||
hx-include="[name='search']"
|
||||
name="search"
|
||||
/>
|
||||
<svg class="absolute right-3 top-2.5 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>
|
||||
</div>
|
||||
|
||||
<!-- Units List (loaded via HTMX) -->
|
||||
<div id="seismo-units-list"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Clear search input on escape key
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const searchInput = document.getElementById('seismo-search');
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
this.value = '';
|
||||
htmx.trigger(this, 'keyup');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
@@ -859,11 +859,6 @@ async function loadRosterTable() {
|
||||
|
||||
function createRosterRow(unit) {
|
||||
const statusBadges = [];
|
||||
if (unit.deployed) {
|
||||
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Deployed</span>');
|
||||
} else {
|
||||
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Benched</span>');
|
||||
}
|
||||
if (unit.retired) {
|
||||
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
||||
}
|
||||
@@ -880,8 +875,24 @@ function createRosterRow(unit) {
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
${statusBadges.join('')}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex gap-3">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="deployed-${unit.id}" value="true"
|
||||
${unit.deployed ? 'checked' : ''}
|
||||
onchange="toggleDeployed('${unit.id}', true)"
|
||||
class="w-4 h-4 text-green-600 focus:ring-green-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="radio" name="deployed-${unit.id}" value="false"
|
||||
${!unit.deployed ? 'checked' : ''}
|
||||
onchange="toggleDeployed('${unit.id}', false)"
|
||||
class="w-4 h-4 text-gray-600 focus:ring-gray-500">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Benched</span>
|
||||
</label>
|
||||
</div>
|
||||
${statusBadges.length > 0 ? '<div class="flex flex-wrap gap-1">' + statusBadges.join('') + '</div>' : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
@@ -896,13 +907,6 @@ function createRosterRow(unit) {
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||
<div class="flex justify-end gap-1">
|
||||
<button onclick="toggleDeployed('${unit.id}', ${unit.deployed})"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||
title="${unit.deployed ? 'Bench Unit' : 'Deploy Unit'}">
|
||||
<svg class="w-4 h-4 ${unit.deployed ? 'text-green-600 dark:text-green-400' : 'text-gray-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>
|
||||
</button>
|
||||
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
||||
@@ -930,12 +934,12 @@ function createRosterRow(unit) {
|
||||
`;
|
||||
}
|
||||
|
||||
async function toggleDeployed(unitId, currentState) {
|
||||
async function toggleDeployed(unitId, newState) {
|
||||
try {
|
||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `deployed=${!currentState}`
|
||||
body: `deployed=${newState}`
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -969,7 +973,7 @@ async function toggleRetired(unitId, currentState) {
|
||||
}
|
||||
|
||||
function editUnit(unitId) {
|
||||
window.location.href = `/unit/${unitId}`;
|
||||
window.location.href = `/roster?edit=${unitId}`;
|
||||
}
|
||||
|
||||
async function confirmDeleteUnit(unitId) {
|
||||
|
||||
115
templates/slm_detail.html
Normal file
115
templates/slm_detail.html
Normal file
@@ -0,0 +1,115 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
{% if from_project and project %}
|
||||
<nav class="flex items-center space-x-2 text-sm">
|
||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
<a href="/projects/{{ from_project }}" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
{{ project.name }}
|
||||
</a>
|
||||
</nav>
|
||||
{% else %}
|
||||
<a href="#" onclick="goBack(event)" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||
</svg>
|
||||
<span id="back-link-text">Back to Sound Level Meters</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function goBack(event) {
|
||||
event.preventDefault();
|
||||
|
||||
// Check if there's a previous page in history
|
||||
// and it's from the same site (not external)
|
||||
if (window.history.length > 1 && document.referrer) {
|
||||
const referrer = new URL(document.referrer);
|
||||
const current = new URL(window.location.href);
|
||||
|
||||
// If referrer is from the same origin, go back
|
||||
if (referrer.origin === current.origin) {
|
||||
window.history.back();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, go to SLM dashboard
|
||||
window.location.href = '/sound-level-meters';
|
||||
}
|
||||
|
||||
// Update the back link text based on referrer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const backText = document.getElementById('back-link-text');
|
||||
if (backText && document.referrer) {
|
||||
try {
|
||||
const referrer = new URL(document.referrer);
|
||||
const current = new URL(window.location.href);
|
||||
|
||||
// Only update if from same origin
|
||||
if (referrer.origin === current.origin) {
|
||||
if (referrer.pathname.includes('/sound-level-meters')) {
|
||||
backText.textContent = 'Back to Sound Level Meters';
|
||||
} else if (referrer.pathname.includes('/roster')) {
|
||||
backText.textContent = 'Back to Roster';
|
||||
} else if (referrer.pathname.includes('/projects')) {
|
||||
backText.textContent = 'Back to Projects';
|
||||
} else if (referrer.pathname === '/') {
|
||||
backText.textContent = 'Back to Dashboard';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Invalid referrer, keep default text
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<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="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>
|
||||
{{ unit_id }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Command Center -->
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||
<div id="slm-command-center"
|
||||
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||
<p>Loading command center...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user