Compare commits
3 Commits
7971092509
...
sfm-old-04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b9a3da8487 | ||
|
|
e1b965c24c | ||
|
|
ee025f1f34 |
@@ -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
1
.gitignore
vendored
@@ -211,4 +211,3 @@ __marimo__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
data/
|
data/
|
||||||
.aider*
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
156
backend/main.py
156
backend/main.py
@@ -7,7 +7,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||||
from fastapi.exceptions import RequestValidationError
|
from fastapi.exceptions import RequestValidationError
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from typing import List, Dict, Optional
|
from typing import List, Dict
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
# Configure logging
|
# Configure logging
|
||||||
@@ -18,7 +18,7 @@ logging.basicConfig(
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler
|
from backend.routers import roster, units, photos, roster_edit, 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
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -84,7 +84,6 @@ app.include_router(roster.router)
|
|||||||
app.include_router(units.router)
|
app.include_router(units.router)
|
||||||
app.include_router(photos.router)
|
app.include_router(photos.router)
|
||||||
app.include_router(roster_edit.router)
|
app.include_router(roster_edit.router)
|
||||||
app.include_router(roster_rename.router)
|
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
app.include_router(activity.router)
|
app.include_router(activity.router)
|
||||||
@@ -96,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
|
||||||
@@ -158,132 +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,
|
|
||||||
from_project: Optional[str] = None,
|
|
||||||
from_nrl: Optional[str] = None,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Legacy SLM control center dashboard for a specific unit"""
|
|
||||||
# Get project details if from_project is provided
|
|
||||||
project = None
|
|
||||||
if from_project:
|
|
||||||
from backend.models import Project
|
|
||||||
project = db.query(Project).filter_by(id=from_project).first()
|
|
||||||
|
|
||||||
# Get NRL location details if from_nrl is provided
|
|
||||||
nrl_location = None
|
|
||||||
if from_nrl:
|
|
||||||
from backend.models import NRLLocation
|
|
||||||
nrl_location = db.query(NRLLocation).filter_by(id=from_nrl).first()
|
|
||||||
|
|
||||||
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"from_project": from_project,
|
|
||||||
"from_nrl": from_nrl,
|
|
||||||
"project": project,
|
|
||||||
"nrl_location": nrl_location
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/seismographs", response_class=HTMLResponse)
|
@app.get("/seismographs", response_class=HTMLResponse)
|
||||||
async def seismographs_page(request: Request):
|
async def seismographs_page(request: Request):
|
||||||
"""Seismographs management dashboard"""
|
"""Seismographs management dashboard"""
|
||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects", response_class=HTMLResponse)
|
|
||||||
async def projects_page(request: Request):
|
|
||||||
"""Projects management and overview"""
|
|
||||||
return templates.TemplateResponse("projects/overview.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}", response_class=HTMLResponse)
|
|
||||||
async def project_detail_page(request: Request, project_id: str):
|
|
||||||
"""Project detail dashboard"""
|
|
||||||
return templates.TemplateResponse("projects/detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"project_id": project_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
|
||||||
async def nrl_detail_page(
|
|
||||||
request: Request,
|
|
||||||
project_id: str,
|
|
||||||
location_id: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
|
||||||
from sqlalchemy import and_
|
|
||||||
|
|
||||||
# Get project
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project:
|
|
||||||
return templates.TemplateResponse("404.html", {
|
|
||||||
"request": request,
|
|
||||||
"message": "Project not found"
|
|
||||||
}, status_code=404)
|
|
||||||
|
|
||||||
# Get location
|
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
|
||||||
id=location_id,
|
|
||||||
project_id=project_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not location:
|
|
||||||
return templates.TemplateResponse("404.html", {
|
|
||||||
"request": request,
|
|
||||||
"message": "Location not found"
|
|
||||||
}, status_code=404)
|
|
||||||
|
|
||||||
# Get active assignment
|
|
||||||
assignment = db.query(UnitAssignment).filter(
|
|
||||||
and_(
|
|
||||||
UnitAssignment.location_id == location_id,
|
|
||||||
UnitAssignment.status == "active"
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
assigned_unit = None
|
|
||||||
if assignment:
|
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
|
||||||
|
|
||||||
# Get session count
|
|
||||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
|
||||||
file_count = db.query(DataFile).join(
|
|
||||||
RecordingSession,
|
|
||||||
DataFile.session_id == RecordingSession.id
|
|
||||||
).filter(RecordingSession.location_id == location_id).count()
|
|
||||||
|
|
||||||
# Check for active session
|
|
||||||
active_session = db.query(RecordingSession).filter(
|
|
||||||
and_(
|
|
||||||
RecordingSession.location_id == location_id,
|
|
||||||
RecordingSession.status == "recording"
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return templates.TemplateResponse("nrl_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"project_id": project_id,
|
|
||||||
"location_id": location_id,
|
|
||||||
"project": project,
|
|
||||||
"location": location,
|
|
||||||
"assignment": assignment,
|
|
||||||
"assigned_unit": assigned_unit,
|
|
||||||
"session_count": session_count,
|
|
||||||
"file_count": file_count,
|
|
||||||
"active_session": active_session,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PWA ROUTES =====
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
@app.get("/sw.js")
|
@app.get("/sw.js")
|
||||||
@@ -658,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
|
||||||
}
|
}
|
||||||
@@ -666,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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
@@ -1,888 +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
|
|
||||||
import logging
|
|
||||||
|
|
||||||
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")
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 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,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{project_id}/ftp-browser", response_class=HTMLResponse)
|
|
||||||
async def get_ftp_browser(
|
|
||||||
project_id: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Get FTP browser interface for downloading files from assigned SLMs.
|
|
||||||
Returns HTML partial with FTP browser.
|
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
# Only include SLM units
|
|
||||||
if unit and unit.device_type == "sound_level_meter":
|
|
||||||
units_data.append({
|
|
||||||
"assignment": assignment,
|
|
||||||
"unit": unit,
|
|
||||||
"location": location,
|
|
||||||
})
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/projects/ftp_browser.html", {
|
|
||||||
"request": request,
|
|
||||||
"project_id": project_id,
|
|
||||||
"units": units_data,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/ftp-download-to-server")
|
|
||||||
async def ftp_download_to_server(
|
|
||||||
project_id: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Download a file from an SLM to the server via FTP.
|
|
||||||
Creates a DataFile record and stores the file in data/Projects/{project_id}/
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
import os
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
|
||||||
from backend.models import DataFile
|
|
||||||
|
|
||||||
data = await request.json()
|
|
||||||
unit_id = data.get("unit_id")
|
|
||||||
remote_path = data.get("remote_path")
|
|
||||||
location_id = data.get("location_id")
|
|
||||||
|
|
||||||
if not unit_id or not remote_path:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
|
||||||
session = db.query(RecordingSession).filter(
|
|
||||||
and_(
|
|
||||||
RecordingSession.project_id == project_id,
|
|
||||||
RecordingSession.location_id == location_id,
|
|
||||||
RecordingSession.unit_id == unit_id,
|
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# If no active session, create one
|
|
||||||
if not session:
|
|
||||||
session = RecordingSession(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
project_id=project_id,
|
|
||||||
location_id=location_id,
|
|
||||||
unit_id=unit_id,
|
|
||||||
status="completed",
|
|
||||||
started_at=datetime.utcnow(),
|
|
||||||
stopped_at=datetime.utcnow(),
|
|
||||||
notes="Auto-created for FTP download"
|
|
||||||
)
|
|
||||||
db.add(session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(session)
|
|
||||||
|
|
||||||
# Download file from SLMM
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=300.0) as client:
|
|
||||||
response = await client.post(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download",
|
|
||||||
json={"remote_path": remote_path}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.is_success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status_code,
|
|
||||||
detail=f"Failed to download from SLMM: {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract filename from remote_path
|
|
||||||
filename = os.path.basename(remote_path)
|
|
||||||
|
|
||||||
# Determine file type from extension
|
|
||||||
ext = os.path.splitext(filename)[1].lower()
|
|
||||||
file_type_map = {
|
|
||||||
'.wav': 'audio',
|
|
||||||
'.mp3': 'audio',
|
|
||||||
'.csv': 'data',
|
|
||||||
'.txt': 'data',
|
|
||||||
'.log': 'log',
|
|
||||||
'.json': 'data',
|
|
||||||
}
|
|
||||||
file_type = file_type_map.get(ext, 'data')
|
|
||||||
|
|
||||||
# Create directory structure: data/Projects/{project_id}/{session_id}/
|
|
||||||
project_dir = Path(f"data/Projects/{project_id}/{session.id}")
|
|
||||||
project_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Save file to disk
|
|
||||||
file_path = project_dir / filename
|
|
||||||
file_content = response.content
|
|
||||||
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(file_content)
|
|
||||||
|
|
||||||
# Calculate checksum
|
|
||||||
checksum = hashlib.sha256(file_content).hexdigest()
|
|
||||||
|
|
||||||
# Create DataFile record
|
|
||||||
data_file = DataFile(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
session_id=session.id,
|
|
||||||
file_path=str(file_path.relative_to("data")), # Store relative to data/
|
|
||||||
file_type=file_type,
|
|
||||||
file_size_bytes=len(file_content),
|
|
||||||
downloaded_at=datetime.utcnow(),
|
|
||||||
checksum=checksum,
|
|
||||||
file_metadata=json.dumps({
|
|
||||||
"source": "ftp",
|
|
||||||
"remote_path": remote_path,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"location_id": location_id,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(data_file)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Downloaded {filename} to server",
|
|
||||||
"file_id": data_file.id,
|
|
||||||
"file_path": str(file_path),
|
|
||||||
"file_size": len(file_content),
|
|
||||||
}
|
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=504,
|
|
||||||
detail="Timeout downloading file from SLM"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error downloading file to server: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to download file to server: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{project_id}/ftp-download-folder-to-server")
|
|
||||||
async def ftp_download_folder_to_server(
|
|
||||||
project_id: str,
|
|
||||||
request: Request,
|
|
||||||
db: Session = Depends(get_db),
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Download an entire folder from an SLM to the server via FTP as a ZIP file.
|
|
||||||
Creates a DataFile record and stores the ZIP in data/Projects/{project_id}/
|
|
||||||
"""
|
|
||||||
import httpx
|
|
||||||
import os
|
|
||||||
import hashlib
|
|
||||||
from pathlib import Path
|
|
||||||
from backend.models import DataFile
|
|
||||||
|
|
||||||
data = await request.json()
|
|
||||||
unit_id = data.get("unit_id")
|
|
||||||
remote_path = data.get("remote_path")
|
|
||||||
location_id = data.get("location_id")
|
|
||||||
|
|
||||||
if not unit_id or not remote_path:
|
|
||||||
raise HTTPException(status_code=400, detail="Missing unit_id or remote_path")
|
|
||||||
|
|
||||||
# Get or create active session for this location/unit
|
|
||||||
session = db.query(RecordingSession).filter(
|
|
||||||
and_(
|
|
||||||
RecordingSession.project_id == project_id,
|
|
||||||
RecordingSession.location_id == location_id,
|
|
||||||
RecordingSession.unit_id == unit_id,
|
|
||||||
RecordingSession.status.in_(["recording", "paused"])
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# If no active session, create one
|
|
||||||
if not session:
|
|
||||||
session = RecordingSession(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
project_id=project_id,
|
|
||||||
location_id=location_id,
|
|
||||||
unit_id=unit_id,
|
|
||||||
status="completed",
|
|
||||||
started_at=datetime.utcnow(),
|
|
||||||
stopped_at=datetime.utcnow(),
|
|
||||||
notes="Auto-created for FTP folder download"
|
|
||||||
)
|
|
||||||
db.add(session)
|
|
||||||
db.commit()
|
|
||||||
db.refresh(session)
|
|
||||||
|
|
||||||
# Download folder from SLMM
|
|
||||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
|
||||||
|
|
||||||
try:
|
|
||||||
async with httpx.AsyncClient(timeout=600.0) as client: # Longer timeout for folders
|
|
||||||
response = await client.post(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/ftp/download-folder",
|
|
||||||
json={"remote_path": remote_path}
|
|
||||||
)
|
|
||||||
|
|
||||||
if not response.is_success:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=response.status_code,
|
|
||||||
detail=f"Failed to download folder from SLMM: {response.text}"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract folder name from remote_path
|
|
||||||
folder_name = os.path.basename(remote_path.rstrip('/'))
|
|
||||||
filename = f"{folder_name}.zip"
|
|
||||||
|
|
||||||
# Create directory structure: data/Projects/{project_id}/{session_id}/
|
|
||||||
project_dir = Path(f"data/Projects/{project_id}/{session.id}")
|
|
||||||
project_dir.mkdir(parents=True, exist_ok=True)
|
|
||||||
|
|
||||||
# Save ZIP file to disk
|
|
||||||
file_path = project_dir / filename
|
|
||||||
file_content = response.content
|
|
||||||
|
|
||||||
with open(file_path, 'wb') as f:
|
|
||||||
f.write(file_content)
|
|
||||||
|
|
||||||
# Calculate checksum
|
|
||||||
checksum = hashlib.sha256(file_content).hexdigest()
|
|
||||||
|
|
||||||
# Create DataFile record
|
|
||||||
data_file = DataFile(
|
|
||||||
id=str(uuid.uuid4()),
|
|
||||||
session_id=session.id,
|
|
||||||
file_path=str(file_path.relative_to("data")), # Store relative to data/
|
|
||||||
file_type='archive', # ZIP archives
|
|
||||||
file_size_bytes=len(file_content),
|
|
||||||
downloaded_at=datetime.utcnow(),
|
|
||||||
checksum=checksum,
|
|
||||||
file_metadata=json.dumps({
|
|
||||||
"source": "ftp_folder",
|
|
||||||
"remote_path": remote_path,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"location_id": location_id,
|
|
||||||
"folder_name": folder_name,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
db.add(data_file)
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Downloaded folder {folder_name} to server as ZIP",
|
|
||||||
"file_id": data_file.id,
|
|
||||||
"file_path": str(file_path),
|
|
||||||
"file_size": len(file_content),
|
|
||||||
}
|
|
||||||
|
|
||||||
except httpx.TimeoutException:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=504,
|
|
||||||
detail="Timeout downloading folder from SLM (large folders may take a while)"
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error downloading folder to server: {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to download folder to server: {str(e)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# 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,
|
|
||||||
})
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"""
|
|
||||||
Roster Unit Rename Router
|
|
||||||
|
|
||||||
Provides endpoint for safely renaming unit IDs across all database tables.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Form
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from datetime import datetime
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from backend.database import get_db
|
|
||||||
from backend.models import RosterUnit, Emitter, UnitHistory
|
|
||||||
from backend.routers.roster_edit import record_history, sync_slm_to_slmm_cache
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-rename"])
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/rename")
|
|
||||||
async def rename_unit(
|
|
||||||
old_id: str = Form(...),
|
|
||||||
new_id: str = Form(...),
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""
|
|
||||||
Rename a unit ID across all tables.
|
|
||||||
Updates the unit ID in roster, emitters, unit_history, and all foreign key references.
|
|
||||||
|
|
||||||
IMPORTANT: This operation updates the primary key, which affects all relationships.
|
|
||||||
"""
|
|
||||||
# Validate input
|
|
||||||
if not old_id or not new_id:
|
|
||||||
raise HTTPException(status_code=400, detail="Both old_id and new_id are required")
|
|
||||||
|
|
||||||
if old_id == new_id:
|
|
||||||
raise HTTPException(status_code=400, detail="New ID must be different from old ID")
|
|
||||||
|
|
||||||
# Check if old unit exists
|
|
||||||
old_unit = db.query(RosterUnit).filter(RosterUnit.id == old_id).first()
|
|
||||||
if not old_unit:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unit '{old_id}' not found")
|
|
||||||
|
|
||||||
# Check if new ID already exists
|
|
||||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == new_id).first()
|
|
||||||
if existing_unit:
|
|
||||||
raise HTTPException(status_code=409, detail=f"Unit ID '{new_id}' already exists")
|
|
||||||
|
|
||||||
device_type = old_unit.device_type
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Record history for the rename operation (using old_id since that's still valid)
|
|
||||||
record_history(
|
|
||||||
db=db,
|
|
||||||
unit_id=old_id,
|
|
||||||
change_type="id_change",
|
|
||||||
field_name="id",
|
|
||||||
old_value=old_id,
|
|
||||||
new_value=new_id,
|
|
||||||
source="manual",
|
|
||||||
notes=f"Unit renamed from '{old_id}' to '{new_id}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update roster table (primary)
|
|
||||||
old_unit.id = new_id
|
|
||||||
old_unit.last_updated = datetime.utcnow()
|
|
||||||
|
|
||||||
# Update emitters table
|
|
||||||
emitter = db.query(Emitter).filter(Emitter.id == old_id).first()
|
|
||||||
if emitter:
|
|
||||||
emitter.id = new_id
|
|
||||||
|
|
||||||
# Update unit_history table (all entries for this unit)
|
|
||||||
db.query(UnitHistory).filter(UnitHistory.unit_id == old_id).update(
|
|
||||||
{"unit_id": new_id},
|
|
||||||
synchronize_session=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update deployed_with_modem_id references (units that reference this as modem)
|
|
||||||
db.query(RosterUnit).filter(RosterUnit.deployed_with_modem_id == old_id).update(
|
|
||||||
{"deployed_with_modem_id": new_id},
|
|
||||||
synchronize_session=False
|
|
||||||
)
|
|
||||||
|
|
||||||
# Update unit_assignments table (if exists)
|
|
||||||
try:
|
|
||||||
from backend.models import UnitAssignment
|
|
||||||
db.query(UnitAssignment).filter(UnitAssignment.unit_id == old_id).update(
|
|
||||||
{"unit_id": new_id},
|
|
||||||
synchronize_session=False
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update unit_assignments: {e}")
|
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
|
||||||
try:
|
|
||||||
from backend.models import RecordingSession
|
|
||||||
db.query(RecordingSession).filter(RecordingSession.unit_id == old_id).update(
|
|
||||||
{"unit_id": new_id},
|
|
||||||
synchronize_session=False
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"Could not update recording_sessions: {e}")
|
|
||||||
|
|
||||||
# Commit all changes
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
# If sound level meter, sync updated config to SLMM cache
|
|
||||||
if device_type == "sound_level_meter":
|
|
||||||
logger.info(f"Syncing renamed SLM {new_id} (was {old_id}) config to SLMM cache...")
|
|
||||||
result = await sync_slm_to_slmm_cache(
|
|
||||||
unit_id=new_id,
|
|
||||||
host=old_unit.slm_host,
|
|
||||||
tcp_port=old_unit.slm_tcp_port,
|
|
||||||
ftp_port=old_unit.slm_ftp_port,
|
|
||||||
deployed_with_modem_id=old_unit.deployed_with_modem_id,
|
|
||||||
db=db
|
|
||||||
)
|
|
||||||
|
|
||||||
if not result["success"]:
|
|
||||||
logger.warning(f"SLMM cache sync warning for renamed unit {new_id}: {result['message']}")
|
|
||||||
|
|
||||||
logger.info(f"Successfully renamed unit '{old_id}' to '{new_id}'")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"success": True,
|
|
||||||
"message": f"Successfully renamed unit from '{old_id}' to '{new_id}'",
|
|
||||||
"old_id": old_id,
|
|
||||||
"new_id": new_id,
|
|
||||||
"device_type": device_type
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
logger.error(f"Error renaming unit '{old_id}' to '{new_id}': {e}")
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=500,
|
|
||||||
detail=f"Failed to rename unit: {str(e)}"
|
|
||||||
)
|
|
||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
})
|
})
|
||||||
@@ -158,7 +128,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
is_measuring = False
|
is_measuring = False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||||
# Get measurement state
|
# Get measurement state
|
||||||
state_response = await client.get(
|
state_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||||
@@ -168,23 +138,7 @@ async def get_live_view(request: Request, unit_id: str, db: Session = Depends(ge
|
|||||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||||
is_measuring = state_data.get("is_measuring", False)
|
is_measuring = state_data.get("is_measuring", False)
|
||||||
|
|
||||||
# If measuring, sync start time from FTP to database (fixes wrong timestamps)
|
# Get live status
|
||||||
if is_measuring:
|
|
||||||
try:
|
|
||||||
sync_response = await client.post(
|
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/sync-start-time",
|
|
||||||
timeout=10.0
|
|
||||||
)
|
|
||||||
if sync_response.status_code == 200:
|
|
||||||
sync_data = sync_response.json()
|
|
||||||
logger.info(f"Synced start time for {unit_id}: {sync_data.get('message')}")
|
|
||||||
else:
|
|
||||||
logger.warning(f"Failed to sync start time for {unit_id}: {sync_response.status_code}")
|
|
||||||
except Exception as e:
|
|
||||||
# Don't fail the whole request if sync fails
|
|
||||||
logger.warning(f"Could not sync start time for {unit_id}: {e}")
|
|
||||||
|
|
||||||
# Get live status (now with corrected start time)
|
|
||||||
status_response = await client.get(
|
status_response = await client.get(
|
||||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||||
)
|
)
|
||||||
@@ -378,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})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ from typing import Dict, Any
|
|||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import RosterUnit
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["units"])
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
@@ -43,32 +42,3 @@ def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
"note": unit_data.get("note", ""),
|
"note": unit_data.get("note", ""),
|
||||||
"coordinates": coords
|
"coordinates": coords
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/units/{unit_id}")
|
|
||||||
def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Get unit data directly from the roster (for settings/configuration).
|
|
||||||
"""
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"id": unit.id,
|
|
||||||
"unit_type": unit.unit_type,
|
|
||||||
"device_type": unit.device_type,
|
|
||||||
"deployed": unit.deployed,
|
|
||||||
"retired": unit.retired,
|
|
||||||
"note": unit.note,
|
|
||||||
"location": unit.location,
|
|
||||||
"address": unit.address,
|
|
||||||
"coordinates": unit.coordinates,
|
|
||||||
"slm_host": unit.slm_host,
|
|
||||||
"slm_tcp_port": unit.slm_tcp_port,
|
|
||||||
"slm_ftp_port": unit.slm_ftp_port,
|
|
||||||
"slm_model": unit.slm_model,
|
|
||||||
"slm_serial_number": unit.slm_serial_number,
|
|
||||||
"deployed_with_modem_id": unit.deployed_with_modem_id
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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()
|
|
||||||
@@ -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
|
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -4,19 +4,17 @@ 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://host.docker.internal:8100
|
- PORT=8001
|
||||||
|
- SLMM_BASE_URL=http://localhost:8100
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- slmm
|
- slmm
|
||||||
extra_hosts:
|
|
||||||
- "host.docker.internal:host-gateway"
|
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -25,36 +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
|
||||||
network_mode: host
|
network_mode: host
|
||||||
volumes:
|
volumes:
|
||||||
- ../slmm/data:/app/data
|
- ../../slmm/data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- PYTHONUNBUFFERED=1
|
||||||
- PORT=8100
|
- PORT=8100
|
||||||
|
|||||||
138
rename_unit.py
138
rename_unit.py
@@ -1,138 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
"""
|
|
||||||
Script to rename a unit ID in the database.
|
|
||||||
This updates the unit across all tables with proper foreign key handling.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from sqlalchemy import create_engine, text
|
|
||||||
from sqlalchemy.orm import sessionmaker
|
|
||||||
|
|
||||||
DATABASE_URL = "sqlite:///data/sfm.db"
|
|
||||||
|
|
||||||
def rename_unit(old_id: str, new_id: str):
|
|
||||||
"""
|
|
||||||
Rename a unit ID across all relevant tables.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
old_id: Current unit ID (e.g., "SLM4301")
|
|
||||||
new_id: New unit ID (e.g., "SLM-43-01")
|
|
||||||
"""
|
|
||||||
engine = create_engine(DATABASE_URL)
|
|
||||||
Session = sessionmaker(bind=engine)
|
|
||||||
session = Session()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if old unit exists
|
|
||||||
result = session.execute(
|
|
||||||
text("SELECT id, device_type FROM roster WHERE id = :old_id"),
|
|
||||||
{"old_id": old_id}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
print(f"❌ Error: Unit '{old_id}' not found in roster")
|
|
||||||
return False
|
|
||||||
|
|
||||||
device_type = result[1]
|
|
||||||
print(f"✓ Found unit '{old_id}' (device_type: {device_type})")
|
|
||||||
|
|
||||||
# Check if new ID already exists
|
|
||||||
result = session.execute(
|
|
||||||
text("SELECT id FROM roster WHERE id = :new_id"),
|
|
||||||
{"new_id": new_id}
|
|
||||||
).fetchone()
|
|
||||||
|
|
||||||
if result:
|
|
||||||
print(f"❌ Error: Unit ID '{new_id}' already exists")
|
|
||||||
return False
|
|
||||||
|
|
||||||
print(f"\n🔄 Renaming '{old_id}' → '{new_id}'...\n")
|
|
||||||
|
|
||||||
# Update roster table (primary)
|
|
||||||
session.execute(
|
|
||||||
text("UPDATE roster SET id = :new_id WHERE id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
print(f" ✓ Updated roster")
|
|
||||||
|
|
||||||
# Update emitters table
|
|
||||||
result = session.execute(
|
|
||||||
text("UPDATE emitters SET id = :new_id WHERE id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
if result.rowcount > 0:
|
|
||||||
print(f" ✓ Updated emitters ({result.rowcount} rows)")
|
|
||||||
|
|
||||||
# Update unit_history table
|
|
||||||
result = session.execute(
|
|
||||||
text("UPDATE unit_history SET unit_id = :new_id WHERE unit_id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
if result.rowcount > 0:
|
|
||||||
print(f" ✓ Updated unit_history ({result.rowcount} rows)")
|
|
||||||
|
|
||||||
# Update deployed_with_modem_id references
|
|
||||||
result = session.execute(
|
|
||||||
text("UPDATE roster SET deployed_with_modem_id = :new_id WHERE deployed_with_modem_id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
if result.rowcount > 0:
|
|
||||||
print(f" ✓ Updated modem references ({result.rowcount} rows)")
|
|
||||||
|
|
||||||
# Update unit_assignments table (if exists)
|
|
||||||
try:
|
|
||||||
result = session.execute(
|
|
||||||
text("UPDATE unit_assignments SET unit_id = :new_id WHERE unit_id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
if result.rowcount > 0:
|
|
||||||
print(f" ✓ Updated unit_assignments ({result.rowcount} rows)")
|
|
||||||
except Exception:
|
|
||||||
pass # Table may not exist
|
|
||||||
|
|
||||||
# Update recording_sessions table (if exists)
|
|
||||||
try:
|
|
||||||
result = session.execute(
|
|
||||||
text("UPDATE recording_sessions SET unit_id = :new_id WHERE unit_id = :old_id"),
|
|
||||||
{"new_id": new_id, "old_id": old_id}
|
|
||||||
)
|
|
||||||
if result.rowcount > 0:
|
|
||||||
print(f" ✓ Updated recording_sessions ({result.rowcount} rows)")
|
|
||||||
except Exception:
|
|
||||||
pass # Table may not exist
|
|
||||||
|
|
||||||
# Commit all changes
|
|
||||||
session.commit()
|
|
||||||
print(f"\n✅ Successfully renamed unit '{old_id}' to '{new_id}'")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
session.rollback()
|
|
||||||
print(f"\n❌ Error during rename: {e}")
|
|
||||||
return False
|
|
||||||
finally:
|
|
||||||
session.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
if len(sys.argv) != 3:
|
|
||||||
print("Usage: python rename_unit.py <old_id> <new_id>")
|
|
||||||
print("Example: python rename_unit.py SLM4301 SLM-43-01")
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
old_id = sys.argv[1]
|
|
||||||
new_id = sys.argv[2]
|
|
||||||
|
|
||||||
print(f"Unit Renaming Tool")
|
|
||||||
print(f"=" * 50)
|
|
||||||
print(f"Old ID: {old_id}")
|
|
||||||
print(f"New ID: {new_id}")
|
|
||||||
print(f"=" * 50)
|
|
||||||
|
|
||||||
confirm = input(f"\nAre you sure you want to rename '{old_id}' to '{new_id}'? (yes/no): ")
|
|
||||||
if confirm.lower() != 'yes':
|
|
||||||
print("❌ Rename cancelled")
|
|
||||||
sys.exit(0)
|
|
||||||
|
|
||||||
success = rename_unit(old_id, new_id)
|
|
||||||
sys.exit(0 if success else 1)
|
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}?from_project={{ project_id }}&from_nrl={{ location_id }}" class="text-seismo-orange hover:text-seismo-navy">
|
|
||||||
{{ assigned_unit.id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% if assigned_unit.slm_model %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ assigned_unit.slm_model }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% if assignment %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
|
|
||||||
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at.strftime('%Y-%m-%d %H:%M') if assignment.assigned_at else 'N/A' }}</div>
|
|
||||||
</div>
|
|
||||||
{% if assignment.notes %}
|
|
||||||
<div>
|
|
||||||
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
|
||||||
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
<div class="pt-2">
|
|
||||||
<button onclick="unassignUnit('{{ assignment.id }}')"
|
|
||||||
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors">
|
|
||||||
Unassign Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8">
|
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
|
|
||||||
<button onclick="openAssignModal()"
|
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
|
||||||
Assign a Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stats Cards -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mt-6">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Total Sessions</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ session_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-12 h-12 bg-blue-100 dark:bg-blue-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Data Files</p>
|
|
||||||
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ file_count }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-12 h-12 bg-green-100 dark:bg-green-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">Active Session</p>
|
|
||||||
<p class="text-lg font-semibold text-gray-900 dark:text-white mt-2">
|
|
||||||
{% if active_session %}
|
|
||||||
<span class="text-green-600 dark:text-green-400">Recording</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-gray-500">Idle</span>
|
|
||||||
{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="w-12 h-12 bg-purple-100 dark:bg-purple-900/30 rounded-lg flex items-center justify-center">
|
|
||||||
<svg class="w-6 h-6 text-purple-600 dark:text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Settings Tab -->
|
|
||||||
<div id="settings-tab" class="tab-panel hidden">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Location Settings</h2>
|
|
||||||
|
|
||||||
<form id="location-settings-form" class="space-y-6">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
|
|
||||||
<input type="text" id="settings-name" value="{{ location.name }}"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
|
||||||
<textarea id="settings-description" rows="3"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">{{ location.description or '' }}</textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
|
||||||
<input type="text" id="settings-address" value="{{ location.address or '' }}"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
|
||||||
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
|
|
||||||
placeholder="40.7128,-74.0060"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
|
||||||
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="settings-error" class="hidden text-sm text-red-600"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
|
||||||
Save Changes
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Command Center Tab -->
|
|
||||||
{% if assigned_unit %}
|
|
||||||
<div id="command-tab" class="tab-panel hidden">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
|
||||||
SLM Command Center - {{ assigned_unit.id }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div id="slm-command-center"
|
|
||||||
hx-get="/api/slm-dashboard/live-view/{{ assigned_unit.id if assigned_unit else '' }}"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center py-8 text-gray-500">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
|
||||||
<p>Loading command center...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- Recording Sessions Tab -->
|
|
||||||
<div id="sessions-tab" class="tab-panel hidden">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recording Sessions</h2>
|
|
||||||
{% if assigned_unit %}
|
|
||||||
<button onclick="openScheduleModal()"
|
|
||||||
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
|
||||||
Schedule Session
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="sessions-list"
|
|
||||||
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/sessions"
|
|
||||||
hx-trigger="load, every 30s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading sessions...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Data Files Tab -->
|
|
||||||
<div id="data-tab" class="tab-panel hidden">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Data Files</h2>
|
|
||||||
<div class="text-sm text-gray-500">
|
|
||||||
<span class="font-medium">{{ file_count }}</span> files
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="data-files-list"
|
|
||||||
hx-get="/api/projects/{{ project_id }}/nrl/{{ location_id }}/files"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading data files...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Assign Unit Modal -->
|
|
||||||
<div id="assign-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Attach a sound level meter to this location</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="closeAssignModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form id="assign-form" class="p-6 space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Available Units</label>
|
|
||||||
<select id="assign-unit-id" name="unit_id"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white" required>
|
|
||||||
<option value="">Loading units...</option>
|
|
||||||
</select>
|
|
||||||
<p id="assign-empty" class="hidden text-xs text-gray-500 mt-2">No available units for this location type.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
|
||||||
<textarea id="assign-notes" name="notes" rows="2"
|
|
||||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="assign-error" class="hidden text-sm text-red-600"></div>
|
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-2">
|
|
||||||
<button type="button" onclick="closeAssignModal()"
|
|
||||||
class="px-6 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button type="submit"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
|
|
||||||
Assign Unit
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const projectId = "{{ project_id }}";
|
|
||||||
const locationId = "{{ location_id }}";
|
|
||||||
|
|
||||||
// Tab switching
|
|
||||||
function switchTab(tabName) {
|
|
||||||
// Hide all tab panels
|
|
||||||
document.querySelectorAll('.tab-panel').forEach(panel => {
|
|
||||||
panel.classList.add('hidden');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Reset all tab buttons
|
|
||||||
document.querySelectorAll('.tab-button').forEach(button => {
|
|
||||||
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
|
|
||||||
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Show selected tab panel
|
|
||||||
const panel = document.getElementById(`${tabName}-tab`);
|
|
||||||
if (panel) {
|
|
||||||
panel.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Highlight selected tab button
|
|
||||||
const button = document.querySelector(`[data-tab="${tabName}"]`);
|
|
||||||
if (button) {
|
|
||||||
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
|
|
||||||
button.classList.add('border-seismo-orange', 'text-seismo-orange');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Location settings form submission
|
|
||||||
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
name: document.getElementById('settings-name').value.trim(),
|
|
||||||
description: document.getElementById('settings-description').value.trim() || null,
|
|
||||||
address: document.getElementById('settings-address').value.trim() || null,
|
|
||||||
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
|
|
||||||
method: 'PUT',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify(payload)
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to update location');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('settings-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to update location.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Assign modal functions
|
|
||||||
function openAssignModal() {
|
|
||||||
const modal = document.getElementById('assign-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
loadAvailableUnits();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAssignModal() {
|
|
||||||
document.getElementById('assign-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAvailableUnits() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=sound`);
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Failed to load available units');
|
|
||||||
}
|
|
||||||
const data = await response.json();
|
|
||||||
const select = document.getElementById('assign-unit-id');
|
|
||||||
select.innerHTML = '<option value="">Select a unit</option>';
|
|
||||||
|
|
||||||
if (!data.length) {
|
|
||||||
document.getElementById('assign-empty').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.forEach(unit => {
|
|
||||||
const option = document.createElement('option');
|
|
||||||
option.value = unit.id;
|
|
||||||
option.textContent = `${unit.id} • ${unit.model || unit.device_type}`;
|
|
||||||
select.appendChild(option);
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('assign-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to load units.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('assign-form').addEventListener('submit', async function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
const unitId = document.getElementById('assign-unit-id').value;
|
|
||||||
const notes = document.getElementById('assign-notes').value.trim();
|
|
||||||
|
|
||||||
if (!unitId) {
|
|
||||||
document.getElementById('assign-error').textContent = 'Select a unit to assign.';
|
|
||||||
document.getElementById('assign-error').classList.remove('hidden');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('unit_id', unitId);
|
|
||||||
formData.append('notes', notes);
|
|
||||||
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/assign`, {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to assign unit');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
const errorEl = document.getElementById('assign-error');
|
|
||||||
errorEl.textContent = err.message || 'Failed to assign unit.';
|
|
||||||
errorEl.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function unassignUnit(assignmentId) {
|
|
||||||
if (!confirm('Unassign this unit from the location?')) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const data = await response.json().catch(() => ({}));
|
|
||||||
throw new Error(data.detail || 'Failed to unassign unit');
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.reload();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to unassign unit.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcuts
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeAssignModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside to close modal
|
|
||||||
document.getElementById('assign-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeAssignModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -1,431 +0,0 @@
|
|||||||
<!-- FTP File Browser for SLMs v2.0 - Folder Download Support -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Download Files from SLMs</h2>
|
|
||||||
|
|
||||||
{% if units %}
|
|
||||||
<div class="space-y-6">
|
|
||||||
{% for unit_item in units %}
|
|
||||||
<div class="border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
||||||
<!-- Unit Header -->
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900 px-4 py-3 flex items-center justify-between">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
|
||||||
{{ unit_item.unit.id }}
|
|
||||||
</h3>
|
|
||||||
{% if unit_item.location %}
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
@ {{ unit_item.location.name }}
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
<span id="ftp-status-{{ unit_item.unit.id }}" class="px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">
|
|
||||||
Checking...
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button onclick="enableFTP('{{ unit_item.unit.id }}')"
|
|
||||||
id="enable-ftp-{{ unit_item.unit.id }}"
|
|
||||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors"
|
|
||||||
disabled>
|
|
||||||
Enable FTP
|
|
||||||
</button>
|
|
||||||
<button onclick="disableFTP('{{ unit_item.unit.id }}')"
|
|
||||||
id="disable-ftp-{{ unit_item.unit.id }}"
|
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors"
|
|
||||||
disabled>
|
|
||||||
Disable FTP
|
|
||||||
</button>
|
|
||||||
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
|
|
||||||
id="browse-ftp-{{ unit_item.unit.id }}"
|
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors"
|
|
||||||
disabled>
|
|
||||||
Browse Files
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FTP File List -->
|
|
||||||
<div id="ftp-files-{{ unit_item.unit.id }}" class="hidden" data-location-id="{{ unit_item.location.id if unit_item.location else '' }}">
|
|
||||||
<div class="p-4">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<svg class="w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"></path>
|
|
||||||
</svg>
|
|
||||||
<span id="current-path-{{ unit_item.unit.id }}" class="text-sm font-mono text-gray-600 dark:text-gray-400">/NL-43</span>
|
|
||||||
<button onclick="loadFTPFiles('{{ unit_item.unit.id }}', '/NL-43')"
|
|
||||||
class="ml-auto text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div id="ftp-file-list-{{ unit_item.unit.id }}" class="space-y-1">
|
|
||||||
<!-- Files will be loaded here -->
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
|
||||||
</svg>
|
|
||||||
<p>No units assigned to this project</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Check FTP status for all units on load
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
{% for unit_item in units %}
|
|
||||||
checkFTPStatus('{{ unit_item.unit.id }}');
|
|
||||||
{% endfor %}
|
|
||||||
});
|
|
||||||
|
|
||||||
async function checkFTPStatus(unitId) {
|
|
||||||
const statusSpan = document.getElementById(`ftp-status-${unitId}`);
|
|
||||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
|
||||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
|
||||||
const browseBtn = document.getElementById(`browse-ftp-${unitId}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/status`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.ftp_enabled) {
|
|
||||||
statusSpan.textContent = 'FTP Enabled';
|
|
||||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300';
|
|
||||||
enableBtn.disabled = true;
|
|
||||||
disableBtn.disabled = false;
|
|
||||||
browseBtn.disabled = false;
|
|
||||||
} else {
|
|
||||||
statusSpan.textContent = 'FTP Disabled';
|
|
||||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300';
|
|
||||||
enableBtn.disabled = false;
|
|
||||||
disableBtn.disabled = true;
|
|
||||||
browseBtn.disabled = true;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
statusSpan.textContent = 'Error';
|
|
||||||
statusSpan.className = 'px-2 py-1 text-xs font-medium rounded-full bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300';
|
|
||||||
console.error('Error checking FTP status:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function enableFTP(unitId) {
|
|
||||||
const enableBtn = document.getElementById(`enable-ftp-${unitId}`);
|
|
||||||
enableBtn.disabled = true;
|
|
||||||
enableBtn.textContent = 'Enabling...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/enable`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await checkFTPStatus(unitId);
|
|
||||||
// Auto-load files after enabling
|
|
||||||
setTimeout(() => loadFTPFiles(unitId, '/NL-43'), 1000);
|
|
||||||
} else {
|
|
||||||
alert('Failed to enable FTP');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error enabling FTP: ' + error);
|
|
||||||
} finally {
|
|
||||||
enableBtn.textContent = 'Enable FTP';
|
|
||||||
enableBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function disableFTP(unitId) {
|
|
||||||
if (!confirm('Disable FTP on this unit? This will close the FTP connection.')) return;
|
|
||||||
|
|
||||||
const disableBtn = document.getElementById(`disable-ftp-${unitId}`);
|
|
||||||
disableBtn.disabled = true;
|
|
||||||
disableBtn.textContent = 'Disabling...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/disable`, {
|
|
||||||
method: 'POST'
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
await checkFTPStatus(unitId);
|
|
||||||
// Hide file list
|
|
||||||
document.getElementById(`ftp-files-${unitId}`).classList.add('hidden');
|
|
||||||
} else {
|
|
||||||
alert('Failed to disable FTP');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error disabling FTP: ' + error);
|
|
||||||
} finally {
|
|
||||||
disableBtn.textContent = 'Disable FTP';
|
|
||||||
disableBtn.disabled = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadFTPFiles(unitId, path) {
|
|
||||||
const fileListDiv = document.getElementById(`ftp-file-list-${unitId}`);
|
|
||||||
const filesContainer = document.getElementById(`ftp-files-${unitId}`);
|
|
||||||
const currentPathSpan = document.getElementById(`current-path-${unitId}`);
|
|
||||||
|
|
||||||
// Show loading
|
|
||||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">Loading files...</div>';
|
|
||||||
filesContainer.classList.remove('hidden');
|
|
||||||
currentPathSpan.textContent = path;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/files?path=${encodeURIComponent(path)}`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (!data.files || data.files.length === 0) {
|
|
||||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-gray-500">No files found</div>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort: directories first, then files
|
|
||||||
const sorted = data.files.sort((a, b) => {
|
|
||||||
if (a.is_dir && !b.is_dir) return -1;
|
|
||||||
if (!a.is_dir && b.is_dir) return 1;
|
|
||||||
return a.name.localeCompare(b.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Render file list
|
|
||||||
let html = '';
|
|
||||||
for (const file of sorted) {
|
|
||||||
const icon = file.is_dir
|
|
||||||
? '<svg class="w-5 h-5 text-blue-500" fill="currentColor" viewBox="0 0 20 20"><path d="M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z"></path></svg>'
|
|
||||||
: '<svg class="w-5 h-5 text-gray-400" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z" clip-rule="evenodd"></path></svg>';
|
|
||||||
|
|
||||||
const sizeStr = file.is_dir ? '' : formatFileSize(file.size);
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="flex items-center gap-3 px-3 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 rounded">
|
|
||||||
${icon}
|
|
||||||
<div class="flex-1 min-w-0 ${file.is_dir ? 'cursor-pointer' : ''}" ${file.is_dir ? `onclick="loadFTPFiles('${unitId}', '${file.path}')"` : ''}>
|
|
||||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">${file.name}</div>
|
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${file.modified}${sizeStr ? ' • ' + sizeStr : ''}</div>
|
|
||||||
</div>
|
|
||||||
${file.is_dir ? `
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button onclick="event.stopPropagation(); downloadFolderToServer('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
|
||||||
title="Download entire folder to server and add to database">
|
|
||||||
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
|
|
||||||
</svg>
|
|
||||||
To Server (ZIP)
|
|
||||||
</button>
|
|
||||||
<button onclick="event.stopPropagation(); downloadFTPFolder('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
|
|
||||||
title="Download entire folder as ZIP to your computer">
|
|
||||||
<svg class="w-3 h-3 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>
|
|
||||||
To Browser (ZIP)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
` : `
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<button onclick="downloadToServer('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-green-600 text-white rounded hover:bg-green-700 transition-colors"
|
|
||||||
title="Download to server and add to database">
|
|
||||||
<svg class="w-3 h-3 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"></path>
|
|
||||||
</svg>
|
|
||||||
To Server
|
|
||||||
</button>
|
|
||||||
<button onclick="downloadFTPFile('${unitId}', '${file.path}', '${file.name}')"
|
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded hover:bg-seismo-navy transition-colors"
|
|
||||||
title="Download directly to your computer">
|
|
||||||
<svg class="w-3 h-3 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>
|
|
||||||
To Browser
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
`}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
fileListDiv.innerHTML = html;
|
|
||||||
} catch (error) {
|
|
||||||
fileListDiv.innerHTML = '<div class="text-center py-4 text-red-500">Error loading files: ' + error + '</div>';
|
|
||||||
console.error('Error loading FTP files:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFTPFile(unitId, remotePath, fileName) {
|
|
||||||
const btn = event.target;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.textContent = 'Downloading...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
remote_path: remotePath
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = fileName;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
alert('Download failed: ' + (errorData.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error downloading file: ' + error);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.textContent = 'Download';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFTPFolder(unitId, remotePath, folderName) {
|
|
||||||
const btn = event.target;
|
|
||||||
const originalHTML = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/slmm/${unitId}/ftp/download-folder`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
remote_path: remotePath
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
const blob = await response.blob();
|
|
||||||
const url = window.URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
a.download = `${folderName}.zip`;
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
window.URL.revokeObjectURL(url);
|
|
||||||
document.body.removeChild(a);
|
|
||||||
|
|
||||||
// Show success message
|
|
||||||
alert(`✓ Folder "${folderName}" downloaded successfully as ZIP file!`);
|
|
||||||
} else {
|
|
||||||
const errorData = await response.json();
|
|
||||||
alert('Folder download failed: ' + (errorData.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error downloading folder: ' + error);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = originalHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadFolderToServer(unitId, remotePath, folderName) {
|
|
||||||
const btn = event.target;
|
|
||||||
const originalHTML = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>Downloading...';
|
|
||||||
|
|
||||||
// Get location_id from the unit's data attribute
|
|
||||||
const unitContainer = btn.closest('[id^="ftp-files-"]');
|
|
||||||
const locationId = unitContainer.dataset.locationId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-folder-to-server`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
unit_id: unitId,
|
|
||||||
remote_path: remotePath,
|
|
||||||
location_id: locationId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Show success message
|
|
||||||
alert(`✓ Folder "${folderName}" downloaded to server successfully as ZIP!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
|
|
||||||
|
|
||||||
// Refresh the downloaded files list
|
|
||||||
htmx.trigger('#project-files', 'refresh');
|
|
||||||
} else {
|
|
||||||
alert('Folder download to server failed: ' + (data.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error downloading folder to server: ' + error);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = originalHTML;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function downloadToServer(unitId, remotePath, fileName) {
|
|
||||||
const btn = event.target;
|
|
||||||
const originalText = btn.innerHTML;
|
|
||||||
btn.disabled = true;
|
|
||||||
btn.innerHTML = '<svg class="w-3 h-3 inline mr-1 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path></svg>Downloading...';
|
|
||||||
|
|
||||||
// Get location_id from the unit's data attribute
|
|
||||||
const unitContainer = btn.closest('[id^="ftp-files-"]');
|
|
||||||
const locationId = unitContainer.dataset.locationId;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/{{ project_id }}/ftp-download-to-server`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
unit_id: unitId,
|
|
||||||
remote_path: remotePath,
|
|
||||||
location_id: locationId
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (response.ok) {
|
|
||||||
// Show success message
|
|
||||||
alert(`✓ ${fileName} downloaded to server successfully!\n\nFile ID: ${data.file_id}\nSize: ${formatFileSize(data.file_size)}`);
|
|
||||||
|
|
||||||
// Refresh the downloaded files list
|
|
||||||
htmx.trigger('#project-files', 'refresh');
|
|
||||||
} else {
|
|
||||||
alert('Download to server failed: ' + (data.detail || 'Unknown error'));
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert('Error downloading to server: ' + error);
|
|
||||||
} finally {
|
|
||||||
btn.disabled = false;
|
|
||||||
btn.innerHTML = originalText;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatFileSize(bytes) {
|
|
||||||
if (bytes < 1024) return bytes + ' B';
|
|
||||||
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
|
|
||||||
if (bytes < 1073741824) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
||||||
return (bytes / 1073741824).toFixed(2) + ' GB';
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -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>
|
|
||||||
@@ -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 }}?from_project={{ project_id }}" class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
|
||||||
{{ item.unit.id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Started:</span>
|
|
||||||
<span class="ml-1">{{ item.session.started_at.strftime('%Y-%m-%d %H:%M') if item.session.started_at else 'N/A' }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.session.stopped_at %}
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Ended:</span>
|
|
||||||
<span class="ml-1">{{ item.session.stopped_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.session.duration_seconds %}
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Duration:</span>
|
|
||||||
<span class="ml-1">{{ (item.session.duration_seconds // 3600) }}h {{ ((item.session.duration_seconds % 3600) // 60) }}m</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.session.notes %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
{{ item.session.notes }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
{% if item.session.status == 'recording' %}
|
|
||||||
<button onclick="stopRecording('{{ item.session.id }}')"
|
|
||||||
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
|
||||||
Stop
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
<button onclick="viewSession('{{ item.session.id }}')"
|
|
||||||
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
Details
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No recording sessions yet</p>
|
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Schedule a session to get started</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function viewSession(sessionId) {
|
|
||||||
// TODO: Implement session detail modal or page
|
|
||||||
alert('Session details coming soon: ' + sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopRecording(sessionId) {
|
|
||||||
if (!confirm('Stop this recording session?')) return;
|
|
||||||
|
|
||||||
// TODO: Implement stop recording API call
|
|
||||||
alert('Stop recording API coming soon for session: ' + sessionId);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -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 }}?from_project={{ project_id }}" class="hover:text-seismo-orange">
|
|
||||||
{{ item.unit.id }}
|
|
||||||
</a>
|
|
||||||
</h4>
|
|
||||||
{% if item.active_session %}
|
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 rounded-full flex items-center">
|
|
||||||
<span class="w-2 h-2 bg-red-500 rounded-full mr-1.5 animate-pulse"></span>
|
|
||||||
Recording
|
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">
|
|
||||||
Available
|
|
||||||
</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{% if item.location %}
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">
|
|
||||||
{% if project_type and project_type.id == 'sound_monitoring' %}
|
|
||||||
NRL:
|
|
||||||
{% else %}
|
|
||||||
Location:
|
|
||||||
{% endif %}
|
|
||||||
</span>
|
|
||||||
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
|
||||||
class="text-seismo-orange hover:text-seismo-navy font-medium ml-1">
|
|
||||||
{{ item.location.name }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if item.unit.slm_model %}
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Model:</span>
|
|
||||||
<span class="ml-1">{{ item.unit.slm_model }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Sessions:</span>
|
|
||||||
<span class="ml-1">{{ item.session_count }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<span class="text-xs text-gray-500">Files:</span>
|
|
||||||
<span class="ml-1">{{ item.file_count }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.assignment.assigned_at %}
|
|
||||||
<div class="col-span-2">
|
|
||||||
<span class="text-xs text-gray-500">Assigned:</span>
|
|
||||||
<span class="ml-1">{{ item.assignment.assigned_at.strftime('%Y-%m-%d %H:%M') }}</span>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if item.unit.note %}
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
{{ item.unit.note }}
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<a href="/slm/{{ item.unit.id }}?from_project={{ project_id }}"
|
|
||||||
class="px-3 py-1 text-xs bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
|
|
||||||
View Unit
|
|
||||||
</a>
|
|
||||||
{% if item.location %}
|
|
||||||
<a href="/projects/{{ project_id }}/nrl/{{ item.location.id }}"
|
|
||||||
class="px-3 py-1 text-xs bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors">
|
|
||||||
View NRL
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-12">
|
|
||||||
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
|
||||||
</svg>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400 mb-2">No units assigned yet</p>
|
|
||||||
<p class="text-sm text-gray-400 dark:text-gray-500">Assign units to locations to get started</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
<!-- SLM Device List -->
|
|
||||||
{% if units %}
|
|
||||||
{% for unit in units %}
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
|
|
||||||
<div class="absolute top-3 right-3 flex gap-2">
|
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
|
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
|
||||||
title="View live chart">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="event.preventDefault(); event.stopPropagation(); openDeviceConfigModal('{{ unit.id }}');"
|
|
||||||
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
|
|
||||||
title="Configure {{ unit.id }}">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<a href="/slm/{{ unit.id }}" class="block">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
|
|
||||||
{% if unit.slm_model %}
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
{% if unit.address %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
|
|
||||||
{% elif unit.location %}
|
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if unit.retired %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
|
|
||||||
{% elif not unit.deployed %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
|
|
||||||
{% elif unit.measurement_state == "Start" %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
|
|
||||||
{% elif unit.is_recent %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{% if unit.slm_last_check %}
|
|
||||||
Last check: {{ unit.slm_last_check.strftime('%Y-%m-%d %H:%M') }}
|
|
||||||
{% else %}
|
|
||||||
No recent check-in
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
{% else %}
|
|
||||||
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
|
||||||
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
|
||||||
</svg>
|
|
||||||
<p>No sound level meters found</p>
|
|
||||||
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
206
templates/partials/slm_diagnostics_card.html
Normal file
206
templates/partials/slm_diagnostics_card.html
Normal 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
@@ -1,857 +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">
|
|
||||||
<!-- FTP File Browser -->
|
|
||||||
<div id="ftp-browser"
|
|
||||||
hx-get="/api/projects/{{ project_id }}/ftp-browser"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6">
|
|
||||||
<div class="text-center py-8 text-gray-500">Loading FTP browser...</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Downloaded Files List -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Downloaded 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 %}
|
|
||||||
@@ -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 %}
|
|
||||||
@@ -288,7 +288,6 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<option value="seismograph">Seismograph</option>
|
<option value="seismograph">Seismograph</option>
|
||||||
<option value="modem">Modem</option>
|
<option value="modem">Modem</option>
|
||||||
<option value="sound_level_meter">Sound Level Meter</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -352,56 +351,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
|
||||||
<div id="editSlmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
|
||||||
<input type="text" name="slm_model" id="editSlmModel" placeholder="NL-43"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
|
||||||
<input type="text" name="slm_host" id="editSlmHost" placeholder="192.168.1.100"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
|
||||||
<input type="number" name="slm_tcp_port" id="editSlmTcpPort" placeholder="2255"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
|
||||||
<input type="number" name="slm_ftp_port" id="editSlmFtpPort" placeholder="21"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
|
||||||
<input type="text" name="slm_serial_number" id="editSlmSerialNumber" placeholder="SN123456"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
|
||||||
<select name="slm_frequency_weighting" id="editSlmFrequencyWeighting"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="">Not set</option>
|
|
||||||
<option value="A">A-weighting</option>
|
|
||||||
<option value="C">C-weighting</option>
|
|
||||||
<option value="Z">Z-weighting (Flat)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
|
||||||
<select name="slm_time_weighting" id="editSlmTimeWeighting"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="">Not set</option>
|
|
||||||
<option value="F">F (Fast)</option>
|
|
||||||
<option value="S">S (Slow)</option>
|
|
||||||
<option value="I">I (Impulse)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
||||||
@@ -423,12 +372,6 @@
|
|||||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick="openRenameUnitModal()" class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"></path>
|
|
||||||
</svg>
|
|
||||||
Rename
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="closeEditUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
<button type="button" onclick="closeEditUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@@ -437,59 +380,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Rename Unit Modal -->
|
|
||||||
<div id="renameUnitModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-md w-full mx-4">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div class="flex justify-between items-center">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Rename Unit</h2>
|
|
||||||
<button onclick="closeRenameUnitModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form id="renameUnitForm" class="p-6 space-y-4">
|
|
||||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
|
|
||||||
<div class="flex items-start gap-3">
|
|
||||||
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-yellow-800 dark:text-yellow-200">Important: Renaming Changes All References</p>
|
|
||||||
<p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
|
|
||||||
This will update the unit ID everywhere including history, assignments, and sessions.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Current Unit ID</label>
|
|
||||||
<input type="text" id="renameOldId" readonly
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed font-mono">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">New Unit ID *</label>
|
|
||||||
<input type="text" id="renameNewId" required pattern="[^\s]+" title="Unit ID cannot contain spaces"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 font-mono"
|
|
||||||
placeholder="Enter new unit ID (no spaces)">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors font-medium">
|
|
||||||
Rename Unit
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="closeRenameUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Import CSV Modal -->
|
<!-- Import CSV Modal -->
|
||||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
||||||
@@ -699,30 +589,20 @@
|
|||||||
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||||
const seismoFields = document.getElementById('editSeismographFields');
|
const seismoFields = document.getElementById('editSeismographFields');
|
||||||
const modemFields = document.getElementById('editModemFields');
|
const modemFields = document.getElementById('editModemFields');
|
||||||
const slmFields = document.getElementById('editSlmFields');
|
|
||||||
|
|
||||||
if (deviceType === 'seismograph') {
|
if (deviceType === 'seismograph') {
|
||||||
seismoFields.classList.remove('hidden');
|
seismoFields.classList.remove('hidden');
|
||||||
modemFields.classList.add('hidden');
|
modemFields.classList.add('hidden');
|
||||||
slmFields.classList.add('hidden');
|
// Enable seismograph fields, disable modem fields
|
||||||
setFieldsDisabled(seismoFields, false);
|
setFieldsDisabled(seismoFields, false);
|
||||||
setFieldsDisabled(modemFields, true);
|
setFieldsDisabled(modemFields, true);
|
||||||
setFieldsDisabled(slmFields, true);
|
|
||||||
toggleEditModemPairing();
|
toggleEditModemPairing();
|
||||||
} else if (deviceType === 'modem') {
|
} else {
|
||||||
seismoFields.classList.add('hidden');
|
seismoFields.classList.add('hidden');
|
||||||
modemFields.classList.remove('hidden');
|
modemFields.classList.remove('hidden');
|
||||||
slmFields.classList.add('hidden');
|
// Enable modem fields, disable seismograph fields
|
||||||
setFieldsDisabled(seismoFields, true);
|
setFieldsDisabled(seismoFields, true);
|
||||||
setFieldsDisabled(modemFields, false);
|
setFieldsDisabled(modemFields, false);
|
||||||
setFieldsDisabled(slmFields, true);
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
seismoFields.classList.add('hidden');
|
|
||||||
modemFields.classList.add('hidden');
|
|
||||||
slmFields.classList.remove('hidden');
|
|
||||||
setFieldsDisabled(seismoFields, true);
|
|
||||||
setFieldsDisabled(modemFields, true);
|
|
||||||
setFieldsDisabled(slmFields, false);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -772,15 +652,6 @@
|
|||||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
|
|
||||||
// SLM fields
|
|
||||||
document.getElementById('editSlmModel').value = unit.slm_model || '';
|
|
||||||
document.getElementById('editSlmHost').value = unit.slm_host || '';
|
|
||||||
document.getElementById('editSlmTcpPort').value = unit.slm_tcp_port || '';
|
|
||||||
document.getElementById('editSlmFtpPort').value = unit.slm_ftp_port || '';
|
|
||||||
document.getElementById('editSlmSerialNumber').value = unit.slm_serial_number || '';
|
|
||||||
document.getElementById('editSlmFrequencyWeighting').value = unit.slm_frequency_weighting || '';
|
|
||||||
document.getElementById('editSlmTimeWeighting').value = unit.slm_time_weighting || '';
|
|
||||||
|
|
||||||
// Store unit ID for form submission
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
@@ -1206,88 +1077,6 @@
|
|||||||
function filterRosterTable() {
|
function filterRosterTable() {
|
||||||
filterDevices();
|
filterDevices();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rename Unit Modal Functions
|
|
||||||
function openRenameUnitModal() {
|
|
||||||
const currentUnitId = document.getElementById('editUnitId').value;
|
|
||||||
document.getElementById('renameOldId').value = currentUnitId;
|
|
||||||
document.getElementById('renameNewId').value = '';
|
|
||||||
document.getElementById('renameUnitModal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeRenameUnitModal() {
|
|
||||||
document.getElementById('renameUnitModal').classList.add('hidden');
|
|
||||||
document.getElementById('renameUnitForm').reset();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle Rename Unit form submission
|
|
||||||
document.getElementById('renameUnitForm').addEventListener('submit', async function(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const oldId = document.getElementById('renameOldId').value;
|
|
||||||
const newId = document.getElementById('renameNewId').value.trim();
|
|
||||||
|
|
||||||
if (!newId) {
|
|
||||||
alert('Please enter a new unit ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldId === newId) {
|
|
||||||
alert('New unit ID must be different from the current ID');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Final confirmation
|
|
||||||
const confirmed = confirm(
|
|
||||||
`Are you sure you want to rename '${oldId}' to '${newId}'?\n\n` +
|
|
||||||
`This will update:\n` +
|
|
||||||
`• Unit roster entry\n` +
|
|
||||||
`• All history records\n` +
|
|
||||||
`• Project assignments\n` +
|
|
||||||
`• Recording sessions\n` +
|
|
||||||
`• Modem references\n\n` +
|
|
||||||
`This action cannot be undone.`
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!confirmed) return;
|
|
||||||
|
|
||||||
const formData = new FormData();
|
|
||||||
formData.append('old_id', oldId);
|
|
||||||
formData.append('new_id', newId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/roster/rename', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
|
|
||||||
if (response.ok && result.success) {
|
|
||||||
alert(`✓ Successfully renamed unit from '${oldId}' to '${newId}'`);
|
|
||||||
closeRenameUnitModal();
|
|
||||||
closeEditUnitModal();
|
|
||||||
// Reload the page to show updated unit ID
|
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
alert(`Error: ${result.detail || result.message || 'Failed to rename unit'}`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
alert(`Error renaming unit: ${error.message}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-open edit modal if ?edit=UNIT_ID query parameter is present
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const urlParams = new URLSearchParams(window.location.search);
|
|
||||||
const editUnitId = urlParams.get('edit');
|
|
||||||
if (editUnitId) {
|
|
||||||
// Wait a bit for the page to fully load, then open the edit modal
|
|
||||||
setTimeout(() => {
|
|
||||||
editUnit(editUnitId);
|
|
||||||
}, 500);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -859,6 +859,11 @@ async function loadRosterTable() {
|
|||||||
|
|
||||||
function createRosterRow(unit) {
|
function createRosterRow(unit) {
|
||||||
const statusBadges = [];
|
const statusBadges = [];
|
||||||
|
if (unit.deployed) {
|
||||||
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300">Deployed</span>');
|
||||||
|
} else {
|
||||||
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Benched</span>');
|
||||||
|
}
|
||||||
if (unit.retired) {
|
if (unit.retired) {
|
||||||
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
||||||
}
|
}
|
||||||
@@ -875,24 +880,8 @@ function createRosterRow(unit) {
|
|||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-wrap gap-1">
|
||||||
<div class="flex gap-3">
|
${statusBadges.join('')}
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="deployed-${unit.id}" value="true"
|
|
||||||
${unit.deployed ? 'checked' : ''}
|
|
||||||
onchange="toggleDeployed('${unit.id}', true)"
|
|
||||||
class="w-4 h-4 text-green-600 focus:ring-green-500">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="radio" name="deployed-${unit.id}" value="false"
|
|
||||||
${!unit.deployed ? 'checked' : ''}
|
|
||||||
onchange="toggleDeployed('${unit.id}', false)"
|
|
||||||
class="w-4 h-4 text-gray-600 focus:ring-gray-500">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Benched</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
${statusBadges.length > 0 ? '<div class="flex flex-wrap gap-1">' + statusBadges.join('') + '</div>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -907,6 +896,13 @@ function createRosterRow(unit) {
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
<div class="flex justify-end gap-1">
|
<div class="flex justify-end gap-1">
|
||||||
|
<button onclick="toggleDeployed('${unit.id}', ${unit.deployed})"
|
||||||
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
|
title="${unit.deployed ? 'Bench Unit' : 'Deploy Unit'}">
|
||||||
|
<svg class="w-4 h-4 ${unit.deployed ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
||||||
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
||||||
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
||||||
@@ -934,12 +930,12 @@ function createRosterRow(unit) {
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleDeployed(unitId, newState) {
|
async function toggleDeployed(unitId, currentState) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
body: `deployed=${newState}`
|
body: `deployed=${!currentState}`
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
@@ -973,7 +969,7 @@ async function toggleRetired(unitId, currentState) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function editUnit(unitId) {
|
function editUnit(unitId) {
|
||||||
window.location.href = `/roster?edit=${unitId}`;
|
window.location.href = `/unit/${unitId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirmDeleteUnit(unitId) {
|
async function confirmDeleteUnit(unitId) {
|
||||||
|
|||||||
@@ -4,75 +4,12 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
{% if from_project and project %}
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||||
<nav class="flex items-center space-x-2 text-sm">
|
|
||||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<a href="/projects/{{ from_project }}" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
</svg>
|
</svg>
|
||||||
{{ project.name }}
|
Back to Roster
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
|
||||||
{% else %}
|
|
||||||
<a href="#" onclick="goBack(event)" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
<span id="back-link-text">Back to Sound Level Meters</span>
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function goBack(event) {
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
// Check if there's a previous page in history
|
|
||||||
// and it's from the same site (not external)
|
|
||||||
if (window.history.length > 1 && document.referrer) {
|
|
||||||
const referrer = new URL(document.referrer);
|
|
||||||
const current = new URL(window.location.href);
|
|
||||||
|
|
||||||
// If referrer is from the same origin, go back
|
|
||||||
if (referrer.origin === current.origin) {
|
|
||||||
window.history.back();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, go to SLM dashboard
|
|
||||||
window.location.href = '/sound-level-meters';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the back link text based on referrer
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
|
||||||
const backText = document.getElementById('back-link-text');
|
|
||||||
if (backText && document.referrer) {
|
|
||||||
try {
|
|
||||||
const referrer = new URL(document.referrer);
|
|
||||||
const current = new URL(window.location.href);
|
|
||||||
|
|
||||||
// Only update if from same origin
|
|
||||||
if (referrer.origin === current.origin) {
|
|
||||||
if (referrer.pathname.includes('/sound-level-meters')) {
|
|
||||||
backText.textContent = 'Back to Sound Level Meters';
|
|
||||||
} else if (referrer.pathname.includes('/roster')) {
|
|
||||||
backText.textContent = 'Back to Roster';
|
|
||||||
} else if (referrer.pathname.includes('/projects')) {
|
|
||||||
backText.textContent = 'Back to Projects';
|
|
||||||
} else if (referrer.pathname === '/') {
|
|
||||||
backText.textContent = 'Back to Dashboard';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Invalid referrer, keep default text
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
@@ -100,16 +37,159 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Command Center -->
|
<!-- Control Panel -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="mb-8">
|
||||||
<div id="slm-command-center"
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
hx-trigger="load"
|
hx-trigger="load, every 5s"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<div class="text-center py-8 text-gray-500">
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
</div>
|
||||||
<p>Loading command center...</p>
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-time Data Stream -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div id="slm-stream-container">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<button onclick="startStream()"
|
||||||
|
id="stream-start-btn"
|
||||||
|
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||||
|
Start Real-time Stream
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||||
|
</div>
|
||||||
|
<div id="stream-data" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||||
|
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||||
|
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||||
|
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||||
|
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Streaming
|
||||||
|
</div>
|
||||||
|
<button onclick="stopStream()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop Stream
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||||
|
document.getElementById('stream-data').classList.remove('hidden');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Stream error:', data.error);
|
||||||
|
stopStream();
|
||||||
|
alert('Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('stream-data').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,176 +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">
|
|
||||||
{% if from_nrl and nrl_location and from_project and project %}
|
|
||||||
<!-- From NRL Location: Projects > Project > NRL > Unit -->
|
|
||||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<a href="/projects/{{ from_project }}" class="text-gray-500 hover:text-seismo-orange">
|
|
||||||
{{ project.name }}
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<a href="/projects/{{ from_project }}/nrl/{{ from_nrl }}" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
{{ nrl_location.name }}
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
|
||||||
{% elif from_project and project %}
|
|
||||||
<!-- From Project: Projects > Project > Unit -->
|
|
||||||
<a href="/projects" class="text-gray-500 hover:text-seismo-orange">Projects</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<a href="/projects/{{ from_project }}" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
{{ project.name }}
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
|
||||||
{% else %}
|
|
||||||
<!-- Default: Sound Level Meters > Unit -->
|
|
||||||
<a href="/sound-level-meters" class="text-seismo-orange hover:text-seismo-navy flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
Sound Level Meters
|
|
||||||
</a>
|
|
||||||
<svg class="w-4 h-4 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-gray-900 dark:text-white font-medium">{{ unit_id }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="flex justify-between items-start">
|
|
||||||
<div>
|
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
|
||||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
|
||||||
</svg>
|
|
||||||
{{ unit_id }}
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
|
||||||
Sound Level Meter {% if from_project or from_nrl %}Operations{% else %}Control Center{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
{% if not from_project and not from_nrl %}
|
|
||||||
<!-- Configure button only shown in administrative context (accessed from roster/SLM dashboard) -->
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<button onclick="openConfigModal()"
|
|
||||||
class="px-4 py-2 bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-200 dark:hover:bg-gray-600 transition-colors flex items-center">
|
|
||||||
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
|
||||||
</svg>
|
|
||||||
Configure
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live View Panel -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg">
|
|
||||||
<div id="live-view-content"
|
|
||||||
hx-get="/api/slm-dashboard/live-view/{{ unit_id }}"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading State -->
|
|
||||||
<div class="p-12 text-center">
|
|
||||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-seismo-orange mx-auto mb-4"></div>
|
|
||||||
<p class="text-gray-500 dark:text-gray-400">Loading control center...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Configuration Modal -->
|
|
||||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
|
|
||||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Configure {{ unit_id }}</h2>
|
|
||||||
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="config-modal-content"
|
|
||||||
hx-get="/api/slm-dashboard/config/{{ unit_id }}"
|
|
||||||
hx-trigger="load"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<!-- Loading skeleton -->
|
|
||||||
<div class="p-6 space-y-4 animate-pulse">
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
|
||||||
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
// Modal functions
|
|
||||||
function openConfigModal() {
|
|
||||||
const modal = document.getElementById('config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
// Reload config when opening
|
|
||||||
htmx.ajax('GET', '/api/slm-dashboard/config/{{ unit_id }}', {
|
|
||||||
target: '#config-modal-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeConfigModal() {
|
|
||||||
document.getElementById('config-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keyboard shortcut
|
|
||||||
document.addEventListener('keydown', function(e) {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Click outside to close
|
|
||||||
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
|
||||||
if (e.target === this) {
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Listen for config updates to refresh live view
|
|
||||||
document.body.addEventListener('htmx:afterRequest', function(event) {
|
|
||||||
if (event.detail.pathInfo.requestPath.includes('/config/') && event.detail.successful) {
|
|
||||||
// Refresh live view after config update
|
|
||||||
htmx.ajax('GET', '/api/slm-dashboard/live-view/{{ unit_id }}', {
|
|
||||||
target: '#live-view-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
});
|
|
||||||
closeConfigModal();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
{% endblock %}
|
|
||||||
@@ -4,13 +4,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
||||||
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
|
||||||
</svg>
|
|
||||||
Sound Level Meters
|
|
||||||
</h1>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and control sound level measurement devices</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Summary Stats -->
|
<!-- Summary Stats -->
|
||||||
@@ -25,131 +20,84 @@
|
|||||||
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
<div class="animate-pulse bg-gray-200 dark:bg-gray-700 h-24 rounded-xl"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Device List with Quick Actions -->
|
<!-- Main Content Grid -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<!-- SLM List -->
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Active Devices</h2>
|
<div class="lg:col-span-1">
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Auto-refresh: 15s</span>
|
|
||||||
<a href="/roster" class="text-sm text-seismo-orange hover:text-seismo-navy">Manage roster</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="slm-devices-list"
|
|
||||||
class="space-y-3"
|
|
||||||
hx-get="/api/slm-dashboard/units?include_measurement=true"
|
|
||||||
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-32 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
|
||||||
<div class="bg-gray-200 dark:bg-gray-700 h-32 rounded-lg"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Live Measurement Chart - shows when a device is selected -->
|
|
||||||
<div id="live-chart-panel" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
|
|
||||||
<div class="flex items-center justify-between mb-6">
|
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Live Measurements</h2>
|
|
||||||
<button onclick="closeLiveChart()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Current Metrics -->
|
|
||||||
<div class="grid grid-cols-5 gap-4 mb-6">
|
|
||||||
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
|
||||||
<p id="chart-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
|
||||||
<p id="chart-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">--</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
|
||||||
<p id="chart-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">--</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
|
||||||
<p id="chart-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">--</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
|
||||||
<p id="chart-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">--</p>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Chart -->
|
|
||||||
<div class="bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
|
||||||
<canvas id="dashboardLiveChart"></canvas>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stream Control -->
|
|
||||||
<div class="mt-4 flex justify-center gap-3">
|
|
||||||
<button id="start-chart-stream" onclick="startDashboardStream()"
|
|
||||||
class="px-6 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
|
||||||
</svg>
|
|
||||||
Start Live Stream
|
|
||||||
</button>
|
|
||||||
<button id="stop-chart-stream" onclick="stopDashboardStream()" style="display: none;"
|
|
||||||
class="px-6 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
|
||||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
|
||||||
</svg>
|
|
||||||
Stop Live Stream
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Projects Overview -->
|
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="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">Active 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-[400px] 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>
|
||||||
|
|
||||||
|
<!-- Live View Panel -->
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<div id="live-view-panel" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Initial state - no unit selected -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-[600px] text-gray-400 dark:text-gray-500">
|
||||||
|
<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>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium">No unit selected</p>
|
||||||
|
<p class="text-sm mt-2">Select a sound level meter from the list to view live data</p>
|
||||||
|
</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>
|
||||||
@@ -159,242 +107,209 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
<!-- Command Center Modal -->
|
||||||
|
<div id="command-center-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50">
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<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>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Global variables
|
// Global Connection Manager - ensures only one SLM connection at a time
|
||||||
window.dashboardChart = null;
|
window.SLMConnectionManager = {
|
||||||
window.dashboardWebSocket = null;
|
activeIntervals: [],
|
||||||
window.selectedUnitId = null;
|
activeWebSocket: null,
|
||||||
window.dashboardChartData = {
|
currentUnitId: null,
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Initialize Chart.js
|
// Clear all existing connections
|
||||||
function initializeDashboardChart() {
|
clearAll: function() {
|
||||||
if (typeof Chart === 'undefined') {
|
console.log('SLMConnectionManager: Clearing all connections');
|
||||||
setTimeout(initializeDashboardChart, 100);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const canvas = document.getElementById('dashboardLiveChart');
|
// Clear all intervals
|
||||||
if (!canvas) return;
|
this.activeIntervals.forEach(interval => {
|
||||||
|
clearInterval(interval);
|
||||||
if (window.dashboardChart) {
|
|
||||||
window.dashboardChart.destroy();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = canvas.getContext('2d');
|
|
||||||
const isDarkMode = document.documentElement.classList.contains('dark');
|
|
||||||
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
|
||||||
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
|
||||||
|
|
||||||
window.dashboardChart = new Chart(ctx, {
|
|
||||||
type: 'line',
|
|
||||||
data: {
|
|
||||||
labels: [],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: 'Lp (Instantaneous)',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Leq (Equivalent)',
|
|
||||||
data: [],
|
|
||||||
borderColor: 'rgb(34, 197, 94)',
|
|
||||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
|
||||||
tension: 0.3,
|
|
||||||
borderWidth: 2,
|
|
||||||
pointRadius: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
options: {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: false,
|
|
||||||
animation: false,
|
|
||||||
interaction: {
|
|
||||||
intersect: false,
|
|
||||||
mode: 'index'
|
|
||||||
},
|
|
||||||
scales: {
|
|
||||||
x: {
|
|
||||||
display: true,
|
|
||||||
grid: { color: gridColor },
|
|
||||||
ticks: { color: textColor, maxTicksLimit: 10 }
|
|
||||||
},
|
|
||||||
y: {
|
|
||||||
display: true,
|
|
||||||
title: {
|
|
||||||
display: true,
|
|
||||||
text: 'Sound Level (dB)',
|
|
||||||
color: textColor
|
|
||||||
},
|
|
||||||
grid: { color: gridColor },
|
|
||||||
ticks: { color: textColor },
|
|
||||||
min: 30,
|
|
||||||
max: 130
|
|
||||||
}
|
|
||||||
},
|
|
||||||
plugins: {
|
|
||||||
legend: {
|
|
||||||
labels: { color: textColor }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
this.activeIntervals = [];
|
||||||
|
|
||||||
|
// Close WebSocket if exists
|
||||||
|
if (this.activeWebSocket) {
|
||||||
|
this.activeWebSocket.close();
|
||||||
|
this.activeWebSocket = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show live chart for a specific unit
|
// Clear any global intervals that might exist
|
||||||
function showLiveChart(unitId) {
|
if (window.refreshInterval) {
|
||||||
window.selectedUnitId = unitId;
|
clearInterval(window.refreshInterval);
|
||||||
const panel = document.getElementById('live-chart-panel');
|
window.refreshInterval = null;
|
||||||
panel.classList.remove('hidden');
|
}
|
||||||
|
if (window.timerInterval) {
|
||||||
// Initialize chart if needed
|
clearInterval(window.timerInterval);
|
||||||
if (!window.dashboardChart) {
|
window.timerInterval = null;
|
||||||
initializeDashboardChart();
|
}
|
||||||
|
if (window.diagRefreshInterval) {
|
||||||
|
clearInterval(window.diagRefreshInterval);
|
||||||
|
window.diagRefreshInterval = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset data
|
console.log('SLMConnectionManager: All connections cleared');
|
||||||
window.dashboardChartData = {
|
},
|
||||||
timestamps: [],
|
|
||||||
lp: [],
|
|
||||||
leq: []
|
|
||||||
};
|
|
||||||
|
|
||||||
// Scroll to chart
|
// Register a new interval
|
||||||
panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
registerInterval: function(intervalId) {
|
||||||
|
this.activeIntervals.push(intervalId);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Register WebSocket
|
||||||
|
registerWebSocket: function(ws) {
|
||||||
|
if (this.activeWebSocket) {
|
||||||
|
this.activeWebSocket.close();
|
||||||
}
|
}
|
||||||
|
this.activeWebSocket = ws;
|
||||||
|
},
|
||||||
|
|
||||||
function closeLiveChart() {
|
// Set current unit
|
||||||
stopDashboardStream();
|
setCurrentUnit: function(unitId) {
|
||||||
document.getElementById('live-chart-panel').classList.add('hidden');
|
if (this.currentUnitId !== unitId) {
|
||||||
window.selectedUnitId = null;
|
this.clearAll();
|
||||||
|
this.currentUnitId = unitId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebSocket streaming
|
|
||||||
function startDashboardStream() {
|
|
||||||
if (!window.selectedUnitId) return;
|
|
||||||
|
|
||||||
// Close existing connection
|
|
||||||
if (window.dashboardWebSocket) {
|
|
||||||
window.dashboardWebSocket.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset chart data
|
|
||||||
window.dashboardChartData = { timestamps: [], lp: [], leq: [] };
|
|
||||||
if (window.dashboardChart) {
|
|
||||||
window.dashboardChart.data.labels = [];
|
|
||||||
window.dashboardChart.data.datasets[0].data = [];
|
|
||||||
window.dashboardChart.data.datasets[1].data = [];
|
|
||||||
window.dashboardChart.update();
|
|
||||||
}
|
|
||||||
|
|
||||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${window.selectedUnitId}/live`;
|
|
||||||
|
|
||||||
window.dashboardWebSocket = new WebSocket(wsUrl);
|
|
||||||
|
|
||||||
window.dashboardWebSocket.onopen = function() {
|
|
||||||
console.log('Dashboard WebSocket connected');
|
|
||||||
document.getElementById('start-chart-stream').style.display = 'none';
|
|
||||||
document.getElementById('stop-chart-stream').style.display = 'flex';
|
|
||||||
};
|
|
||||||
|
|
||||||
window.dashboardWebSocket.onmessage = function(event) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(event.data);
|
|
||||||
updateDashboardMetrics(data);
|
|
||||||
updateDashboardChart(data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error parsing WebSocket message:', error);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.dashboardWebSocket.onerror = function(error) {
|
// Function to select a unit and load DIAGNOSTICS CARD (not full command center)
|
||||||
console.error('Dashboard WebSocket error:', error);
|
function selectUnit(unitId) {
|
||||||
};
|
console.log(`Selecting unit: ${unitId}`);
|
||||||
|
|
||||||
window.dashboardWebSocket.onclose = function() {
|
// Clear all existing connections
|
||||||
console.log('Dashboard WebSocket closed');
|
window.SLMConnectionManager.clearAll();
|
||||||
document.getElementById('start-chart-stream').style.display = 'flex';
|
|
||||||
document.getElementById('stop-chart-stream').style.display = 'none';
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopDashboardStream() {
|
// Remove active state from all items
|
||||||
if (window.dashboardWebSocket) {
|
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||||
window.dashboardWebSocket.close();
|
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
window.dashboardWebSocket = null;
|
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
||||||
}
|
});
|
||||||
}
|
|
||||||
|
|
||||||
function updateDashboardMetrics(data) {
|
// Add active state to clicked item
|
||||||
document.getElementById('chart-lp').textContent = data.lp || '--';
|
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||||
document.getElementById('chart-leq').textContent = data.leq || '--';
|
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||||
document.getElementById('chart-lmax').textContent = data.lmax || '--';
|
|
||||||
document.getElementById('chart-lmin').textContent = data.lmin || '--';
|
|
||||||
document.getElementById('chart-lpeak').textContent = data.lpeak || '--';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateDashboardChart(data) {
|
// Load DIAGNOSTICS CARD (not full live view)
|
||||||
const now = new Date();
|
htmx.ajax('GET', `/api/slm-dashboard/diagnostics/${unitId}`, {
|
||||||
window.dashboardChartData.timestamps.push(now.toLocaleTimeString());
|
target: '#live-view-panel',
|
||||||
window.dashboardChartData.lp.push(parseFloat(data.lp || 0));
|
|
||||||
window.dashboardChartData.leq.push(parseFloat(data.leq || 0));
|
|
||||||
|
|
||||||
// Keep only last 60 data points
|
|
||||||
if (window.dashboardChartData.timestamps.length > 60) {
|
|
||||||
window.dashboardChartData.timestamps.shift();
|
|
||||||
window.dashboardChartData.lp.shift();
|
|
||||||
window.dashboardChartData.leq.shift();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.dashboardChart) {
|
|
||||||
window.dashboardChart.data.labels = window.dashboardChartData.timestamps;
|
|
||||||
window.dashboardChart.data.datasets[0].data = window.dashboardChartData.lp;
|
|
||||||
window.dashboardChart.data.datasets[1].data = window.dashboardChartData.leq;
|
|
||||||
window.dashboardChart.update('none');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Configuration modal
|
|
||||||
function openDeviceConfigModal(unitId) {
|
|
||||||
const modal = document.getElementById('slm-config-modal');
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
|
||||||
target: '#slm-config-modal-content',
|
|
||||||
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
|
// Cleanup on page unload
|
||||||
window.addEventListener('beforeunload', function() {
|
window.addEventListener('beforeunload', function() {
|
||||||
stopDashboardStream();
|
window.SLMConnectionManager.clearAll();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button id="editButton" onclick="window.location.href='/roster?edit=' + unitId" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
Reference in New Issue
Block a user