3 Commits

Author SHA1 Message Date
serversdwn
b9a3da8487 terra-view toplevel rebrand. Names changed all around 2026-01-12 16:21:14 +00:00
serversdwn
e1b965c24c SLM dashboard rework, diagnostics and command pages added 2026-01-12 04:42:08 +00:00
serversdwn
ee025f1f34 old leftovers from pre merge 2026-01-09 19:41:16 +00:00
40 changed files with 1507 additions and 6513 deletions

View File

@@ -35,7 +35,7 @@ data/
.DS_Store .DS_Store
Thumbs.db Thumbs.db
.claude .claude
sfm.code-workspace terra-view.code-workspace
# Tests (optional) # Tests (optional)
tests/ tests/

1
.gitignore vendored
View File

@@ -211,4 +211,3 @@ __marimo__/
*.db *.db
*.db-journal *.db-journal
data/ data/
.aider*

View File

@@ -1,6 +1,6 @@
# Changelog # Changelog
All notable changes to Seismo Fleet Manager will be documented in this file. All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
@@ -54,7 +54,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [0.4.1] - 2026-01-05 ## [0.4.1] - 2026-01-05
### Added ### Added
- **SLM Integration**: Sound Level Meters are now manageable in SFM - **SLM Integration**: Sound Level Meters are now manageable in Terra-View
### Fixed ### Fixed
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate. - Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.

View File

@@ -1,546 +0,0 @@
# 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)

View File

@@ -1,5 +1,5 @@
# Seismo Fleet Manager v0.4.2 # Terra-View v0.4.2
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 Terra-View - a unified fleet management system. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Terra-View supports seismographs (SFM module), sound level meters, field modems, and other monitoring devices.
## Features ## Features

View File

@@ -1,108 +0,0 @@
#!/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()

View File

@@ -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, projects, project_locations, scheduler from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit from backend.models import IgnoredUnit
@@ -31,8 +31,8 @@ ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.4.2" VERSION = "0.4.2"
app = FastAPI( app = FastAPI(
title="Seismo Fleet Manager", title="Terra-View",
description="Backend API for managing seismograph fleet status", description="Backend API for Terra-View fleet management system",
version=VERSION version=VERSION
) )
@@ -95,27 +95,6 @@ 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
@@ -157,110 +136,12 @@ 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):
"""Legacy SLM control center dashboard for a specific unit"""
return templates.TemplateResponse("slm_legacy_dashboard.html", {
"request": request,
"unit_id": unit_id
})
@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")
@@ -635,7 +516,7 @@ async def devices_all_partial(request: Request):
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""
return { return {
"message": f"Seismo Fleet Manager v{VERSION}", "message": f"Terra-View v{VERSION}",
"status": "running", "status": "running",
"version": VERSION "version": VERSION
} }
@@ -643,4 +524,6 @@ def health_check():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8001) import os
port = int(os.getenv("PORT", 8001))
uvicorn.run(app, host="0.0.0.0", port=port)

View File

@@ -108,170 +108,3 @@ 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) # sound_level_meter | 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) # sound_level_meter | 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)

View File

@@ -1,488 +0,0 @@
"""
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 = "sound_level_meter" 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 = "sound_level_meter" 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 == "sound_level_meter" 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,
})

View File

@@ -1,583 +0,0 @@
"""
Projects Router
Provides API endpoints for the Projects system:
- Project CRUD operations
- Project dashboards
- Project statistics
- Type-aware features
"""
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 func, and_
from datetime import datetime, timedelta
from typing import Optional
import uuid
import json
from backend.database import get_db
from backend.models import (
Project,
ProjectType,
MonitoringLocation,
UnitAssignment,
RecordingSession,
ScheduledAction,
RosterUnit,
)
router = APIRouter(prefix="/api/projects", tags=["projects"])
templates = Jinja2Templates(directory="templates")
# ============================================================================
# Project List & Overview
# ============================================================================
@router.get("/list", response_class=HTMLResponse)
async def get_projects_list(
request: Request,
db: Session = Depends(get_db),
status: Optional[str] = Query(None),
project_type_id: Optional[str] = Query(None),
view: Optional[str] = Query(None),
):
"""
Get list of all projects.
Returns HTML partial with project cards.
"""
query = db.query(Project)
# Filter by status if provided
if status:
query = query.filter(Project.status == status)
# Filter by project type if provided
if project_type_id:
query = query.filter(Project.project_type_id == project_type_id)
projects = query.order_by(Project.created_at.desc()).all()
# Enrich each project with stats
projects_data = []
for project in projects:
# Get project type
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
# Count locations
location_count = db.query(func.count(MonitoringLocation.id)).filter_by(
project_id=project.id
).scalar()
# Count assigned units
unit_count = db.query(func.count(UnitAssignment.id)).filter(
and_(
UnitAssignment.project_id == project.id,
UnitAssignment.status == "active",
)
).scalar()
# Count active sessions
active_session_count = db.query(func.count(RecordingSession.id)).filter(
and_(
RecordingSession.project_id == project.id,
RecordingSession.status == "recording",
)
).scalar()
projects_data.append({
"project": project,
"project_type": project_type,
"location_count": location_count,
"unit_count": unit_count,
"active_session_count": active_session_count,
})
template_name = "partials/projects/project_list.html"
if view == "compact":
template_name = "partials/projects/project_list_compact.html"
return templates.TemplateResponse(template_name, {
"request": request,
"projects": projects_data,
})
@router.get("/stats", response_class=HTMLResponse)
async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
"""
Get summary statistics for projects overview.
Returns HTML partial with stat cards.
"""
# Count projects by status
total_projects = db.query(func.count(Project.id)).scalar()
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
# Count total locations across all projects
total_locations = db.query(func.count(MonitoringLocation.id)).scalar()
# Count assigned units
assigned_units = db.query(func.count(UnitAssignment.id)).filter_by(
status="active"
).scalar()
# Count active recording sessions
active_sessions = db.query(func.count(RecordingSession.id)).filter_by(
status="recording"
).scalar()
return templates.TemplateResponse("partials/projects/project_stats.html", {
"request": request,
"total_projects": total_projects,
"active_projects": active_projects,
"completed_projects": completed_projects,
"total_locations": total_locations,
"assigned_units": assigned_units,
"active_sessions": active_sessions,
})
# ============================================================================
# Project CRUD
# ============================================================================
@router.post("/create")
async def create_project(request: Request, db: Session = Depends(get_db)):
"""
Create a new project.
Expects form data with project details.
"""
form_data = await request.form()
project = Project(
id=str(uuid.uuid4()),
name=form_data.get("name"),
description=form_data.get("description"),
project_type_id=form_data.get("project_type_id"),
status="active",
client_name=form_data.get("client_name"),
site_address=form_data.get("site_address"),
site_coordinates=form_data.get("site_coordinates"),
start_date=datetime.fromisoformat(form_data.get("start_date")) if form_data.get("start_date") else None,
end_date=datetime.fromisoformat(form_data.get("end_date")) if form_data.get("end_date") else None,
)
db.add(project)
db.commit()
db.refresh(project)
return JSONResponse({
"success": True,
"project_id": project.id,
"message": f"Project '{project.name}' created successfully",
})
@router.get("/{project_id}")
async def get_project(project_id: str, db: Session = Depends(get_db)):
"""
Get project details by ID.
Returns JSON with full project data.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
return {
"id": project.id,
"name": project.name,
"description": project.description,
"project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None,
"status": project.status,
"client_name": project.client_name,
"site_address": project.site_address,
"site_coordinates": project.site_coordinates,
"start_date": project.start_date.isoformat() if project.start_date else None,
"end_date": project.end_date.isoformat() if project.end_date else None,
"created_at": project.created_at.isoformat(),
"updated_at": project.updated_at.isoformat(),
}
@router.put("/{project_id}")
async def update_project(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Update project details.
Expects JSON body with fields to update.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
data = await request.json()
# Update fields if provided
if "name" in data:
project.name = data["name"]
if "description" in data:
project.description = data["description"]
if "status" in data:
project.status = data["status"]
if "client_name" in data:
project.client_name = data["client_name"]
if "site_address" in data:
project.site_address = data["site_address"]
if "site_coordinates" in data:
project.site_coordinates = data["site_coordinates"]
if "start_date" in data:
project.start_date = datetime.fromisoformat(data["start_date"]) if data["start_date"] else None
if "end_date" in data:
project.end_date = datetime.fromisoformat(data["end_date"]) if data["end_date"] else None
project.updated_at = datetime.utcnow()
db.commit()
return {"success": True, "message": "Project updated successfully"}
@router.delete("/{project_id}")
async def delete_project(project_id: str, db: Session = Depends(get_db)):
"""
Delete a project (soft delete by archiving).
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project.status = "archived"
project.updated_at = datetime.utcnow()
db.commit()
return {"success": True, "message": "Project archived successfully"}
# ============================================================================
# Project Dashboard Data
# ============================================================================
@router.get("/{project_id}/dashboard", response_class=HTMLResponse)
async def get_project_dashboard(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Get project dashboard data.
Returns HTML partial with project summary.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
# Get locations
locations = db.query(MonitoringLocation).filter_by(project_id=project_id).all()
# Get assigned units with details
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
)
).all()
assigned_units = []
for assignment in assignments:
unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
if unit:
assigned_units.append({
"assignment": assignment,
"unit": unit,
})
# Get active recording sessions
active_sessions = db.query(RecordingSession).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.status == "recording",
)
).all()
# Get completed sessions count
completed_sessions_count = db.query(func.count(RecordingSession.id)).filter(
and_(
RecordingSession.project_id == project_id,
RecordingSession.status == "completed",
)
).scalar()
# Get upcoming scheduled actions
upcoming_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.project_id == project_id,
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time > datetime.utcnow(),
)
).order_by(ScheduledAction.scheduled_time).limit(5).all()
return templates.TemplateResponse("partials/projects/project_dashboard.html", {
"request": request,
"project": project,
"project_type": project_type,
"locations": locations,
"assigned_units": assigned_units,
"active_sessions": active_sessions,
"completed_sessions_count": completed_sessions_count,
"upcoming_actions": upcoming_actions,
})
# ============================================================================
# Project Types
# ============================================================================
@router.get("/{project_id}/header", response_class=JSONResponse)
async def get_project_header(project_id: str, db: Session = Depends(get_db)):
"""
Get project header information for dynamic display.
Returns JSON with project name, status, and type.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
raise HTTPException(status_code=404, detail="Project not found")
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first()
return JSONResponse({
"id": project.id,
"name": project.name,
"status": project.status,
"project_type_id": project.project_type_id,
"project_type_name": project_type.name if project_type else None,
})
@router.get("/{project_id}/units", response_class=HTMLResponse)
async def get_project_units(
project_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Get all units assigned to this project's locations.
Returns HTML partial with unit list.
"""
from backend.models import DataFile
# Get all assignments for this project
assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.project_id == project_id,
UnitAssignment.status == "active",
)
).all()
# Enrich with unit and location details
units_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()
# Count sessions for this assignment
session_count = db.query(func.count(RecordingSession.id)).filter_by(
location_id=assignment.location_id,
unit_id=assignment.unit_id,
).scalar()
# Count files from sessions
file_count = db.query(func.count(DataFile.id)).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
).scalar()
# Check if currently recording
active_session = db.query(RecordingSession).filter(
and_(
RecordingSession.location_id == assignment.location_id,
RecordingSession.unit_id == assignment.unit_id,
RecordingSession.status == "recording",
)
).first()
units_data.append({
"assignment": assignment,
"unit": unit,
"location": location,
"session_count": session_count,
"file_count": file_count,
"active_session": active_session,
})
# Get project type for label context
project = db.query(Project).filter_by(id=project_id).first()
project_type = db.query(ProjectType).filter_by(id=project.project_type_id).first() if project else None
return templates.TemplateResponse("partials/projects/unit_list.html", {
"request": request,
"project_id": project_id,
"units": units_data,
"project_type": project_type,
})
@router.get("/{project_id}/schedules", response_class=HTMLResponse)
async def get_project_schedules(
project_id: str,
request: Request,
db: Session = Depends(get_db),
status: Optional[str] = Query(None),
):
"""
Get scheduled actions for this project.
Returns HTML partial with schedule list.
Optional status filter: pending, completed, failed, cancelled
"""
query = db.query(ScheduledAction).filter_by(project_id=project_id)
# Filter by status if provided
if status:
query = query.filter(ScheduledAction.execution_status == status)
schedules = query.order_by(ScheduledAction.scheduled_time.desc()).all()
# Enrich with location details
schedules_data = []
for schedule in schedules:
location = None
if schedule.location_id:
location = db.query(MonitoringLocation).filter_by(id=schedule.location_id).first()
schedules_data.append({
"schedule": schedule,
"location": location,
})
return templates.TemplateResponse("partials/projects/schedule_list.html", {
"request": request,
"project_id": project_id,
"schedules": schedules_data,
})
@router.get("/{project_id}/sessions", response_class=HTMLResponse)
async def get_project_sessions(
project_id: str,
request: Request,
db: Session = Depends(get_db),
status: Optional[str] = Query(None),
):
"""
Get all recording sessions for this project.
Returns HTML partial with session list.
Optional status filter: recording, completed, paused, failed
"""
query = db.query(RecordingSession).filter_by(project_id=project_id)
# Filter by status if provided
if status:
query = query.filter(RecordingSession.status == status)
sessions = query.order_by(RecordingSession.started_at.desc()).all()
# Enrich with unit and location details
sessions_data = []
for session in sessions:
unit = None
location = None
if session.unit_id:
unit = db.query(RosterUnit).filter_by(id=session.unit_id).first()
if session.location_id:
location = db.query(MonitoringLocation).filter_by(id=session.location_id).first()
sessions_data.append({
"session": session,
"unit": unit,
"location": location,
})
return templates.TemplateResponse("partials/projects/session_list.html", {
"request": request,
"project_id": project_id,
"sessions": sessions_data,
})
@router.get("/{project_id}/files", response_class=HTMLResponse)
async def get_project_files(
project_id: str,
request: Request,
db: Session = Depends(get_db),
file_type: Optional[str] = Query(None),
):
"""
Get all data files from all sessions in this project.
Returns HTML partial with file list.
Optional file_type filter: audio, data, log, etc.
"""
from backend.models import DataFile
# Join through RecordingSession to get project files
query = db.query(DataFile).join(
RecordingSession,
DataFile.session_id == RecordingSession.id
).filter(RecordingSession.project_id == project_id)
# Filter by file type if provided
if file_type:
query = query.filter(DataFile.file_type == file_type)
files = query.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,
"files": files_data,
})
# ============================================================================
# Project Types
# ============================================================================
@router.get("/types/list", response_class=HTMLResponse)
async def get_project_types(request: Request, db: Session = Depends(get_db)):
"""
Get all available project types.
Returns HTML partial with project type cards.
"""
project_types = db.query(ProjectType).all()
return templates.TemplateResponse("partials/projects/project_type_cards.html", {
"request": request,
"project_types": project_types,
})

View File

@@ -1,409 +0,0 @@
"""
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 = "sound_level_meter" 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 = "sound_level_meter" 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,
}

View File

@@ -6,11 +6,10 @@ Provides API endpoints for the Sound Level Meters dashboard page.
from fastapi import APIRouter, Request, Depends, Query from fastapi import APIRouter, Request, Depends, Query
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse, JSONResponse
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
@@ -62,12 +61,12 @@ 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), 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.
Supports filtering by search term and project.
""" """
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter") query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
@@ -84,39 +83,10 @@ async def get_slm_units(
(RosterUnit.address.like(search_term)) (RosterUnit.address.like(search_term))
) )
units = query.order_by( # Only show deployed units by default
RosterUnit.retired.asc(), units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all()
RosterUnit.deployed.desc(),
RosterUnit.id.asc()
).all()
one_hour_ago = datetime.utcnow() - timedelta(hours=1) return templates.TemplateResponse("partials/slm_unit_list.html", {
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
}) })
@@ -362,3 +332,55 @@ async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
"modem_id": modem_id, "modem_id": modem_id,
"detail": str(e) "detail": str(e)
} }
@router.get("/diagnostics/{unit_id}", response_class=HTMLResponse)
async def get_diagnostics(request: Request, unit_id: str, db: Session = Depends(get_db)):
"""
Get compact diagnostics card for a specific SLM unit.
Returns HTML partial with key metrics only.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
if not unit:
return HTMLResponse(
content='<div class="p-6 text-center text-red-600">Unit not found</div>',
status_code=404
)
# Get modem info
modem = None
modem_ip = None
if unit.deployed_with_modem_id:
modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first()
if modem:
# Try modem_rx_host first (if it exists), then fall back to ip_address
modem_ip = getattr(modem, 'modem_rx_host', None) or modem.ip_address
elif unit.slm_host:
modem_ip = unit.slm_host
return templates.TemplateResponse("partials/slm_diagnostics_card.html", {
"request": request,
"unit": unit,
"modem": modem,
"modem_ip": modem_ip
})
@router.get("/projects")
async def get_projects(db: Session = Depends(get_db)):
"""
Get list of unique projects from deployed SLMs.
Returns JSON array of project names.
"""
projects = db.query(RosterUnit.project_id).filter(
RosterUnit.device_type == "sound_level_meter",
RosterUnit.deployed == True,
RosterUnit.retired == False,
RosterUnit.project_id.isnot(None)
).distinct().order_by(RosterUnit.project_id).all()
# Extract project names from query result tuples
project_list = [p[0] for p in projects if p[0]]
return JSONResponse(content={"projects": project_list})

View File

@@ -1,7 +1,7 @@
""" """
SLMM (Sound Level Meter Manager) Proxy Router SLMM (Sound Level Meter Manager) Proxy Router
Proxies requests from SFM to the standalone SLMM backend service. Proxies requests from Terra-View to the standalone SLMM backend service.
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication. SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
""" """
@@ -72,7 +72,7 @@ async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
Proxy WebSocket connections to SLMM's /stream endpoint. Proxy WebSocket connections to SLMM's /stream endpoint.
This allows real-time streaming of measurement data from NL43 devices This allows real-time streaming of measurement data from NL43 devices
through the SFM unified interface. through the Terra-View unified interface.
""" """
await websocket.accept() await websocket.accept()
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}") logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
@@ -237,7 +237,7 @@ async def proxy_to_slmm(path: str, request: Request):
""" """
Proxy all requests to the SLMM backend service. Proxy all requests to the SLMM backend service.
This allows SFM to act as a unified frontend for all device types, This allows Terra-View to act as a unified frontend for all device types,
while SLMM remains a standalone backend service. while SLMM remains a standalone backend service.
""" """
# Build target URL # Build target URL

View File

@@ -1,384 +0,0 @@
"""
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", "sound_level_meter", 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: "sound_level_meter" | "seismograph"
config: Device-specific recording configuration
Returns:
Response dict from device module
Raises:
UnsupportedDeviceTypeError: Device type not supported
DeviceControllerError: Operation failed
"""
if device_type == "sound_level_meter":
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: sound_level_meter, 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: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
Returns:
Response dict from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
Returns:
Status dict from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
Returns:
Live data dict from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "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 == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
config: Configuration parameters
Returns:
Updated config from device module
"""
if device_type == "sound_level_meter":
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: "sound_level_meter" | "seismograph"
Returns:
True if device is reachable, False otherwise
"""
if device_type == "sound_level_meter":
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

View File

@@ -1,355 +0,0 @@
"""
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 == "sound_level_meter" 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 == "sound_level_meter" 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()

View File

@@ -1,423 +0,0 @@
"""
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

View File

@@ -1,9 +1,9 @@
/* IndexedDB wrapper for offline data storage in SFM */ /* IndexedDB wrapper for offline data storage in Terra-View */
/* Handles unit data, status snapshots, and pending edit queue */ /* Handles unit data, status snapshots, and pending edit queue */
class OfflineDB { class OfflineDB {
constructor() { constructor() {
this.dbName = 'sfm-offline-db'; this.dbName = 'terra-view-offline-db';
this.version = 1; this.version = 1;
this.db = null; this.db = null;
} }

View File

@@ -1,10 +1,10 @@
/* Service Worker for Seismo Fleet Manager PWA */ /* Service Worker for Terra-View PWA */
/* Network-first strategy with cache fallback for real-time data */ /* Network-first strategy with cache fallback for real-time data */
const CACHE_VERSION = 'v1'; const CACHE_VERSION = 'v1';
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`; const STATIC_CACHE = `terra-view-static-${CACHE_VERSION}`;
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`; const DYNAMIC_CACHE = `terra-view-dynamic-${CACHE_VERSION}`;
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`; const DATA_CACHE = `terra-view-data-${CACHE_VERSION}`;
// Files to precache (critical app shell) // Files to precache (critical app shell)
const STATIC_FILES = [ const STATIC_FILES = [
@@ -137,7 +137,7 @@ async function networkFirstStrategy(request, cacheName) {
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Offline - SFM</title> <title>Offline - Terra-View</title>
<style> <style>
body { body {
font-family: system-ui, -apple-system, sans-serif; font-family: system-ui, -apple-system, sans-serif;
@@ -170,7 +170,7 @@ async function networkFirstStrategy(request, cacheName) {
<body> <body>
<div class="container"> <div class="container">
<h1>📡 You're Offline</h1> <h1>📡 You're Offline</h1>
<p>SFM requires an internet connection for this page.</p> <p>Terra-View requires an internet connection for this page.</p>
<p>Please check your connection and try again.</p> <p>Please check your connection and try again.</p>
<button onclick="location.reload()">Retry</button> <button onclick="location.reload()">Retry</button>
</div> </div>
@@ -285,7 +285,7 @@ async function syncPendingEdits() {
// IndexedDB helpers (simplified versions - full implementations in offline-db.js) // IndexedDB helpers (simplified versions - full implementations in offline-db.js)
function openDatabase() { function openDatabase() {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const request = indexedDB.open('sfm-offline-db', 1); const request = indexedDB.open('terra-view-offline-db', 1);
request.onerror = () => reject(request.error); request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result); request.onsuccess = () => resolve(request.result);

View File

@@ -4,14 +4,14 @@ services:
terra-view-prod: terra-view-prod:
build: . build: .
container_name: terra-view container_name: terra-view
ports: network_mode: host
- "8001:8001"
volumes: volumes:
- ./data:/app/data - ./data:/app/data
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ENVIRONMENT=production - ENVIRONMENT=production
- SLMM_BASE_URL=http://slmm:8100 - PORT=8001
- SLMM_BASE_URL=http://localhost:8100
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- slmm - slmm
@@ -23,37 +23,38 @@ 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: network_mode: host
# - "1001:8001" volumes:
# volumes: - ./data-dev:/app/data
# - ./data-dev:/app/data environment:
# environment: - PYTHONUNBUFFERED=1
# - PYTHONUNBUFFERED=1 - ENVIRONMENT=development
# - ENVIRONMENT=development - PORT=1001
# - SLMM_BASE_URL=http://slmm:8100 - SLMM_BASE_URL=http://localhost:8100
# restart: unless-stopped restart: unless-stopped
# depends_on: depends_on:
# - slmm - slmm
# healthcheck: profiles:
# test: ["CMD", "curl", "-f", "http://localhost:8001/health"] - dev
# interval: 30s healthcheck:
# timeout: 10s test: ["CMD", "curl", "-f", "http://localhost:1001/health"]
# retries: 3 interval: 30s
# start_period: 40s timeout: 10s
retries: 3
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

View File

@@ -127,7 +127,7 @@
Sound Level Meters Sound Level Meters
</a> </a>
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}"> <a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
<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>

View File

@@ -1,563 +0,0 @@
{% 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 }}" 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 %}

View File

@@ -1,30 +0,0 @@
<!-- 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 %}

View File

@@ -1,126 +0,0 @@
<!-- Data Files List -->
{% if files %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
File Name
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Type
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Size
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Created
</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Session
</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for item in files %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center">
<svg class="w-5 h-5 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>
<div>
<div class="text-sm font-medium text-gray-900 dark:text-white">
{{ item.file.file_path.split('/')[-1] if item.file.file_path else 'Unknown' }}
</div>
{% if item.file.file_path %}
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono">
{{ item.file.file_path }}
</div>
{% endif %}
</div>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span class="px-2 py-1 text-xs font-medium rounded-full
{% if item.file.file_type == 'audio' %}bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300
{% elif item.file.file_type == 'data' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300
{% elif item.file.file_type == 'log' %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300
{% else %}bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300{% endif %}">
{{ item.file.file_type or 'unknown' }}
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{% if item.file.file_size_bytes %}
{% if item.file.file_size_bytes < 1024 %}
{{ item.file.file_size_bytes }} B
{% elif item.file.file_size_bytes < 1048576 %}
{{ "%.1f"|format(item.file.file_size_bytes / 1024) }} KB
{% elif item.file.file_size_bytes < 1073741824 %}
{{ "%.1f"|format(item.file.file_size_bytes / 1048576) }} MB
{% else %}
{{ "%.2f"|format(item.file.file_size_bytes / 1073741824) }} GB
{% endif %}
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-white">
{{ item.file.created_at.strftime('%Y-%m-%d %H:%M') if item.file.created_at else 'N/A' }}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
{% if item.session %}
<span class="text-gray-900 dark:text-white font-mono text-xs">
{{ item.session.id[:8] }}...
</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
<div class="flex items-center justify-end gap-2">
<button onclick="downloadFile('{{ item.file.id }}')"
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
title="Download file">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
<button onclick="viewFileDetails('{{ item.file.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"
title="View details">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</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="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 data files yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500">Files will appear here after recording sessions</p>
</div>
{% endif %}
<script>
function downloadFile(fileId) {
// TODO: Implement file download
window.location.href = `/api/projects/{{ project_id }}/files/${fileId}/download`;
}
function viewFileDetails(fileId) {
// TODO: Implement file details modal
alert('File details coming soon: ' + fileId);
}
</script>

View File

@@ -1,69 +0,0 @@
<!-- 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 %}

View File

@@ -1,95 +0,0 @@
<!-- 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>

View File

@@ -1,97 +0,0 @@
<!-- 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 %}

View File

@@ -1,41 +0,0 @@
<!-- 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 %}

View File

@@ -1,58 +0,0 @@
<!-- 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>

View File

@@ -1,58 +0,0 @@
<!-- 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 %}

View File

@@ -1,149 +0,0 @@
<!-- 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>

View File

@@ -1,107 +0,0 @@
<!-- 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 }}" 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>

View File

@@ -1,99 +0,0 @@
<!-- 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 }}" 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 }}"
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 %}

View File

@@ -1,58 +0,0 @@
<!-- SLM Device List -->
{% if units %}
{% for unit in units %}
<a href="/slm/{{ unit.id }}" class="block bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
class="absolute top-3 right-3 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 class="flex items-start justify-between gap-4">
<div class="min-w-0">
<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>
{% 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 %}

View File

@@ -0,0 +1,206 @@
<!-- Compact Diagnostics Card for {{ unit.id }} -->
<div class="h-full flex flex-col p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
<p class="text-sm text-gray-600 dark:text-gray-400">
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
</p>
</div>
<!-- Status Badge -->
<span id="diag-status-badge" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
Loading...
</span>
</div>
<!-- Connection Status -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-4">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M5.05 3.636a1 1 0 010 1.414 7 7 0 000 9.9 1 1 0 11-1.414 1.414 9 9 0 010-12.728 1 1 0 011.414 0zm9.9 0a1 1 0 011.414 0 9 9 0 010 12.728 1 1 0 11-1.414-1.414 7 7 0 000-9.9 1 1 0 010-1.414zM7.879 6.464a1 1 0 010 1.414 3 3 0 000 4.243 1 1 0 11-1.415 1.414 5 5 0 010-7.07 1 1 0 011.415 0zm4.242 0a1 1 0 011.415 0 5 5 0 010 7.072 1 1 0 01-1.415-1.415 3 3 0 000-4.242 1 1 0 010-1.415zM10 9a1 1 0 011 1v.01a1 1 0 11-2 0V10a1 1 0 011-1z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Connection</span>
</div>
<div>
{% if modem %}
<span class="text-sm text-gray-600 dark:text-gray-400">via {{ modem.id }}</span>
{% elif modem_ip %}
<span class="text-sm text-gray-600 dark:text-gray-400">Direct: {{ modem_ip }}</span>
{% else %}
<span class="text-sm text-red-600 dark:text-red-400">Not configured</span>
{% endif %}
<span id="connection-status" class="ml-2 w-2 h-2 bg-gray-400 rounded-full inline-block"></span>
</div>
</div>
</div>
<!-- Current Sound Levels -->
<div class="grid grid-cols-2 gap-3 mb-4">
<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="diag-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="diag-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="diag-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="diag-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>
<!-- Battery and Power -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-600 dark:text-gray-400">Battery</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm0 2h8v12H6V4zm7 2a1 1 0 011 1v6a1 1 0 11-2 0V7a1 1 0 011-1z"/>
</svg>
</div>
<div id="diag-battery-level" class="text-xl font-bold text-gray-900 dark:text-white">--</div>
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div id="diag-battery-bar" class="bg-gray-400 h-2 rounded-full transition-all" style="width: 0%"></div>
</div>
</div>
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
<div class="flex items-center justify-between mb-2">
<span class="text-xs text-gray-600 dark:text-gray-400">Power</span>
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
</svg>
</div>
<div id="diag-power-source" class="text-lg font-semibold text-gray-900 dark:text-white">--</div>
</div>
</div>
<!-- Last Check-in -->
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700 mb-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg class="w-5 h-5 text-gray-500 mr-2" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Last Check-in</span>
</div>
<div>
{% if unit.slm_last_check %}
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M:%S') }}</span>
{% else %}
<span class="text-sm text-gray-500 dark:text-gray-500">Never</span>
{% endif %}
</div>
</div>
</div>
<!-- Open Command Center Button -->
<div class="mt-auto">
<button onclick="openCommandCenter('{{ unit.id }}')"
class="w-full px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium flex items-center justify-center transition-colors">
<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="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"/>
</svg>
Open Command Center
</button>
</div>
</div>
<script>
(function() {
const diagUnitId = '{{ unit.id }}';
// Clear any existing connections before starting new ones
window.SLMConnectionManager.setCurrentUnit(diagUnitId);
function updateDiagnosticsData() {
fetch(`/api/slmm/${diagUnitId}/live`)
.then(response => response.json())
.then(result => {
if (result.status === 'ok' && result.data) {
const data = result.data;
// Update status badge
const statusBadge = document.getElementById('diag-status-badge');
if (statusBadge) {
const isMeasuring = data.measurement_state === 'Start';
if (isMeasuring) {
statusBadge.className = 'px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center';
statusBadge.innerHTML = '<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>Measuring';
} else {
statusBadge.className = 'px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium';
statusBadge.textContent = 'Stopped';
}
}
// Update sound levels
['lp', 'leq', 'lmax', 'lmin'].forEach(metric => {
const el = document.getElementById(`diag-${metric}`);
if (el) el.textContent = data[metric] || '--';
});
// Update battery
const batteryEl = document.getElementById('diag-battery-level');
const batteryBar = document.getElementById('diag-battery-bar');
if (batteryEl && data.battery_level) {
const level = parseInt(data.battery_level);
batteryEl.textContent = `${level}%`;
if (batteryBar) {
batteryBar.style.width = `${level}%`;
if (level > 50) {
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
} else if (level > 20) {
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
} else {
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
}
}
}
// Update power source
const powerEl = document.getElementById('diag-power-source');
if (powerEl) powerEl.textContent = data.power_source || '--';
// Update connection status
const connStatus = document.getElementById('connection-status');
if (connStatus) {
connStatus.className = 'ml-2 w-2 h-2 bg-green-500 rounded-full inline-block';
}
}
})
.catch(error => {
console.error('Failed to refresh diagnostics:', error);
const connStatus = document.getElementById('connection-status');
if (connStatus) {
connStatus.className = 'ml-2 w-2 h-2 bg-red-500 rounded-full inline-block';
}
});
}
// Initial update
updateDiagnosticsData();
// Set up refresh interval and register it
const interval = setInterval(updateDiagnosticsData, 10000);
window.SLMConnectionManager.registerInterval(interval);
console.log(`Diagnostics card for ${diagUnitId} initialized`);
})();
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,846 +0,0 @@
{% 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">
<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="flex items-center gap-4">
<select id="files-filter" onchange="filterFiles()"
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 Files</option>
<option value="audio">Audio</option>
<option value="data">Data</option>
<option value="log">Logs</option>
</select>
<button onclick="exportProjectData()"
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">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
Export All
</button>
</div>
</div>
<div id="project-files"
hx-get="/api/projects/{{ project_id }}/files"
hx-trigger="load"
hx-swap="innerHTML">
<div class="text-center py-8 text-gray-500">Loading data 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 %}

View File

@@ -1,249 +0,0 @@
{% 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 %}

View File

@@ -1,132 +0,0 @@
{% 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">
<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>
</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 Control Center
</p>
</div>
<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>
</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 %}

View File

@@ -21,61 +21,83 @@
</div> </div>
<!-- Main Content Grid --> <!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Projects Card --> <!-- SLM List -->
<div class="lg:col-span-1">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <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 mb-4">Active Units</h2>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Projects</h2>
<a href="/projects" class="text-sm text-seismo-orange hover:text-seismo-navy">View all</a> <!-- Search/Filter -->
<div class="mb-4 space-y-2">
<!-- Project Filter -->
<select id="project-filter"
name="project"
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="change"
hx-target="#slm-list"
hx-include="#search-input, #project-filter">
<option value="">All Projects</option>
<!-- Will be populated dynamically -->
</select>
<!-- Search Input -->
<input id="search-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="#search-input, #project-filter"
name="search">
</div> </div>
<div id="slm-projects-list" <!-- SLM List -->
class="space-y-3 max-h-[600px] overflow-y-auto" <div id="slm-list"
hx-get="/api/projects/list?status=active&project_type_id=sound_monitoring&view=compact" class="space-y-2 max-h-[600px] overflow-y-auto"
hx-trigger="load, every 60s" hx-get="/api/slm-dashboard/units"
hx-trigger="load, every 10s"
hx-swap="innerHTML"> hx-swap="innerHTML">
<div class="animate-pulse space-y-3"> <!-- 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 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>
</div>
<!-- Devices Card --> <!-- Live View Panel -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="lg:col-span-2">
<div class="flex items-center justify-between mb-4"> <div id="live-view-panel" 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">Devices</h2> <!-- Initial state - no unit selected -->
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a> <div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
</div> <svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="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 id="slm-devices-list" </svg>
class="space-y-3 max-h-[600px] overflow-y-auto" <p class="text-lg font-medium">No unit selected</p>
hx-get="/api/slm-dashboard/units?include_measurement=true" <p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
hx-trigger="load, every 15s"
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>
</div> </div>
<!-- Configuration Modal --> <!-- Configuration Modal -->
<div id="slm-config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50"> <div id="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="closeDeviceConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"> <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"> <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="slm-config-modal-content"> <div id="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>
@@ -85,31 +107,209 @@
</div> </div>
</div> </div>
<script> <!-- Command Center Modal -->
function openDeviceConfigModal(unitId) { <div id="command-center-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
const modal = document.getElementById('slm-config-modal'); <div class="flex items-center justify-center min-h-screen p-4">
modal.classList.remove('hidden'); <div class="bg-white dark:bg-gray-900 rounded-lg shadow-xl w-full max-w-7xl max-h-[90vh] overflow-y-auto">
<div class="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-6 py-4 flex items-center justify-between z-10">
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Command Center</h2>
<button onclick="closeCommandCenter()" 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"/>
</svg>
</button>
</div>
<div id="command-center-content" class="p-6">
<!-- Command center will load here -->
</div>
</div>
</div>
</div>
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, { <script>
target: '#slm-config-modal-content', // Global Connection Manager - ensures only one SLM connection at a time
window.SLMConnectionManager = {
activeIntervals: [],
activeWebSocket: null,
currentUnitId: null,
// Clear all existing connections
clearAll: function() {
console.log('SLMConnectionManager: Clearing all connections');
// Clear all intervals
this.activeIntervals.forEach(interval => {
clearInterval(interval);
});
this.activeIntervals = [];
// Close WebSocket if exists
if (this.activeWebSocket) {
this.activeWebSocket.close();
this.activeWebSocket = null;
}
// Clear any global intervals that might exist
if (window.refreshInterval) {
clearInterval(window.refreshInterval);
window.refreshInterval = null;
}
if (window.timerInterval) {
clearInterval(window.timerInterval);
window.timerInterval = null;
}
if (window.diagRefreshInterval) {
clearInterval(window.diagRefreshInterval);
window.diagRefreshInterval = null;
}
console.log('SLMConnectionManager: All connections cleared');
},
// Register a new interval
registerInterval: function(intervalId) {
this.activeIntervals.push(intervalId);
},
// Register WebSocket
registerWebSocket: function(ws) {
if (this.activeWebSocket) {
this.activeWebSocket.close();
}
this.activeWebSocket = ws;
},
// Set current unit
setCurrentUnit: function(unitId) {
if (this.currentUnitId !== unitId) {
this.clearAll();
this.currentUnitId = unitId;
}
}
};
// Function to select a unit and load DIAGNOSTICS CARD (not full command center)
function selectUnit(unitId) {
console.log(`Selecting unit: ${unitId}`);
// Clear all existing connections
window.SLMConnectionManager.clearAll();
// Remove active state from all items
document.querySelectorAll('.slm-unit-item').forEach(item => {
item.classList.remove('bg-seismo-orange', 'text-white');
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 DIAGNOSTICS CARD (not full live view)
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
target: '#live-view-panel',
swap: 'innerHTML' swap: 'innerHTML'
}); });
} }
function closeDeviceConfigModal() { // Open command center in modal
document.getElementById('slm-config-modal').classList.add('hidden'); function openCommandCenter(unitId) {
console.log(`Opening command center for: ${unitId}`);
// Clear diagnostics refresh before opening modal
window.SLMConnectionManager.clearAll();
const modal = document.getElementById('command-center-modal');
modal.classList.remove('hidden');
// Load full command center
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
target: '#command-center-content',
swap: 'innerHTML'
});
} }
// Close command center modal
function closeCommandCenter() {
console.log('Closing command center');
// Clear all command center connections
window.SLMConnectionManager.clearAll();
document.getElementById('command-center-modal').classList.add('hidden');
// Reload the diagnostics card for the currently selected unit
const activeUnit = document.querySelector('.slm-unit-item.bg-seismo-orange');
if (activeUnit) {
const unitIdMatch = activeUnit.getAttribute('onclick').match(/selectUnit\('(.+?)'\)/);
if (unitIdMatch) {
const unitId = unitIdMatch[1];
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${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 modals on escape key
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeDeviceConfigModal(); closeConfigModal();
closeCommandCenter();
} }
}); });
document.getElementById('slm-config-modal')?.addEventListener('click', function(e) { // Close modals when clicking outside
document.getElementById('config-modal')?.addEventListener('click', function(e) {
if (e.target === this) { if (e.target === this) {
closeDeviceConfigModal(); closeConfigModal();
} }
}); });
document.getElementById('command-center-modal')?.addEventListener('click', function(e) {
if (e.target === this) {
closeCommandCenter();
}
});
// Load projects for filter dropdown on page load
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/slm-dashboard/projects')
.then(response => response.json())
.then(data => {
const projectFilter = document.getElementById('project-filter');
if (projectFilter && data.projects) {
data.projects.forEach(project => {
const option = document.createElement('option');
option.value = project;
option.textContent = project;
projectFilter.appendChild(option);
});
}
})
.catch(error => console.error('Failed to load projects:', error));
});
// Cleanup on page unload
window.addEventListener('beforeunload', function() {
window.SLMConnectionManager.clearAll();
});
</script> </script>
{% endblock %} {% endblock %}