Compare commits
15 Commits
sfm-old-04
...
ff38b74548
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff38b74548 | ||
|
|
c8a030a3ba | ||
|
|
d8a8330427 | ||
|
|
1ef0557ccb | ||
|
|
6c7ce5aad0 | ||
|
|
54754e2279 | ||
|
|
8787a2dbb8 | ||
|
|
7971092509 | ||
|
|
d349af9444 | ||
|
|
be83cb3fe7 | ||
|
|
e9216b9abc | ||
|
|
d93785c230 | ||
|
|
98ee9d7cea | ||
|
|
04c66bdf9c | ||
|
|
8a5fadb5df |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -211,3 +211,5 @@ __marimo__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
data/
|
data/
|
||||||
|
.aider*
|
||||||
|
.aider*
|
||||||
|
|||||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -5,6 +5,19 @@ 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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.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
|
## [0.4.2] - 2026-01-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -348,6 +361,7 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||||
|
|||||||
53
README.md
53
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.4.2
|
# 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.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
@@ -308,7 +308,7 @@ print(response.json())
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| id | string | Unit identifier (primary key) |
|
| id | string | Unit identifier (primary key) |
|
||||||
| unit_type | string | Hardware model name (default: `series3`) |
|
| 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 |
|
| deployed | boolean | Whether the unit is in the field |
|
||||||
| retired | boolean | Removes the unit from deployments but preserves history |
|
| retired | boolean | Removes the unit from deployments but preserves history |
|
||||||
| note | string | Notes about the unit |
|
| note | string | Notes about the unit |
|
||||||
@@ -334,6 +334,39 @@ print(response.json())
|
|||||||
| phone_number | string | Cellular number for the modem |
|
| phone_number | string | Cellular number for the modem |
|
||||||
| hardware_model | string | Modem hardware reference |
|
| 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)
|
### Emitter Table (Device Check-ins)
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -463,6 +496,12 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## 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
|
### v0.4.0 — 2025-12-16
|
||||||
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
- **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
|
- **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
|
## 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)
|
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
150
backend/main.py
150
backend/main.py
@@ -1,13 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
import logging
|
import logging
|
||||||
from fastapi import FastAPI, Request, Depends
|
from fastapi import FastAPI, Request, Depends, HTTPException
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Dict
|
from typing import List, Dict, Optional
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard
|
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.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.4.2"
|
VERSION = "0.4.3"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -84,6 +84,7 @@ app.include_router(roster.router)
|
|||||||
app.include_router(units.router)
|
app.include_router(units.router)
|
||||||
app.include_router(photos.router)
|
app.include_router(photos.router)
|
||||||
app.include_router(roster_edit.router)
|
app.include_router(roster_edit.router)
|
||||||
|
app.include_router(roster_rename.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
@@ -95,6 +96,27 @@ app.include_router(seismo_dashboard.router)
|
|||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|
||||||
|
# Projects system routers
|
||||||
|
app.include_router(projects.router)
|
||||||
|
app.include_router(project_locations.router)
|
||||||
|
app.include_router(scheduler.router)
|
||||||
|
|
||||||
|
# Start scheduler service on application startup
|
||||||
|
from backend.services.scheduler import start_scheduler, stop_scheduler
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
def shutdown_event():
|
||||||
|
"""Clean up services on app shutdown"""
|
||||||
|
logger.info("Stopping scheduler service...")
|
||||||
|
stop_scheduler()
|
||||||
|
logger.info("Scheduler service stopped")
|
||||||
|
|
||||||
|
|
||||||
# Legacy routes from the original backend
|
# Legacy routes from the original backend
|
||||||
@@ -136,12 +158,132 @@ async def sound_level_meters_page(request: Request):
|
|||||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
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)
|
@app.get("/seismographs", response_class=HTMLResponse)
|
||||||
async def seismographs_page(request: Request):
|
async def seismographs_page(request: Request):
|
||||||
"""Seismographs management dashboard"""
|
"""Seismographs management dashboard"""
|
||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
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 =====
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
@app.get("/sw.js")
|
@app.get("/sw.js")
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def migrate():
|
|||||||
print("\n○ No migration needed - all columns already exist.")
|
print("\n○ No migration needed - all columns already exist.")
|
||||||
|
|
||||||
print("\nSound level meter fields are now available in the roster table.")
|
print("\nSound level meter fields are now available in the roster table.")
|
||||||
print("You can now set device_type='sound_level_meter' for SLM devices.")
|
print("Note: Use device_type='slm' for Sound Level Meters. Legacy 'sound_level_meter' has been deprecated.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
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.
|
Roster table: represents our *intended assignment* of a unit.
|
||||||
This is editable from the GUI.
|
This is editable from the GUI.
|
||||||
|
|
||||||
Supports multiple device types (seismograph, modem, sound_level_meter) 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"
|
__tablename__ = "roster"
|
||||||
|
|
||||||
# Core fields (all device types)
|
# Core fields (all device types)
|
||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
unit_type = Column(String, default="series3") # Backward compatibility
|
unit_type = Column(String, default="series3") # Backward compatibility
|
||||||
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
|
||||||
deployed = Column(Boolean, default=True)
|
deployed = Column(Boolean, default=True)
|
||||||
retired = Column(Boolean, default=False)
|
retired = Column(Boolean, default=False)
|
||||||
note = Column(String, nullable=True)
|
note = Column(String, nullable=True)
|
||||||
@@ -108,3 +111,170 @@ class UserPreferences(Base):
|
|||||||
status_ok_threshold_hours = Column(Integer, default=12)
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
status_pending_threshold_hours = Column(Integer, default=24)
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
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)
|
||||||
|
|||||||
488
backend/routers/project_locations.py
Normal file
488
backend/routers/project_locations.py
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
"""
|
||||||
|
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.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,
|
||||||
|
})
|
||||||
1037
backend/routers/projects.py
Normal file
1037
backend/routers/projects.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -223,7 +223,7 @@ async def add_roster_unit(
|
|||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
# If sound level meter, sync config to SLMM cache
|
# If sound level meter, sync config to SLMM cache
|
||||||
if device_type == "sound_level_meter":
|
if device_type == "slm":
|
||||||
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||||
result = await sync_slm_to_slmm_cache(
|
result = await sync_slm_to_slmm_cache(
|
||||||
unit_id=id,
|
unit_id=id,
|
||||||
@@ -458,16 +458,20 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g
|
|||||||
|
|
||||||
|
|
||||||
@router.delete("/{unit_id}")
|
@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.
|
Permanently delete a unit from the database.
|
||||||
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
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
|
deleted = False
|
||||||
|
was_slm = False
|
||||||
|
|
||||||
# Try to delete from roster table
|
# Try to delete from roster table
|
||||||
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
if roster_unit:
|
if roster_unit:
|
||||||
|
was_slm = roster_unit.device_type == "slm"
|
||||||
db.delete(roster_unit)
|
db.delete(roster_unit)
|
||||||
deleted = True
|
deleted = True
|
||||||
|
|
||||||
@@ -488,6 +492,19 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
db.commit()
|
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}
|
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,
|
||||||
|
}
|
||||||
@@ -477,3 +477,75 @@ async def upload_snapshot(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(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)}")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from fastapi.responses import HTMLResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
import asyncio
|
||||||
import httpx
|
import httpx
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
@@ -34,7 +35,7 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
Returns HTML partial with stat cards.
|
Returns HTML partial with stat cards.
|
||||||
"""
|
"""
|
||||||
# Query all SLMs
|
# Query all SLMs
|
||||||
all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all()
|
all_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
||||||
|
|
||||||
# Count deployed vs benched
|
# Count deployed vs benched
|
||||||
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
||||||
@@ -60,13 +61,19 @@ async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
|||||||
async def get_slm_units(
|
async def get_slm_units(
|
||||||
request: Request,
|
request: Request,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
search: str = Query(None)
|
search: str = Query(None),
|
||||||
|
project: str = Query(None),
|
||||||
|
include_measurement: bool = Query(False),
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Get list of SLM units for the sidebar.
|
Get list of SLM units for the sidebar.
|
||||||
Returns HTML partial with unit cards.
|
Returns HTML partial with unit cards.
|
||||||
"""
|
"""
|
||||||
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
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
|
# Filter by search term if provided
|
||||||
if search:
|
if search:
|
||||||
@@ -77,10 +84,39 @@ async def get_slm_units(
|
|||||||
(RosterUnit.address.like(search_term))
|
(RosterUnit.address.like(search_term))
|
||||||
)
|
)
|
||||||
|
|
||||||
# Only show deployed units by default
|
units = query.order_by(
|
||||||
units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all()
|
RosterUnit.retired.asc(),
|
||||||
|
RosterUnit.deployed.desc(),
|
||||||
|
RosterUnit.id.asc()
|
||||||
|
).all()
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/slm_unit_list.html", {
|
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,
|
"request": request,
|
||||||
"units": units
|
"units": units
|
||||||
})
|
})
|
||||||
@@ -93,7 +129,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
Returns HTML partial with live metrics and chart.
|
Returns HTML partial with live metrics and chart.
|
||||||
"""
|
"""
|
||||||
# Get unit from database
|
# Get unit from database
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
return templates.TemplateResponse("partials/slm_live_view_error.html", {
|
return templates.TemplateResponse("partials/slm_live_view_error.html", {
|
||||||
@@ -122,7 +158,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
# Get measurement state
|
# Get measurement state
|
||||||
state_response = await client.get(
|
state_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
@@ -132,7 +168,23 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
is_measuring = state_data.get("is_measuring", False)
|
||||||
|
|
||||||
# Get live status
|
# 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(
|
status_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
)
|
)
|
||||||
@@ -190,7 +242,7 @@ async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(g
|
|||||||
Get configuration form for a specific SLM unit.
|
Get configuration form for a specific SLM unit.
|
||||||
Returns HTML partial with configuration form.
|
Returns HTML partial with configuration form.
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
return HTMLResponse(
|
return HTMLResponse(
|
||||||
@@ -210,7 +262,7 @@ async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(
|
|||||||
Save SLM configuration.
|
Save SLM configuration.
|
||||||
Updates unit parameters in the database.
|
Updates unit parameters in the database.
|
||||||
"""
|
"""
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="slm").first()
|
||||||
|
|
||||||
if not unit:
|
if not unit:
|
||||||
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(
|
|||||||
|
|
||||||
# Get roster unit
|
# Get roster unit
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
if not unit or unit.device_type != "sound_level_meter":
|
if not unit or unit.device_type != "slm":
|
||||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
return templates.TemplateResponse("slm_detail.html", {
|
return templates.TemplateResponse("slm_detail.html", {
|
||||||
@@ -46,7 +46,7 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# Get roster unit
|
# Get roster unit
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
if not unit or unit.device_type != "sound_level_meter":
|
if not unit or unit.device_type != "slm":
|
||||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
# Try to get live status from SLMM
|
# Try to get live status from SLMM
|
||||||
@@ -61,7 +61,7 @@ async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"unit_id": unit_id,
|
"unit_id": unit_id,
|
||||||
"device_type": "sound_level_meter",
|
"device_type": "slm",
|
||||||
"deployed": unit.deployed,
|
"deployed": unit.deployed,
|
||||||
"model": unit.slm_model or "NL-43",
|
"model": unit.slm_model or "NL-43",
|
||||||
"location": unit.address or unit.location,
|
"location": unit.address or unit.location,
|
||||||
@@ -89,7 +89,7 @@ async def slm_controls_partial(request: Request, unit_id: str, db: Session = Dep
|
|||||||
"""Render SLM control panel partial."""
|
"""Render SLM control panel partial."""
|
||||||
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||||
if not unit or unit.device_type != "sound_level_meter":
|
if not unit or unit.device_type != "slm":
|
||||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||||
|
|
||||||
# Get current status from SLMM
|
# Get current status from SLMM
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from typing import Dict, Any
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
|
from backend.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
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", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"coordinates": coords
|
"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
|
||||||
|
}
|
||||||
|
|||||||
384
backend/services/device_controller.py
Normal file
384
backend/services/device_controller.py
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
"""
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 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
|
||||||
355
backend/services/scheduler.py
Normal file
355
backend/services/scheduler.py
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
"""
|
||||||
|
Scheduler Service
|
||||||
|
|
||||||
|
Executes scheduled actions for Projects system.
|
||||||
|
Monitors pending scheduled actions and executes them by calling device modules (SLMM/SFM).
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
from backend.services.device_controller import get_device_controller, DeviceControllerError
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
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."""
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
await self.execute_pending_actions()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Scheduler error: {e}")
|
||||||
|
# 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."""
|
||||||
|
# Start recording via device controller
|
||||||
|
response = await self.device_controller.start_recording(
|
||||||
|
unit_id,
|
||||||
|
action.device_type,
|
||||||
|
config={}, # TODO: Load config from action.notes or metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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}),
|
||||||
|
)
|
||||||
|
db.add(session)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "started",
|
||||||
|
"session_id": session.id,
|
||||||
|
"device_response": 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 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()
|
||||||
423
backend/services/slmm_client.py
Normal file
423
backend/services/slmm_client.py
Normal file
@@ -0,0 +1,423 @@
|
|||||||
|
"""
|
||||||
|
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")
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# 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
|
||||||
@@ -11,10 +11,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=production
|
- ENVIRONMENT=production
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
- SLMM_BASE_URL=http://host.docker.internal:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
|
extra_hosts:
|
||||||
|
- "host.docker.internal:host-gateway"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -23,37 +25,36 @@ services:
|
|||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# --- TERRA-VIEW DEVELOPMENT ---
|
# --- TERRA-VIEW DEVELOPMENT ---
|
||||||
terra-view-dev:
|
# terra-view-dev:
|
||||||
build: .
|
# build: .
|
||||||
container_name: terra-view-dev
|
# container_name: terra-view-dev
|
||||||
ports:
|
# ports:
|
||||||
- "1001:8001"
|
# - "1001:8001"
|
||||||
volumes:
|
# volumes:
|
||||||
- ./data-dev:/app/data
|
# - ./data-dev:/app/data
|
||||||
environment:
|
# environment:
|
||||||
- PYTHONUNBUFFERED=1
|
# - PYTHONUNBUFFERED=1
|
||||||
- ENVIRONMENT=development
|
# - ENVIRONMENT=development
|
||||||
- SLMM_BASE_URL=http://slmm:8100
|
# - SLMM_BASE_URL=http://slmm:8100
|
||||||
restart: unless-stopped
|
# restart: unless-stopped
|
||||||
depends_on:
|
# depends_on:
|
||||||
- slmm
|
# - slmm
|
||||||
healthcheck:
|
# healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
# interval: 30s
|
||||||
timeout: 10s
|
# timeout: 10s
|
||||||
retries: 3
|
# retries: 3
|
||||||
start_period: 40s
|
# start_period: 40s
|
||||||
|
|
||||||
# --- SLMM (Sound Level Meter Manager) ---
|
# --- SLMM (Sound Level Meter Manager) ---
|
||||||
slmm:
|
slmm:
|
||||||
build:
|
build:
|
||||||
context: ../../slmm
|
context: ../slmm
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: slmm
|
container_name: slmm
|
||||||
ports:
|
network_mode: host
|
||||||
- "8100:8100"
|
|
||||||
volumes:
|
volumes:
|
||||||
- ../../slmm/data:/app/data
|
- ../slmm/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8100
|
- PORT=8100
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ seismos = db.query(RosterUnit).filter_by(
|
|||||||
### Sound Level Meters Query
|
### Sound Level Meters Query
|
||||||
```python
|
```python
|
||||||
slms = db.query(RosterUnit).filter_by(
|
slms = db.query(RosterUnit).filter_by(
|
||||||
device_type="sound_level_meter",
|
device_type="slm",
|
||||||
retired=False
|
retired=False
|
||||||
).all()
|
).all()
|
||||||
```
|
```
|
||||||
|
|||||||
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
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Sound Level Meter Device Type Support
|
# 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
|
## 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.
|
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.
|
||||||
@@ -95,7 +97,7 @@ All SLM fields are updated when editing existing unit.
|
|||||||
|
|
||||||
The database schema already included SLM fields (no changes needed):
|
The database schema already included SLM fields (no changes needed):
|
||||||
- All fields are nullable to support multiple device types
|
- All fields are nullable to support multiple device types
|
||||||
- Fields are only relevant when `device_type = "sound_level_meter"`
|
- Fields are only relevant when `device_type = "slm"`
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -125,7 +127,7 @@ The form automatically shows/hides relevant fields based on device type:
|
|||||||
|
|
||||||
## Integration with SLMM Dashboard
|
## Integration with SLMM Dashboard
|
||||||
|
|
||||||
Units with `device_type = "sound_level_meter"` will:
|
Units with `device_type = "slm"` will:
|
||||||
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
|
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
|
||||||
- Be available for live monitoring and control
|
- Be available for live monitoring and control
|
||||||
- Use the configured `slm_host` and `slm_tcp_port` for device communication
|
- Use the configured `slm_host` and `slm_tcp_port` for device communication
|
||||||
|
|||||||
@@ -300,7 +300,7 @@ slm.deployed_with_modem_id = "modem-001"
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "nl43-001",
|
"id": "nl43-001",
|
||||||
"device_type": "sound_level_meter",
|
"device_type": "slm",
|
||||||
"deployed_with_modem_id": "modem-001",
|
"deployed_with_modem_id": "modem-001",
|
||||||
"slm_tcp_port": 2255,
|
"slm_tcp_port": 2255,
|
||||||
"slm_model": "NL-43",
|
"slm_model": "NL-43",
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ The dashboard communicates with the SLMM backend service running on port 8100:
|
|||||||
SLM-specific fields in the RosterUnit model:
|
SLM-specific fields in the RosterUnit model:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
device_type = "sound_level_meter" # Distinguishes SLMs from seismographs
|
device_type = "slm" # Distinguishes SLMs from seismographs
|
||||||
slm_host = String # Device IP or hostname
|
slm_host = String # Device IP or hostname
|
||||||
slm_tcp_port = Integer # TCP control port (default 2255)
|
slm_tcp_port = Integer # TCP control port (default 2255)
|
||||||
slm_model = String # NL-43, NL-53, etc.
|
slm_model = String # NL-43, NL-53, etc.
|
||||||
|
|||||||
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](../)
|
||||||
@@ -1,120 +1,20 @@
|
|||||||
# Helper Scripts
|
# Terra-View Utility Scripts
|
||||||
|
|
||||||
This directory contains helper scripts for database management and testing.
|
This directory contains utility scripts for database operations, testing, and maintenance.
|
||||||
|
|
||||||
## Database Migration Scripts
|
## Scripts
|
||||||
|
|
||||||
### migrate_dev_db.py
|
### create_test_db.py
|
||||||
Migrates the DEV database schema to add SLM-specific columns to the `roster` table.
|
Generate a realistic test database with sample data.
|
||||||
|
|
||||||
**Usage:**
|
Usage: python scripts/create_test_db.py
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
### rename_unit.py
|
||||||
- Adds 8 SLM-specific columns to the DEV database (data-dev/seismo_fleet.db)
|
Rename a unit ID across all tables.
|
||||||
- Columns: slm_host, slm_tcp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check
|
|
||||||
- Safe to run multiple times (skips existing columns)
|
|
||||||
|
|
||||||
### update_dev_db_schema.py
|
Usage: python scripts/rename_unit.py <old_id> <new_id>
|
||||||
Inspects and displays the DEV database schema.
|
|
||||||
|
|
||||||
**Usage:**
|
### sync_slms_to_slmm.py
|
||||||
```bash
|
Manually sync all SLM devices from Terra-View to SLMM.
|
||||||
python3 scripts/update_dev_db_schema.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
Usage: python scripts/sync_slms_to_slmm.py
|
||||||
- Shows all tables in the DEV database
|
|
||||||
- Lists all columns in the roster table
|
|
||||||
- Useful for verifying schema after migrations
|
|
||||||
|
|
||||||
## Test Data Scripts
|
|
||||||
|
|
||||||
### add_test_slms.py
|
|
||||||
Adds test Sound Level Meter units to the DEV database.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it creates:**
|
|
||||||
- nl43-001: NL-43 SLM at Construction Site A
|
|
||||||
- nl43-002: NL-43 SLM at Construction Site B
|
|
||||||
- nl53-001: NL-53 SLM at Residential Area
|
|
||||||
- nl43-003: NL-43 SLM (not deployed, spare unit)
|
|
||||||
|
|
||||||
### add_test_modems.py
|
|
||||||
Adds test modem units to the DEV database and assigns them to SLMs.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it creates:**
|
|
||||||
- modem-001, modem-002, modem-003: Deployed modems (Raven XTV and Sierra Wireless)
|
|
||||||
- modem-004: Spare modem (not deployed)
|
|
||||||
|
|
||||||
**Modem assignments:**
|
|
||||||
- nl43-001 → modem-001
|
|
||||||
- nl43-002 → modem-002
|
|
||||||
- nl53-001 → modem-003
|
|
||||||
|
|
||||||
## Cleanup Scripts
|
|
||||||
|
|
||||||
### remove_test_data_from_prod.py
|
|
||||||
**⚠️ PRODUCTION DATABASE CLEANUP**
|
|
||||||
|
|
||||||
Removes test data from the production database (data/seismo_fleet.db).
|
|
||||||
|
|
||||||
**Status:** Already executed successfully. Production database is clean.
|
|
||||||
|
|
||||||
**What it removed:**
|
|
||||||
- All test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
|
||||||
- All test modem units (modem-001, modem-002, modem-003, modem-004)
|
|
||||||
|
|
||||||
## Database Cloning
|
|
||||||
|
|
||||||
### clone_db_to_dev.py
|
|
||||||
Clones the production database to create/update the DEV database.
|
|
||||||
|
|
||||||
**Usage:**
|
|
||||||
```bash
|
|
||||||
python3 scripts/clone_db_to_dev.py
|
|
||||||
```
|
|
||||||
|
|
||||||
**What it does:**
|
|
||||||
- Copies data/seismo_fleet.db → data-dev/seismo_fleet.db
|
|
||||||
- Useful for syncing DEV database with production schema/data
|
|
||||||
|
|
||||||
## Setup Sequence
|
|
||||||
|
|
||||||
To set up a fresh DEV database with test data:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
|
||||||
|
|
||||||
# 1. Fix permissions (if needed)
|
|
||||||
sudo chown -R serversdown:serversdown data-dev/
|
|
||||||
|
|
||||||
# 2. Migrate schema
|
|
||||||
python3 scripts/migrate_dev_db.py
|
|
||||||
|
|
||||||
# 3. Add test data
|
|
||||||
python3 scripts/add_test_slms.py
|
|
||||||
python3 scripts/add_test_modems.py
|
|
||||||
|
|
||||||
# 4. Verify
|
|
||||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Important Notes
|
|
||||||
|
|
||||||
- **DEV Database**: `data-dev/seismo_fleet.db` - Used for development and testing
|
|
||||||
- **Production Database**: `data/seismo_fleet.db` - Used by the running application
|
|
||||||
- All test scripts are configured to use the DEV database only
|
|
||||||
- Never run test data scripts against production
|
|
||||||
|
|||||||
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)
|
||||||
@@ -25,7 +25,7 @@ async def sync_all_slms():
|
|||||||
try:
|
try:
|
||||||
# Get all SLM devices from Terra-View (source of truth)
|
# Get all SLM devices from Terra-View (source of truth)
|
||||||
slm_devices = db.query(RosterUnit).filter_by(
|
slm_devices = db.query(RosterUnit).filter_by(
|
||||||
device_type="sound_level_meter"
|
device_type="slm"
|
||||||
).all()
|
).all()
|
||||||
|
|
||||||
logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster")
|
logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster")
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
Sound Level Meters
|
Sound Level Meters
|
||||||
</a>
|
</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="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -374,10 +374,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- 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 -->
|
<!-- 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 %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
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 %}
|
||||||
@@ -85,6 +85,10 @@
|
|||||||
<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">
|
<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
|
Modem
|
||||||
</span>
|
</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 %}
|
{% 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">
|
<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
|
Seismograph
|
||||||
|
|||||||
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 %}
|
||||||
607
templates/partials/projects/ftp_browser.html
Normal file
607
templates/partials/projects/ftp_browser.html
Normal file
@@ -0,0 +1,607 @@
|
|||||||
|
<!-- 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="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>
|
||||||
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>
|
||||||
14
templates/partials/projects/project_header.html
Normal file
14
templates/partials/projects/project_header.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<div class="mb-8">
|
||||||
|
<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>
|
||||||
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 %}
|
||||||
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>
|
||||||
200
templates/partials/projects/unified_files.html
Normal file
200
templates/partials/projects/unified_files.html
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<!-- 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>
|
||||||
|
</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>
|
||||||
|
{% 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 == '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>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
|
||||||
|
<!-- Download Button -->
|
||||||
|
{% if exists %}
|
||||||
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<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>
|
||||||
|
</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`;
|
||||||
|
}
|
||||||
|
</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 %}
|
||||||
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 %}
|
||||||
File diff suppressed because it is too large
Load Diff
854
templates/projects/detail.html
Normal file
854
templates/projects/detail.html
Normal file
@@ -0,0 +1,854 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Project Dashboard - Terra-View{% 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>
|
||||||
|
<span class="text-gray-900 dark:text-white font-medium" id="project-name-breadcrumb">Project</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Header (loads dynamically) -->
|
||||||
|
<div id="project-header" hx-get="/api/projects/{{ project_id }}/header" hx-trigger="load" hx-swap="innerHTML">
|
||||||
|
<div class="mb-8 animate-pulse">
|
||||||
|
<div class="h-10 bg-gray-200 dark:bg-gray-700 rounded w-1/3 mb-2"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/4"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="flex space-x-6 overflow-x-auto">
|
||||||
|
<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 whitespace-nowrap">
|
||||||
|
Overview
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('locations')"
|
||||||
|
data-tab="locations"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
<span id="locations-tab-label">Locations</span>
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('units')"
|
||||||
|
data-tab="units"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Assigned Units
|
||||||
|
</button>
|
||||||
|
<button onclick="switchTab('schedules')"
|
||||||
|
data-tab="schedules"
|
||||||
|
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors whitespace-nowrap">
|
||||||
|
Schedules
|
||||||
|
</button>
|
||||||
|
<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 whitespace-nowrap">
|
||||||
|
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 whitespace-nowrap">
|
||||||
|
Data Files
|
||||||
|
</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 whitespace-nowrap">
|
||||||
|
Settings
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content -->
|
||||||
|
<div id="tab-content">
|
||||||
|
<!-- Overview Tab -->
|
||||||
|
<div id="overview-tab" class="tab-panel">
|
||||||
|
<div id="project-dashboard"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/dashboard"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-24 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-xl"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Locations Tab -->
|
||||||
|
<div id="locations-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">
|
||||||
|
<span id="locations-header">Locations</span>
|
||||||
|
</h2>
|
||||||
|
<button onclick="openLocationModal()" id="add-location-btn"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
<span id="add-location-label">Add Location</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-locations"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/locations"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading locations...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Units Tab -->
|
||||||
|
<div id="units-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">Assigned Units</h2>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
Units currently assigned to this project's locations
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-units"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/units"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading units...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Schedules Tab -->
|
||||||
|
<div id="schedules-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">Scheduled Actions</h2>
|
||||||
|
<button onclick="openScheduleModal()"
|
||||||
|
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
||||||
|
<svg class="w-5 h-5 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||||
|
</svg>
|
||||||
|
Schedule Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-schedules"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/schedules"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading schedules...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<select id="sessions-filter" onchange="filterSessions()"
|
||||||
|
class="px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
|
||||||
|
<option value="all">All Sessions</option>
|
||||||
|
<option value="recording">Recording</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-sessions"
|
||||||
|
hx-get="/api/projects/{{ project_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">
|
||||||
|
<!-- FTP File Browser (Download from Devices) -->
|
||||||
|
<div id="ftp-browser" class="mb-6"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/ftp-browser"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unified Files View (Database + Filesystem) -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
Project Files
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<button onclick="htmx.trigger('#unified-files', 'refresh')"
|
||||||
|
class="px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
||||||
|
<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 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>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="unified-files"
|
||||||
|
hx-get="/api/projects/{{ project_id }}/files-unified"
|
||||||
|
hx-trigger="load, refresh from:#unified-files"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="px-6 py-12 text-center text-gray-500">Loading files...</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">Project Settings</h2>
|
||||||
|
|
||||||
|
<form id="project-settings-form" class="space-y-6">
|
||||||
|
<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" id="settings-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 name="description" 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"></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" id="settings-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">Status</label>
|
||||||
|
<select name="status" id="settings-status"
|
||||||
|
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="active">Active</option>
|
||||||
|
<option value="completed">Completed</option>
|
||||||
|
<option value="archived">Archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Site Address</label>
|
||||||
|
<input type="text" name="site_address" id="settings-site-address"
|
||||||
|
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 Coordinates</label>
|
||||||
|
<input type="text" name="site_coordinates" id="settings-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 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" id="settings-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</label>
|
||||||
|
<input type="date" name="end_date" id="settings-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 id="settings-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="loadProjectDetails()"
|
||||||
|
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">
|
||||||
|
Reset
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Danger Zone -->
|
||||||
|
<div class="mt-8 pt-8 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-semibold text-red-600 dark:text-red-400 mb-4">Danger Zone</h3>
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 mb-3">
|
||||||
|
Archive this project to remove it from active listings. All data will be preserved.
|
||||||
|
</p>
|
||||||
|
<button onclick="archiveProject()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Archive Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Modal -->
|
||||||
|
<div id="location-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 id="location-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Add Location</h2>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Create or update a monitoring location</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="closeLocationModal()" 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="location-form" class="p-6 space-y-4">
|
||||||
|
<input type="hidden" id="location-id">
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
||||||
|
<input type="text" name="name" id="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 name="description" id="location-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">Type</label>
|
||||||
|
<select name="location_type" id="location-type"
|
||||||
|
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="sound">Sound</option>
|
||||||
|
<option value="vibration">Vibration</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="location-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">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" name="address" id="location-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 id="location-error" class="hidden text-sm text-red-600"></div>
|
||||||
|
|
||||||
|
<div class="flex justify-end gap-3 pt-2">
|
||||||
|
<button type="button" onclick="closeLocationModal()"
|
||||||
|
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 Location
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</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 device 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">
|
||||||
|
<input type="hidden" id="assign-location-id">
|
||||||
|
<input type="hidden" id="assign-location-type">
|
||||||
|
|
||||||
|
<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="">Select a unit</option>
|
||||||
|
</select>
|
||||||
|
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units match 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 }}";
|
||||||
|
let editingLocationId = null;
|
||||||
|
let projectTypeId = null;
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load project details
|
||||||
|
async function loadProjectDetails() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load project details');
|
||||||
|
}
|
||||||
|
const data = await response.json();
|
||||||
|
projectTypeId = data.project_type_id || null;
|
||||||
|
|
||||||
|
// Update breadcrumb
|
||||||
|
document.getElementById('project-name-breadcrumb').textContent = data.name || 'Project';
|
||||||
|
|
||||||
|
// Update settings form
|
||||||
|
document.getElementById('settings-name').value = data.name || '';
|
||||||
|
document.getElementById('settings-description').value = data.description || '';
|
||||||
|
document.getElementById('settings-client-name').value = data.client_name || '';
|
||||||
|
document.getElementById('settings-status').value = data.status || 'active';
|
||||||
|
document.getElementById('settings-site-address').value = data.site_address || '';
|
||||||
|
document.getElementById('settings-site-coordinates').value = data.site_coordinates || '';
|
||||||
|
document.getElementById('settings-start-date').value = formatDate(data.start_date);
|
||||||
|
document.getElementById('settings-end-date').value = formatDate(data.end_date);
|
||||||
|
|
||||||
|
// Update tab labels based on project type
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
document.getElementById('locations-tab-label').textContent = 'NRLs';
|
||||||
|
document.getElementById('locations-header').textContent = 'Noise Recording Locations';
|
||||||
|
document.getElementById('add-location-label').textContent = 'Add NRL';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('settings-error').classList.add('hidden');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load project details:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value) {
|
||||||
|
if (!value) return '';
|
||||||
|
const date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) return '';
|
||||||
|
return date.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Project settings form submission
|
||||||
|
document.getElementById('project-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,
|
||||||
|
client_name: document.getElementById('settings-client-name').value.trim() || null,
|
||||||
|
status: document.getElementById('settings-status').value,
|
||||||
|
site_address: document.getElementById('settings-site-address').value.trim() || null,
|
||||||
|
site_coordinates: document.getElementById('settings-site-coordinates').value.trim() || null,
|
||||||
|
start_date: document.getElementById('settings-start-date').value || null,
|
||||||
|
end_date: document.getElementById('settings-end-date').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to update project');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload page to show updated data
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('settings-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to update project.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function refreshProjectDashboard() {
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/dashboard`, {
|
||||||
|
target: '#project-dashboard',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/header`, {
|
||||||
|
target: '#project-header',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location modal functions
|
||||||
|
function openLocationModal(defaultType) {
|
||||||
|
editingLocationId = null;
|
||||||
|
document.getElementById('location-modal-title').textContent = 'Add Location';
|
||||||
|
document.getElementById('location-id').value = '';
|
||||||
|
document.getElementById('location-name').value = '';
|
||||||
|
document.getElementById('location-description').value = '';
|
||||||
|
document.getElementById('location-address').value = '';
|
||||||
|
document.getElementById('location-coordinates').value = '';
|
||||||
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationTypeSelect.value = 'sound';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
locationTypeSelect.disabled = false;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
|
locationTypeSelect.value = defaultType || 'sound';
|
||||||
|
}
|
||||||
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditLocationModal(button) {
|
||||||
|
const data = JSON.parse(button.dataset.location);
|
||||||
|
editingLocationId = data.id;
|
||||||
|
document.getElementById('location-modal-title').textContent = 'Edit Location';
|
||||||
|
document.getElementById('location-id').value = data.id;
|
||||||
|
document.getElementById('location-name').value = data.name || '';
|
||||||
|
document.getElementById('location-description').value = data.description || '';
|
||||||
|
document.getElementById('location-address').value = data.address || '';
|
||||||
|
document.getElementById('location-coordinates').value = data.coordinates || '';
|
||||||
|
const locationTypeSelect = document.getElementById('location-type');
|
||||||
|
const locationTypeWrapper = locationTypeSelect.closest('div');
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationTypeSelect.value = 'sound';
|
||||||
|
locationTypeSelect.disabled = true;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
locationTypeSelect.disabled = false;
|
||||||
|
if (locationTypeWrapper) locationTypeWrapper.classList.remove('hidden');
|
||||||
|
locationTypeSelect.value = data.location_type || 'sound';
|
||||||
|
}
|
||||||
|
document.getElementById('location-error').classList.add('hidden');
|
||||||
|
document.getElementById('location-modal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLocationModal() {
|
||||||
|
document.getElementById('location-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('location-form').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const name = document.getElementById('location-name').value.trim();
|
||||||
|
const description = document.getElementById('location-description').value.trim();
|
||||||
|
const address = document.getElementById('location-address').value.trim();
|
||||||
|
const coordinates = document.getElementById('location-coordinates').value.trim();
|
||||||
|
let locationType = document.getElementById('location-type').value;
|
||||||
|
if (projectTypeId === 'sound_monitoring') {
|
||||||
|
locationType = 'sound';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (editingLocationId) {
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
description: description || null,
|
||||||
|
address: address || null,
|
||||||
|
coordinates: coordinates || null,
|
||||||
|
location_type: locationType
|
||||||
|
};
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${editingLocationId}`, {
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('name', name);
|
||||||
|
formData.append('description', description);
|
||||||
|
formData.append('address', address);
|
||||||
|
formData.append('coordinates', coordinates);
|
||||||
|
formData.append('location_type', locationType);
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/create`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to create location');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeLocationModal();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
// Refresh locations tab if visible
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const errorEl = document.getElementById('location-error');
|
||||||
|
errorEl.textContent = err.message || 'Failed to save location.';
|
||||||
|
errorEl.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function deleteLocation(locationId) {
|
||||||
|
if (!confirm('Delete this location?')) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(data.detail || 'Failed to delete location');
|
||||||
|
}
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to delete location.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign modal functions
|
||||||
|
function openAssignModal(locationId, locationType) {
|
||||||
|
const safeType = locationType || 'sound';
|
||||||
|
document.getElementById('assign-location-id').value = locationId;
|
||||||
|
document.getElementById('assign-location-type').value = safeType;
|
||||||
|
document.getElementById('assign-unit-id').innerHTML = '<option value="">Loading units...</option>';
|
||||||
|
document.getElementById('assign-empty').classList.add('hidden');
|
||||||
|
document.getElementById('assign-error').classList.add('hidden');
|
||||||
|
document.getElementById('assign-modal').classList.remove('hidden');
|
||||||
|
loadAvailableUnits(safeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAssignModal() {
|
||||||
|
document.getElementById('assign-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvailableUnits(locationType) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=${locationType}`);
|
||||||
|
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 locationId = document.getElementById('assign-location-id').value;
|
||||||
|
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');
|
||||||
|
}
|
||||||
|
closeAssignModal();
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} 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?')) 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');
|
||||||
|
}
|
||||||
|
refreshProjectDashboard();
|
||||||
|
htmx.ajax('GET', `/api/projects/${projectId}/locations`, {
|
||||||
|
target: '#project-locations',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || 'Failed to unassign unit.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter functions
|
||||||
|
function filterSessions() {
|
||||||
|
const filter = document.getElementById('sessions-filter').value;
|
||||||
|
const url = filter === 'all'
|
||||||
|
? `/api/projects/${projectId}/sessions`
|
||||||
|
: `/api/projects/${projectId}/sessions?status=${filter}`;
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {
|
||||||
|
target: '#project-sessions',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterFiles() {
|
||||||
|
const filter = document.getElementById('files-filter').value;
|
||||||
|
const url = filter === 'all'
|
||||||
|
? `/api/projects/${projectId}/files`
|
||||||
|
: `/api/projects/${projectId}/files?type=${filter}`;
|
||||||
|
|
||||||
|
htmx.ajax('GET', url, {
|
||||||
|
target: '#project-files',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function openScheduleModal() {
|
||||||
|
alert('Schedule modal coming soon');
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportProjectData() {
|
||||||
|
window.location.href = `/api/projects/${projectId}/export`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function archiveProject() {
|
||||||
|
if (!confirm('Archive this project? You can restore it later from the archived projects list.')) return;
|
||||||
|
|
||||||
|
document.getElementById('settings-status').value = 'archived';
|
||||||
|
document.getElementById('project-settings-form').dispatchEvent(new Event('submit'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcuts
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeLocationModal();
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close modals
|
||||||
|
document.getElementById('location-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeLocationModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeAssignModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load project details on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadProjectDetails();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
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 %}
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
|
<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="seismograph">Seismographs</button>
|
||||||
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
|
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
|
||||||
<button class="filter-btn filter-device-type" data-value="sound_level_meter">SLMs</button>
|
<button class="filter-btn filter-device-type" data-value="slm">SLMs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
@@ -288,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">
|
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="seismograph">Seismograph</option>
|
||||||
<option value="modem">Modem</option>
|
<option value="modem">Modem</option>
|
||||||
|
<option value="sound_level_meter">Sound Level Meter</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -351,6 +352,56 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex items-center gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
||||||
@@ -372,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">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</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">
|
<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
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -380,6 +437,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Import CSV Modal -->
|
||||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<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">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
||||||
@@ -589,20 +699,30 @@
|
|||||||
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||||
const seismoFields = document.getElementById('editSeismographFields');
|
const seismoFields = document.getElementById('editSeismographFields');
|
||||||
const modemFields = document.getElementById('editModemFields');
|
const modemFields = document.getElementById('editModemFields');
|
||||||
|
const slmFields = document.getElementById('editSlmFields');
|
||||||
|
|
||||||
if (deviceType === 'seismograph') {
|
if (deviceType === 'seismograph') {
|
||||||
seismoFields.classList.remove('hidden');
|
seismoFields.classList.remove('hidden');
|
||||||
modemFields.classList.add('hidden');
|
modemFields.classList.add('hidden');
|
||||||
// Enable seismograph fields, disable modem fields
|
slmFields.classList.add('hidden');
|
||||||
setFieldsDisabled(seismoFields, false);
|
setFieldsDisabled(seismoFields, false);
|
||||||
setFieldsDisabled(modemFields, true);
|
setFieldsDisabled(modemFields, true);
|
||||||
|
setFieldsDisabled(slmFields, true);
|
||||||
toggleEditModemPairing();
|
toggleEditModemPairing();
|
||||||
} else {
|
} else if (deviceType === 'modem') {
|
||||||
seismoFields.classList.add('hidden');
|
seismoFields.classList.add('hidden');
|
||||||
modemFields.classList.remove('hidden');
|
modemFields.classList.remove('hidden');
|
||||||
// Enable modem fields, disable seismograph fields
|
slmFields.classList.add('hidden');
|
||||||
setFieldsDisabled(seismoFields, true);
|
setFieldsDisabled(seismoFields, true);
|
||||||
setFieldsDisabled(modemFields, false);
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +772,15 @@
|
|||||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
|
|
||||||
|
// 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
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
@@ -1077,6 +1206,88 @@
|
|||||||
function filterRosterTable() {
|
function filterRosterTable() {
|
||||||
filterDevices();
|
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>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -859,11 +859,6 @@ async function loadRosterTable() {
|
|||||||
|
|
||||||
function createRosterRow(unit) {
|
function createRosterRow(unit) {
|
||||||
const statusBadges = [];
|
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) {
|
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>');
|
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>
|
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="flex flex-col gap-2">
|
||||||
${statusBadges.join('')}
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -896,13 +907,6 @@ function createRosterRow(unit) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<div class="flex justify-end gap-1">
|
<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})"
|
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: `deployed=${!currentState}`
|
body: `deployed=${newState}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -969,7 +973,7 @@ async function toggleRetired(unitId, currentState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editUnit(unitId) {
|
function editUnit(unitId) {
|
||||||
window.location.href = `/unit/${unitId}`;
|
window.location.href = `/roster?edit=${unitId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteUnit(unitId) {
|
async function confirmDeleteUnit(unitId) {
|
||||||
|
|||||||
@@ -4,12 +4,75 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
{% if from_project and project %}
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
||||||
</svg>
|
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
Back to Roster
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -37,159 +100,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Control Panel -->
|
<!-- Command Center -->
|
||||||
<div class="mb-8">
|
<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">Control Panel</h2>
|
<div id="slm-command-center"
|
||||||
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
||||||
hx-trigger="load, every 5s"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
<div class="text-center py-8 text-gray-500">
|
||||||
</div>
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||||
</div>
|
<p>Loading command center...</p>
|
||||||
|
|
||||||
<!-- Real-time Data Stream -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div id="slm-stream-container">
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<button onclick="startStream()"
|
|
||||||
id="stream-start-btn"
|
|
||||||
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
|
||||||
Start Real-time Stream
|
|
||||||
</button>
|
|
||||||
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
|
||||||
</div>
|
|
||||||
<div id="stream-data" class="hidden">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
|
||||||
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
|
||||||
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
|
||||||
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
|
||||||
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
|
||||||
<div class="text-xs text-gray-500">dB</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<div class="text-xs text-gray-500">
|
|
||||||
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
|
||||||
Streaming
|
|
||||||
</div>
|
|
||||||
<button onclick="stopStream()"
|
|
||||||
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
|
||||||
Stop Stream
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device Information -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
|
||||||
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
|
||||||
</div>
|
|
||||||
{% if unit.note %}
|
|
||||||
<div class="md:col-span-2">
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
let ws = null;
|
|
||||||
|
|
||||||
function startStream() {
|
|
||||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
|
||||||
|
|
||||||
ws = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
ws.onopen = () => {
|
|
||||||
document.getElementById('stream-start-btn').classList.add('hidden');
|
|
||||||
document.getElementById('stream-data').classList.remove('hidden');
|
|
||||||
console.log('WebSocket connected');
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
|
|
||||||
if (data.error) {
|
|
||||||
console.error('Stream error:', data.error);
|
|
||||||
stopStream();
|
|
||||||
alert('Error: ' + data.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update values
|
|
||||||
document.getElementById('stream-lp').textContent = data.lp || '--';
|
|
||||||
document.getElementById('stream-leq').textContent = data.leq || '--';
|
|
||||||
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
|
||||||
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onerror = (error) => {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
stopStream();
|
|
||||||
};
|
|
||||||
|
|
||||||
ws.onclose = () => {
|
|
||||||
console.log('WebSocket closed');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopStream() {
|
|
||||||
if (ws) {
|
|
||||||
ws.close();
|
|
||||||
ws = null;
|
|
||||||
}
|
|
||||||
document.getElementById('stream-start-btn').classList.remove('hidden');
|
|
||||||
document.getElementById('stream-data').classList.add('hidden');
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
176
templates/slm_legacy_dashboard.html
Normal file
176
templates/slm_legacy_dashboard.html
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter Control Center{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<nav class="flex items-center space-x-2 text-sm">
|
||||||
|
{% if from_nrl and nrl_location and from_project and project %}
|
||||||
|
<!-- From NRL Location: Projects > Project > NRL > Unit -->
|
||||||
|
<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-gray-500 hover:text-seismo-orange">
|
||||||
|
{{ 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>
|
||||||
|
<a href="/projects/{{ from_project }}/nrl/{{ from_nrl }}" 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>
|
||||||
|
{{ nrl_location.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">{{ unit_id }}</span>
|
||||||
|
{% elif from_project and project %}
|
||||||
|
<!-- From Project: Projects > Project > Unit -->
|
||||||
|
<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-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>
|
||||||
|
{{ 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">{{ unit_id }}</span>
|
||||||
|
{% else %}
|
||||||
|
<!-- Default: Sound Level Meters > Unit -->
|
||||||
|
<a href="/sound-level-meters" 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>
|
||||||
|
Sound Level Meters
|
||||||
|
</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">{{ unit_id }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</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="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>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Sound Level Meter {% if from_project or from_nrl %}Operations{% else %}Control Center{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{% if not from_project and not from_nrl %}
|
||||||
|
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button onclick="openConfigModal()"
|
||||||
|
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
Configure
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live View Panel -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
||||||
|
<div id="live-view-content"
|
||||||
|
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading control center...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Configuration Modal -->
|
||||||
|
<div id="config-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">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
|
||||||
|
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="config-modal-content"
|
||||||
|
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="p-6 space-y-4 animate-pulse">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Modal functions
|
||||||
|
function openConfigModal() {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
// Reload config when opening
|
||||||
|
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
|
||||||
|
target: '#config-modal-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard shortcut
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside to close
|
||||||
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for config updates to refresh live view
|
||||||
|
document.body.addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
|
||||||
|
// Refresh live view after config update
|
||||||
|
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
|
||||||
|
target: '#live-view-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -4,8 +4,13 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
<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="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
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and control sound level measurement devices</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
@@ -20,70 +25,131 @@
|
|||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Main Content Grid -->
|
<!-- Device List with Quick Actions -->
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
<!-- SLM List -->
|
<div class="flex items-center justify-between mb-6">
|
||||||
<div class="lg:col-span-1">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Devices</h2>
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="flex items-center gap-3">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Units</h2>
|
<span class="text-sm text-gray-500 dark:text-gray-400">Auto-refresh: 15s</span>
|
||||||
|
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
||||||
<!-- Search/Filter -->
|
|
||||||
<div class="mb-4">
|
|
||||||
<input type="text"
|
|
||||||
placeholder="Search units..."
|
|
||||||
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"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="keyup changed delay:300ms"
|
|
||||||
hx-target="#slm-list"
|
|
||||||
hx-include="this"
|
|
||||||
name="search">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- SLM List -->
|
|
||||||
<div id="slm-list"
|
|
||||||
class="space-y-2 max-h-[600px] overflow-y-auto"
|
|
||||||
hx-get="/api/slm-dashboard/units"
|
|
||||||
hx-trigger="load, every 10s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<div class="animate-pulse space-y-2">
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Live View Panel -->
|
<div id="slm-devices-list"
|
||||||
<div class="lg:col-span-2">
|
class="space-y-3"
|
||||||
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
||||||
<!-- Initial state - no unit selected -->
|
hx-trigger="load, every 15s"
|
||||||
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
hx-swap="innerHTML">
|
||||||
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="animate-pulse space-y-3">
|
||||||
<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>
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
</svg>
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
<p class="text-lg font-medium">No unit selected</p>
|
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
||||||
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Measurement Chart - shows when a device is selected -->
|
||||||
|
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
||||||
|
<button onclick="closeLiveChart()" 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>
|
||||||
|
|
||||||
|
<!-- 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="chart-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</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="chart-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">--</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="chart-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">--</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="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</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="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||||
|
<canvas id="dashboardLiveChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stream Control -->
|
||||||
|
<div class="mt-4 flex justify-center gap-3">
|
||||||
|
<button id="start-chart-stream" onclick="startDashboardStream()"
|
||||||
|
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 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="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Start Live Stream
|
||||||
|
</button>
|
||||||
|
<button id="stop-chart-stream" onclick="stopDashboardStream()" style="display: none;"
|
||||||
|
class="px-6 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 Live Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Overview -->
|
||||||
|
<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-xl font-semibold text-gray-900 dark:text-white">Active Projects</h2>
|
||||||
|
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-projects-list"
|
||||||
|
class="space-y-3 max-h-[400px] overflow-y-auto"
|
||||||
|
hx-get="/api/projects/list?status=active&project_type_id=sound_monitoring&view=compact"
|
||||||
|
hx-trigger="load, every 60s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="animate-pulse space-y-3">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
<!-- Configuration Modal -->
|
||||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div id="slm-config-modal" 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 p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-slate-800 rounded-xl p-6 max-w-2xl w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
<button onclick="closeDeviceConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="config-modal-content">
|
<div id="slm-config-modal-content">
|
||||||
<!-- Content loaded via HTMX -->
|
|
||||||
<div class="animate-pulse space-y-4">
|
<div class="animate-pulse space-y-4">
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
@@ -93,157 +159,242 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
<script>
|
<script>
|
||||||
// Function to select a unit and load live view
|
// Global variables
|
||||||
function selectUnit(unitId) {
|
window.dashboardChart = null;
|
||||||
// Remove active state from all items
|
window.dashboardWebSocket = null;
|
||||||
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
window.selectedUnitId = null;
|
||||||
item.classList.remove('bg-seismo-orange', 'text-white');
|
window.dashboardChartData = {
|
||||||
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add active state to clicked item
|
|
||||||
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
|
||||||
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
|
||||||
|
|
||||||
// Load live view for this unit
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
|
||||||
target: '#live-view-panel',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration modal functions
|
|
||||||
function openConfigModal(unitId) {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Load configuration form via HTMX
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
|
||||||
target: '#config-modal-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConfigModal() {
|
|
||||||
document.getElementById('config-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close modal on escape key
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Close modal when clicking outside
|
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize WebSocket for selected unit
|
|
||||||
let currentWebSocket = null;
|
|
||||||
|
|
||||||
function initLiveDataStream(unitId) {
|
|
||||||
// Close existing connection if any
|
|
||||||
if (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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`;
|
|
||||||
|
|
||||||
currentWebSocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
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';
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onmessage = function(event) {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
updateLiveChart(data);
|
|
||||||
updateLiveMetrics(data);
|
|
||||||
};
|
|
||||||
|
|
||||||
currentWebSocket.onerror = function(error) {
|
|
||||||
console.error('WebSocket error:', error);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (currentWebSocket) {
|
|
||||||
currentWebSocket.close();
|
|
||||||
currentWebSocket = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update live chart with new data point
|
|
||||||
let chartData = {
|
|
||||||
timestamps: [],
|
timestamps: [],
|
||||||
lp: [],
|
lp: [],
|
||||||
leq: []
|
leq: []
|
||||||
};
|
};
|
||||||
|
|
||||||
function updateLiveChart(data) {
|
// Initialize Chart.js
|
||||||
|
function initializeDashboardChart() {
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
setTimeout(initializeDashboardChart, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = document.getElementById('dashboardLiveChart');
|
||||||
|
if (!canvas) return;
|
||||||
|
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
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.dashboardChart = 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show live chart for a specific unit
|
||||||
|
function showLiveChart(unitId) {
|
||||||
|
window.selectedUnitId = unitId;
|
||||||
|
const panel = document.getElementById('live-chart-panel');
|
||||||
|
panel.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Initialize chart if needed
|
||||||
|
if (!window.dashboardChart) {
|
||||||
|
initializeDashboardChart();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset data
|
||||||
|
window.dashboardChartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
|
||||||
|
// Scroll to chart
|
||||||
|
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLiveChart() {
|
||||||
|
stopDashboardStream();
|
||||||
|
document.getElementById('live-chart-panel').classList.add('hidden');
|
||||||
|
window.selectedUnitId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket streaming
|
||||||
|
function startDashboardStream() {
|
||||||
|
if (!window.selectedUnitId) return;
|
||||||
|
|
||||||
|
// Close existing connection
|
||||||
|
if (window.dashboardWebSocket) {
|
||||||
|
window.dashboardWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset chart data
|
||||||
|
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
||||||
|
if (window.dashboardChart) {
|
||||||
|
window.dashboardChart.data.labels = [];
|
||||||
|
window.dashboardChart.data.datasets[0].data = [];
|
||||||
|
window.dashboardChart.data.datasets[1].data = [];
|
||||||
|
window.dashboardChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
||||||
|
|
||||||
|
window.dashboardWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onopen = function() {
|
||||||
|
console.log('Dashboard WebSocket connected');
|
||||||
|
document.getElementById('start-chart-stream').style.display = 'none';
|
||||||
|
document.getElementById('stop-chart-stream').style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
updateDashboardMetrics(data);
|
||||||
|
updateDashboardChart(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onerror = function(error) {
|
||||||
|
console.error('Dashboard WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.dashboardWebSocket.onclose = function() {
|
||||||
|
console.log('Dashboard WebSocket closed');
|
||||||
|
document.getElementById('start-chart-stream').style.display = 'flex';
|
||||||
|
document.getElementById('stop-chart-stream').style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopDashboardStream() {
|
||||||
|
if (window.dashboardWebSocket) {
|
||||||
|
window.dashboardWebSocket.close();
|
||||||
|
window.dashboardWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardMetrics(data) {
|
||||||
|
document.getElementById('chart-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('chart-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
||||||
|
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardChart(data) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
chartData.timestamps.push(now.toLocaleTimeString());
|
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
||||||
chartData.lp.push(parseFloat(data.lp || 0));
|
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
||||||
chartData.leq.push(parseFloat(data.leq || 0));
|
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
// Keep only last 60 data points
|
||||||
if (chartData.timestamps.length > 60) {
|
if (window.dashboardChartData.timestamps.length > 60) {
|
||||||
chartData.timestamps.shift();
|
window.dashboardChartData.timestamps.shift();
|
||||||
chartData.lp.shift();
|
window.dashboardChartData.lp.shift();
|
||||||
chartData.leq.shift();
|
window.dashboardChartData.leq.shift();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update chart (using Chart.js if available)
|
if (window.dashboardChart) {
|
||||||
if (window.liveChart) {
|
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
||||||
window.liveChart.data.labels = chartData.timestamps;
|
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
||||||
window.liveChart.data.datasets[0].data = chartData.lp;
|
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
||||||
window.liveChart.data.datasets[1].data = chartData.leq;
|
window.dashboardChart.update('none');
|
||||||
window.liveChart.update('none'); // Update without animation for smooth real-time
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateLiveMetrics(data) {
|
// Configuration modal
|
||||||
// Update metric displays
|
function openDeviceConfigModal(unitId) {
|
||||||
if (document.getElementById('live-lp')) {
|
const modal = document.getElementById('slm-config-modal');
|
||||||
document.getElementById('live-lp').textContent = data.lp || '--';
|
modal.classList.remove('hidden');
|
||||||
}
|
|
||||||
if (document.getElementById('live-leq')) {
|
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||||
document.getElementById('live-leq').textContent = data.leq || '--';
|
target: '#slm-config-modal-content',
|
||||||
}
|
swap: 'innerHTML'
|
||||||
if (document.getElementById('live-lmax')) {
|
});
|
||||||
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
|
||||||
}
|
|
||||||
if (document.getElementById('live-lmin')) {
|
|
||||||
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeDeviceConfigModal() {
|
||||||
|
document.getElementById('slm-config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeDeviceConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeDeviceConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Cleanup on page unload
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
if (currentWebSocket) {
|
stopDashboardStream();
|
||||||
currentWebSocket.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user