Compare commits
7 Commits
dev
...
1.0-experi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9d51151a | ||
|
|
7715123053 | ||
|
|
94354da611 | ||
|
|
5b907c0cd7 | ||
|
|
ff438c1197 | ||
|
|
16eb9eb1fe | ||
|
|
991aaca34b |
2
.gitignore
vendored
@@ -211,5 +211,3 @@ __marimo__/
|
|||||||
*.db
|
*.db
|
||||||
*.db-journal
|
*.db-journal
|
||||||
data/
|
data/
|
||||||
.aider*
|
|
||||||
.aider*
|
|
||||||
|
|||||||
126
CHANGELOG.md
@@ -5,56 +5,94 @@ All notable changes to Terra-View will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## [0.5.1] - 2026-01-27
|
## [0.5.0] - 2026-01-09
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
- **Dashboard Schedule View**: Today's scheduled actions now display directly on the main dashboard
|
- **Unified Modular Monolith Architecture**: Complete architectural refactoring to modular monolith pattern
|
||||||
- New "Today's Actions" panel showing upcoming and past scheduled events
|
- **Three Feature Modules**: Seismo (seismograph fleet), SLM (sound level meters), UI (shared templates/static)
|
||||||
- Schedule list partial for project-specific schedule views
|
- **Module Isolation**: Each module has its own database, models, services, and routers
|
||||||
- API endpoint for fetching today's schedule data
|
- **Shared Infrastructure**: Common utilities and API aggregation layer
|
||||||
- **New Branding Assets**: Complete logo rework for Terra-View
|
- **Multi-Container Deployment**: Three Docker containers (terra-view, sfm, slmm) built from single codebase
|
||||||
- New Terra-View logos for light and dark themes
|
- **SLMM Integration**: Sound Level Meter Manager fully integrated as `app/slm/` module
|
||||||
- Retina-ready (@2x) logo variants
|
- Migrated from separate repository to unified codebase
|
||||||
- Updated favicons (16px and 32px)
|
- Complete NL43 device management API (`/api/nl43/*`)
|
||||||
- Refreshed PWA icons (72px through 512px)
|
- Database models for NL43Config and NL43Status
|
||||||
|
- NL43Client service for device communication
|
||||||
|
- FTP, TCP, and web interface support for NL43 devices
|
||||||
|
- **SLM Dashboard API Layer**: New dashboard endpoints bridge UI and device APIs
|
||||||
|
- `GET /api/slm-dashboard/stats` - Aggregate statistics (total units, online/offline, measuring/idle)
|
||||||
|
- `GET /api/slm-dashboard/units` - List all units with latest status
|
||||||
|
- `GET /api/slm-dashboard/live-view/{unit_id}` - Real-time measurement data
|
||||||
|
- `GET /api/slm-dashboard/config/{unit_id}` - Retrieve unit configuration
|
||||||
|
- `POST /api/slm-dashboard/config/{unit_id}` - Update unit configuration
|
||||||
|
- `POST /api/slm-dashboard/control/{unit_id}/{action}` - Send control commands (start, stop, pause, resume, reset, sleep, wake)
|
||||||
|
- `GET /api/slm-dashboard/test-modem/{unit_id}` - Test device connectivity
|
||||||
|
- **Repository Rebranding**: Renamed from `seismo-fleet-manager` to `terra-view`
|
||||||
|
- Reflects unified platform nature (seismo + SLM + future modules)
|
||||||
|
- Git remote updated to `terra-view.git`
|
||||||
|
- All references updated throughout codebase
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- **Dashboard Layout**: Reorganized to include schedule information panel
|
- **Project Structure**: Complete reorganization following modular monolith pattern
|
||||||
- **Base Template**: Updated to use new Terra-View logos with theme-aware switching
|
- `app/seismo/` - Seismograph fleet module (formerly `backend/`)
|
||||||
|
- `app/slm/` - Sound level meter module (integrated from SLMM)
|
||||||
## [0.5.0] - 2026-01-23
|
- `app/ui/` - Shared templates and static assets
|
||||||
|
- `app/api/` - Cross-module API aggregation layer
|
||||||
_Note: This version was not formally released; changes were included in v0.5.1._
|
- Removed `backend/` and `templates/` directories
|
||||||
|
- **Import Paths**: All imports updated from `backend.*` to `app.seismo.*` or `app.slm.*`
|
||||||
## [0.4.4] - 2026-01-23
|
- **Database Initialization**: Each module initializes its own database tables
|
||||||
|
- Seismo database: `app/seismo/database.py`
|
||||||
### Added
|
- SLM database: `app/slm/database.py`
|
||||||
- **Recurring schedules**: New scheduler service, recurring schedule APIs, and schedule templates (calendar/interval/list).
|
- **Docker Architecture**: Three-container deployment from single codebase
|
||||||
- **Alerts UI + backend**: Alerting service plus dropdown/list templates for surfacing notifications.
|
- `terra-view` (port 8001): Main UI/orchestrator with all modules
|
||||||
- **Report templates + viewers**: CRUD API for report templates, report preview screen, and RND file viewer.
|
- `sfm` (port 8002): Seismograph Fleet Module API
|
||||||
- **SLM tooling**: SLM settings modal and SLM project report generator workflow.
|
- `slmm` (port 8100): Sound Level Meter Manager API
|
||||||
|
- All containers built from same unified codebase with different entry points
|
||||||
### Changed
|
|
||||||
- **Project data management**: Unified files view, refreshed FTP browser, and new project header/templates for file/session/unit/assignment lists.
|
|
||||||
- **Device/SLM sync**: Standardized SLM device types and tightened SLMM sync paths.
|
|
||||||
- **Docs/scripts**: Cleanup pass and expanded device-type documentation.
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- **Scheduler actions**: Strict command definitions so actions run reliably.
|
- **Template Path Issues**: Fixed seismo dashboard template references
|
||||||
- **Project view title**: Resolved JSON string rendering in project headers.
|
- Updated `app/seismo/routers/dashboard.py` to use `app/ui/templates` directory
|
||||||
|
- Resolved 404 errors for `partials/benched_table.html` and `partials/active_table.html`
|
||||||
|
- **Module Import Errors**: Corrected SLMM module structure
|
||||||
|
- Fixed `app/slm/main.py` to import from `app.slm.routers` instead of `app.routers`
|
||||||
|
- Updated all SLMM internal imports to use `app.slm.*` namespace
|
||||||
|
- **Docker Build Issues**: Resolved file permission problems
|
||||||
|
- Fixed dashboard.py permissions for Docker COPY operations
|
||||||
|
- Ensured all source files readable during container builds
|
||||||
|
|
||||||
## [0.4.3] - 2026-01-14
|
### Technical Details
|
||||||
|
- **Modular Monolith Benefits**:
|
||||||
|
- Single repository for easier development and deployment
|
||||||
|
- Module boundaries enforced through folder structure
|
||||||
|
- Shared dependencies managed in single requirements.txt
|
||||||
|
- Independent database schemas per module
|
||||||
|
- Clean separation of concerns with explicit module APIs
|
||||||
|
- **Migration Path**: Existing installations automatically migrate
|
||||||
|
- Import path updates applied programmatically
|
||||||
|
- Database schemas remain compatible
|
||||||
|
- No data migration required
|
||||||
|
- **Module Structure**: Each module follows consistent pattern
|
||||||
|
- `database.py` - SQLAlchemy models and session management
|
||||||
|
- `models.py` - Pydantic schemas and database models
|
||||||
|
- `routers.py` - FastAPI route definitions
|
||||||
|
- `services.py` - Business logic and external integrations
|
||||||
|
- **Container Communication**: Containers use host networking
|
||||||
|
- terra-view proxies to sfm and slmm containers
|
||||||
|
- Environment variables configure API URLs
|
||||||
|
- Health checks ensure container availability
|
||||||
|
|
||||||
### Added
|
### Migration Notes
|
||||||
- **Sound Level Meter roster tooling**: Roster manager surfaces SLM metadata, supports rename unit flows, and adds return-to-project navigation to keep SLM dashboard users oriented.
|
- **Breaking Changes**: Import paths changed for all modules
|
||||||
- **Project management templates**: New schedule and unit list templates plus file/session lists show what each project stores before teams dive into deployments.
|
- Old: `from backend.models import RosterUnit`
|
||||||
|
- New: `from app.seismo.models import RosterUnit`
|
||||||
### Changed
|
- **Configuration Updates**: Environment variables for multi-container setup
|
||||||
- **Project view refresh**: FTP browser now downloads folders locally, the countdown timer was rebuilt, and project/device templates gained edit modals for projects and locations so navigation feels smoother.
|
- `SFM_API_URL=http://localhost:8002` - SFM backend endpoint
|
||||||
- **SLM control sync & accuracy**: Control center groundwork now runs inside the dev UI, configuration edits propagate to SLMM (which caches configs for faster responses), and the SLM live view reads the correct DRD fields after the refactor.
|
- `SLMM_API_URL=http://localhost:8100` - SLMM backend endpoint
|
||||||
|
- `MODULE_MODE=sfm|slmm` - Future flag for API-only containers
|
||||||
### Fixed
|
- **Repository Migration**: Update git remotes for renamed repository
|
||||||
- **SLM UI syntax bug**: Resolved the unexpected token error that appeared in the refreshed SLM components.
|
```bash
|
||||||
|
git remote set-url origin ssh://git@10.0.0.2:2222/serversdown/terra-view.git
|
||||||
|
```
|
||||||
|
|
||||||
## [0.4.2] - 2026-01-05
|
## [0.4.2] - 2026-01-05
|
||||||
|
|
||||||
@@ -399,10 +437,6 @@ No database migration required for v0.4.0. All new features use existing databas
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
[0.5.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.5.0...v0.5.1
|
|
||||||
[0.5.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.4...v0.5.0
|
|
||||||
[0.4.4]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.3...v0.4.4
|
|
||||||
[0.4.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.2...v0.4.3
|
|
||||||
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||||
|
|||||||
26
Dockerfile.sfm
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose SFM port
|
||||||
|
EXPOSE 8002
|
||||||
|
|
||||||
|
# Run SFM backend (API only)
|
||||||
|
# For now: runs same app on different port
|
||||||
|
# Future: will run SFM-specific entry point
|
||||||
|
CMD ["python3", "-m", "app.main"]
|
||||||
21
Dockerfile.slm
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements and install dependencies
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY app /app/app
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8100
|
||||||
|
|
||||||
|
# Run the SLM application
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8100"]
|
||||||
24
Dockerfile.terraview
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose Terra-View UI port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run Terra-View (UI + orchestration)
|
||||||
|
CMD ["python3", "-m", "app.main"]
|
||||||
141
MIGRATION_BASELINE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# Terra-View Modular Monolith - Known-Good Baseline
|
||||||
|
|
||||||
|
**Date:** 2026-01-09
|
||||||
|
**Status:** ✅ IMPORT MIGRATION COMPLETE
|
||||||
|
|
||||||
|
## What We've Achieved
|
||||||
|
|
||||||
|
Successfully restructured the application into a modular monolith architecture with the new folder structure working end-to-end.
|
||||||
|
|
||||||
|
## New Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/home/serversdown/sfm/seismo-fleet-manager/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # NEW: Entry point with Terra-View branding
|
||||||
|
│ ├── core/ # Shared infrastructure
|
||||||
|
│ │ ├── config.py # NEW: Centralized configuration
|
||||||
|
│ │ └── database.py # Shared DB utilities
|
||||||
|
│ ├── ui/ # UI Layer (device-agnostic)
|
||||||
|
│ │ ├── routes.py # NEW: HTML page routes
|
||||||
|
│ │ ├── templates/ # All HTML templates (copied from old location)
|
||||||
|
│ │ └── static/ # All static files (copied from old location)
|
||||||
|
│ ├── seismo/ # Seismograph Feature Module
|
||||||
|
│ │ ├── models.py # ✅ Updated to use app.seismo.database
|
||||||
|
│ │ ├── database.py # NEW: Seismo-specific DB connection
|
||||||
|
│ │ ├── routers/ # API routers (copied from backend/routers/)
|
||||||
|
│ │ └── services/ # Business logic (copied from backend/services/)
|
||||||
|
│ ├── slm/ # Sound Level Meter Feature Module
|
||||||
|
│ │ ├── models.py # NEW: Placeholder for SLM models
|
||||||
|
│ │ ├── database.py # NEW: SLM-specific DB connection
|
||||||
|
│ │ └── routers/ # SLM routers (copied from backend/routers/)
|
||||||
|
│ └── api/ # API Aggregation Layer (placeholder)
|
||||||
|
│ ├── dashboard.py # NEW: Future aggregation endpoints
|
||||||
|
│ └── roster.py # NEW: Future aggregation endpoints
|
||||||
|
└── data/
|
||||||
|
└── seismo_fleet.db # Still using shared DB (migration pending)
|
||||||
|
```
|
||||||
|
|
||||||
|
## What's Working
|
||||||
|
|
||||||
|
✅ **Application starts successfully** on port 9999
|
||||||
|
✅ **Health endpoint works**: `/health` returns Terra-View v1.0.0
|
||||||
|
✅ **UI renders**: Main dashboard loads with proper templates
|
||||||
|
✅ **API endpoints work**: `/api/status-snapshot` returns seismograph data
|
||||||
|
✅ **Database access works**: Models properly connected
|
||||||
|
✅ **Static files serve**: CSS, JS, icons all accessible
|
||||||
|
|
||||||
|
## Critical Changes Made
|
||||||
|
|
||||||
|
### 1. Fixed Import in models.py
|
||||||
|
**File:** `app/seismo/models.py`
|
||||||
|
**Change:** `from backend.database import Base` → `from app.seismo.database import Base`
|
||||||
|
**Reason:** Avoid duplicate Base instances causing SQLAlchemy errors
|
||||||
|
|
||||||
|
### 2. Created New Entry Point
|
||||||
|
**File:** `app/main.py`
|
||||||
|
**Features:**
|
||||||
|
- Terra-View branding (title, version, health check)
|
||||||
|
- Imports from new `app.*` structure
|
||||||
|
- Registers all seismo and SLM routers
|
||||||
|
- Middleware for environment context
|
||||||
|
|
||||||
|
### 3. Created UI Routes Module
|
||||||
|
**File:** `app/ui/routes.py`
|
||||||
|
**Purpose:** Centralize all HTML page routes (device-agnostic)
|
||||||
|
|
||||||
|
### 4. Created Module-Specific Databases
|
||||||
|
**Files:** `app/seismo/database.py`, `app/slm/database.py`
|
||||||
|
**Status:** Both currently point to shared `seismo_fleet.db` (migration pending)
|
||||||
|
|
||||||
|
## Recent Updates (2026-01-09)
|
||||||
|
|
||||||
|
✅ **ALL imports updated** - Changed all `backend.*` imports to `app.seismo.*` or `app.slm.*`
|
||||||
|
✅ **Old structure deleted** - `backend/` and `templates/` directories removed
|
||||||
|
✅ **Containers rebuilt** - All three containers (Terra-View, SFM, SLMM) working with new imports
|
||||||
|
✅ **Verified working** - Tested health endpoints and UI after migration
|
||||||
|
|
||||||
|
## What's NOT Yet Done
|
||||||
|
|
||||||
|
❌ **Partial routes missing** - `/partials/*` endpoints not yet added
|
||||||
|
❌ **Database not split** - Still using shared `seismo_fleet.db`
|
||||||
|
|
||||||
|
## How to Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start on custom port to avoid conflicts
|
||||||
|
PORT=9999 python3 -m app.main
|
||||||
|
|
||||||
|
# Test health endpoint
|
||||||
|
curl http://localhost:9999/health
|
||||||
|
|
||||||
|
# Test API endpoint
|
||||||
|
curl http://localhost:9999/api/status-snapshot
|
||||||
|
|
||||||
|
# Access UI
|
||||||
|
open http://localhost:9999/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Next Steps (Recommended Order)
|
||||||
|
|
||||||
|
1. **Add partial routes** to app/main.py or create separate router
|
||||||
|
2. **Test all endpoints thoroughly** - Verify roster CRUD, photos, settings
|
||||||
|
3. **Split databases** (Phase 2 of plan)
|
||||||
|
4. **Implement API aggregation layer** (Phase 3 of plan)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
|
||||||
|
None currently - app starts and serves requests successfully!
|
||||||
|
|
||||||
|
## Testing Checklist
|
||||||
|
|
||||||
|
- [x] App starts without errors
|
||||||
|
- [x] Health endpoint returns correct version
|
||||||
|
- [x] Main dashboard loads
|
||||||
|
- [x] Status snapshot API works
|
||||||
|
- [ ] All seismo endpoints work
|
||||||
|
- [ ] All SLM endpoints work
|
||||||
|
- [ ] Roster CRUD operations work
|
||||||
|
- [ ] Photos upload/download works
|
||||||
|
- [ ] Settings page works
|
||||||
|
|
||||||
|
## Rollback Instructions
|
||||||
|
|
||||||
|
~~The old structure has been deleted.~~ To rollback, restore from your backup:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restore from your backup
|
||||||
|
# The old backend/ and templates/ directories were removed on 2026-01-09
|
||||||
|
```
|
||||||
|
|
||||||
|
## Important Notes
|
||||||
|
|
||||||
|
- **MIGRATION COMPLETE**: Old `backend/` and `templates/` directories removed
|
||||||
|
- **ALL IMPORTS UPDATED**: All Python files now use `app.*` imports
|
||||||
|
- **NO DATA LOSS**: Database untouched, only code structure changed
|
||||||
|
- **CONTAINERS WORKING**: All three containers (Terra-View, SFM, SLMM) healthy
|
||||||
|
- **FULLY SELF-CONTAINED**: Application runs entirely from `app/` directory
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Congratulations!** 🎉 Import migration complete! The modular monolith is now self-contained and production-ready.
|
||||||
85
README.md
@@ -1,5 +1,31 @@
|
|||||||
# Terra-View v0.5.1
|
# Terra-View v0.5.0
|
||||||
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.
|
Unified platform for managing seismograph fleets and sound level meter deployments. Built as a modular monolith with independent feature modules (Seismo, SLM) sharing a common UI layer. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your entire fleet through a unified database and dashboard.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Terra-View follows a **modular monolith** architecture with independent feature modules in a single codebase:
|
||||||
|
|
||||||
|
- **app/seismo/** - Seismograph Fleet Module (SFM)
|
||||||
|
- Device roster and deployment tracking
|
||||||
|
- Series 3/4 telemetry ingestion
|
||||||
|
- Status monitoring (OK/Pending/Missing)
|
||||||
|
- Photo management and location tracking
|
||||||
|
- **app/slm/** - Sound Level Meter Manager (SLMM)
|
||||||
|
- NL43 device configuration and control
|
||||||
|
- Real-time measurement monitoring
|
||||||
|
- TCP/FTP/Web interface support
|
||||||
|
- Dashboard statistics and unit management
|
||||||
|
- **app/ui/** - Shared UI layer
|
||||||
|
- Templates, static assets, and common components
|
||||||
|
- Progressive Web App (PWA) support
|
||||||
|
- **app/api/** - API aggregation layer
|
||||||
|
- Cross-module endpoints
|
||||||
|
- Future unified dashboard APIs
|
||||||
|
|
||||||
|
**Multi-Container Deployment**: Three Docker containers built from the same codebase:
|
||||||
|
- `terra-view` (port 8001) - Main UI with all modules integrated
|
||||||
|
- `sfm` (port 8002) - Seismo API backend
|
||||||
|
- `slmm` (port 8100) - SLM API backend
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -308,7 +334,7 @@ print(response.json())
|
|||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| id | string | Unit identifier (primary key) |
|
| id | string | Unit identifier (primary key) |
|
||||||
| unit_type | string | Hardware model name (default: `series3`) |
|
| unit_type | string | Hardware model name (default: `series3`) |
|
||||||
| device_type | string | Device type: `"seismograph"`, `"modem"`, or `"slm"` (sound level meter) |
|
| device_type | string | `seismograph` or `modem` discriminator |
|
||||||
| deployed | boolean | Whether the unit is in the field |
|
| deployed | boolean | Whether the unit is in the field |
|
||||||
| retired | boolean | Removes the unit from deployments but preserves history |
|
| retired | boolean | Removes the unit from deployments but preserves history |
|
||||||
| note | string | Notes about the unit |
|
| note | string | Notes about the unit |
|
||||||
@@ -334,39 +360,6 @@ print(response.json())
|
|||||||
| phone_number | string | Cellular number for the modem |
|
| phone_number | string | Cellular number for the modem |
|
||||||
| hardware_model | string | Modem hardware reference |
|
| hardware_model | string | Modem hardware reference |
|
||||||
|
|
||||||
**Sound Level Meter (SLM) fields**
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
|-------|------|-------------|
|
|
||||||
| slm_host | string | Direct IP address for SLM (if not using modem) |
|
|
||||||
| slm_tcp_port | integer | TCP control port (default: 2255) |
|
|
||||||
| slm_ftp_port | integer | FTP file transfer port (default: 21) |
|
|
||||||
| slm_model | string | Device model (NL-43, NL-53) |
|
|
||||||
| slm_serial_number | string | Manufacturer serial number |
|
|
||||||
| slm_frequency_weighting | string | Frequency weighting setting (A, C, Z) |
|
|
||||||
| slm_time_weighting | string | Time weighting setting (F=Fast, S=Slow) |
|
|
||||||
| slm_measurement_range | string | Measurement range setting |
|
|
||||||
| slm_last_check | datetime | Last status check timestamp |
|
|
||||||
| deployed_with_modem_id | string | Modem pairing (shared with seismographs) |
|
|
||||||
|
|
||||||
### Device Type Schema
|
|
||||||
|
|
||||||
Terra-View supports three device types with the following standardized `device_type` values:
|
|
||||||
|
|
||||||
- **`"seismograph"`** (default) - Seismic monitoring devices (Series 3, Series 4, Micromate)
|
|
||||||
- Uses: calibration dates, modem pairing
|
|
||||||
- Examples: BE1234, UM12345 (Series 3/4 units)
|
|
||||||
|
|
||||||
- **`"modem"`** - Field modems and network equipment
|
|
||||||
- Uses: IP address, phone number, hardware model
|
|
||||||
- Examples: MDM001, MODEM-2025-01
|
|
||||||
|
|
||||||
- **`"slm"`** - Sound level meters (Rion NL-43/NL-53)
|
|
||||||
- Uses: TCP/FTP configuration, measurement settings, modem pairing
|
|
||||||
- Examples: SLM-43-01, NL43-001
|
|
||||||
|
|
||||||
**Important**: All `device_type` values must be lowercase. The legacy value `"sound_level_meter"` has been deprecated in favor of the shorter `"slm"`. Run `backend/migrate_standardize_device_types.py` to update existing databases.
|
|
||||||
|
|
||||||
### Emitter Table (Device Check-ins)
|
### Emitter Table (Device Check-ins)
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
@@ -496,12 +489,6 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
### v0.4.3 — 2026-01-14
|
|
||||||
- **Sound Level Meter workflow**: Roster manager surfaces SLM metadata, supports rename actions, and adds return-to-project navigation plus schedule/unit templates for project planning.
|
|
||||||
- **Project insight panels**: Project dashboards now expose file and session lists so teams can see what each project stores before diving into units.
|
|
||||||
- **Project view polish**: FTP browser supports folder downloads, the timer display was reimplemented, and the project/device templates gained edit modals for projects and locations to streamline navigation.
|
|
||||||
- **SLM sync & accuracy**: Configuration edits now propagate to SLMM (which caches configs for faster responses) and the live view uses the correct DRD fields so telemetry aligns with the control center.
|
|
||||||
|
|
||||||
### v0.4.0 — 2025-12-16
|
### v0.4.0 — 2025-12-16
|
||||||
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
||||||
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
||||||
@@ -571,19 +558,9 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.5.1** — Dashboard schedule view with today's actions panel, new Terra-View branding and logo rework (2026-01-27)
|
**Current: 0.4.0** — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||||
|
|
||||||
Previous: 0.4.4 — Recurring schedules, alerting UI, report templates + RND viewer, and SLM workflow polish (2026-01-23)
|
Previous: 0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
||||||
|
|
||||||
0.4.3 — SLM roster/project view refresh, project insight panels, FTP browser folder downloads, and SLMM sync (2026-01-14)
|
|
||||||
|
|
||||||
0.4.2 — SLM configuration interface with TCP/FTP controls, modem diagnostics, and dashboard endpoints for Sound Level Meters (2026-01-05)
|
|
||||||
|
|
||||||
0.4.1 — Sound Level Meter integration with full management UI for SLM units (2026-01-05)
|
|
||||||
|
|
||||||
0.4.0 — Database management system with backup/restore and remote cloning (2025-12-16)
|
|
||||||
|
|
||||||
0.3.3 — Mobile navigation improvements and better status visibility (2025-12-12)
|
|
||||||
|
|
||||||
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
|
||||||
|
|
||||||
|
|||||||
0
app/__init__.py
Normal file
0
app/api/__init__.py
Normal file
13
app/api/dashboard.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Dashboard endpoints
|
||||||
|
Composes data from multiple feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/dashboard", tags=["dashboard-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement aggregation endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own APIs directly
|
||||||
|
# Future: Add cross-feature aggregation here
|
||||||
13
app/api/roster.py
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
"""
|
||||||
|
API Aggregation Layer - Roster endpoints
|
||||||
|
Aggregates roster data from all feature modules
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster-aggregation", tags=["roster-aggregation"])
|
||||||
|
|
||||||
|
# TODO: Implement unified roster endpoints that combine data from
|
||||||
|
# app.seismo and app.slm modules
|
||||||
|
|
||||||
|
# For now, individual feature modules expose their own roster APIs
|
||||||
|
# Future: Add cross-feature roster aggregation here
|
||||||
83
app/api/slmm_proxy.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
"""
|
||||||
|
SLMM API Proxy
|
||||||
|
Forwards /api/slmm/* requests to the SLMM backend service
|
||||||
|
"""
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from fastapi import APIRouter, Request, Response, WebSocket
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
from app.core.config import SLMM_API_URL
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/slmm", tags=["slmm-proxy"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||||
|
async def proxy_slmm_request(path: str, request: Request):
|
||||||
|
"""Proxy HTTP requests to SLMM backend"""
|
||||||
|
# Build target URL - rewrite /api/slmm/* to /api/nl43/*
|
||||||
|
target_url = f"{SLMM_API_URL}/api/nl43/{path}"
|
||||||
|
|
||||||
|
# Get query params
|
||||||
|
query_string = str(request.url.query)
|
||||||
|
if query_string:
|
||||||
|
target_url += f"?{query_string}"
|
||||||
|
|
||||||
|
logger.info(f"Proxying {request.method} {target_url}")
|
||||||
|
|
||||||
|
# Read request body
|
||||||
|
body = await request.body()
|
||||||
|
|
||||||
|
# Forward headers (exclude host)
|
||||||
|
headers = {
|
||||||
|
key: value
|
||||||
|
for key, value in request.headers.items()
|
||||||
|
if key.lower() not in ['host', 'content-length']
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||||
|
try:
|
||||||
|
# Make proxied request
|
||||||
|
response = await client.request(
|
||||||
|
method=request.method,
|
||||||
|
url=target_url,
|
||||||
|
content=body,
|
||||||
|
headers=headers
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return response
|
||||||
|
return Response(
|
||||||
|
content=response.content,
|
||||||
|
status_code=response.status_code,
|
||||||
|
headers=dict(response.headers)
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Proxy request failed: {e}")
|
||||||
|
return Response(
|
||||||
|
content=f'{{"detail": "SLMM backend unavailable: {str(e)}"}}',
|
||||||
|
status_code=502,
|
||||||
|
media_type="application/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.websocket("/{unit_id}/live")
|
||||||
|
async def proxy_slmm_websocket(websocket: WebSocket, unit_id: str):
|
||||||
|
"""Proxy WebSocket connections to SLMM backend for live data streaming"""
|
||||||
|
await websocket.accept()
|
||||||
|
|
||||||
|
# Build WebSocket URL
|
||||||
|
ws_protocol = "ws" if "localhost" in SLMM_API_URL or "127.0.0.1" in SLMM_API_URL else "wss"
|
||||||
|
ws_url = SLMM_API_URL.replace("http://", f"{ws_protocol}://").replace("https://", f"{ws_protocol}://")
|
||||||
|
ws_target = f"{ws_url}/api/slmm/{unit_id}/live"
|
||||||
|
|
||||||
|
logger.info(f"Proxying WebSocket to {ws_target}")
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
try:
|
||||||
|
async with client.stream("GET", ws_target) as response:
|
||||||
|
async for chunk in response.aiter_bytes():
|
||||||
|
await websocket.send_bytes(chunk)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"WebSocket proxy error: {e}")
|
||||||
|
await websocket.close(code=1011, reason=f"Backend error: {str(e)}")
|
||||||
0
app/core/__init__.py
Normal file
22
app/core/config.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
"""
|
||||||
|
Core configuration for Terra-View application
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Application
|
||||||
|
APP_NAME = "Terra-View"
|
||||||
|
VERSION = "1.0.0"
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
PORT = int(os.getenv("PORT", 8001))
|
||||||
|
|
||||||
|
# External Services
|
||||||
|
# Terra-View is a unified application with seismograph logic built-in
|
||||||
|
# The only external HTTP dependency is SLMM for NL-43 device communication
|
||||||
|
SLMM_API_URL = os.getenv("SLMM_API_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
# Database URLs (feature-specific)
|
||||||
|
SEISMO_DATABASE_URL = "sqlite:///./data/seismo.db"
|
||||||
|
SLM_DATABASE_URL = "sqlite:///./data/slm.db"
|
||||||
|
MODEM_DATABASE_URL = "sqlite:///./data/modem.db"
|
||||||
216
app/main.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
"""
|
||||||
|
Terra-View - Unified monitoring platform for device fleets
|
||||||
|
Modular monolith architecture with strict feature boundaries
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Import configuration
|
||||||
|
from app.core.config import APP_NAME, VERSION, ENVIRONMENT
|
||||||
|
|
||||||
|
# Import UI routes
|
||||||
|
from app.ui import routes as ui_routes
|
||||||
|
|
||||||
|
# Import feature module routers (seismo)
|
||||||
|
from app.seismo.routers import (
|
||||||
|
roster as seismo_roster,
|
||||||
|
units as seismo_units,
|
||||||
|
photos as seismo_photos,
|
||||||
|
roster_edit as seismo_roster_edit,
|
||||||
|
dashboard as seismo_dashboard,
|
||||||
|
dashboard_tabs as seismo_dashboard_tabs,
|
||||||
|
activity as seismo_activity,
|
||||||
|
seismo_dashboard as seismo_seismo_dashboard,
|
||||||
|
settings as seismo_settings,
|
||||||
|
partials as seismo_partials,
|
||||||
|
)
|
||||||
|
from app.seismo import routes as seismo_legacy_routes
|
||||||
|
|
||||||
|
# Import feature module routers (SLM)
|
||||||
|
from app.slm.routers import router as slm_router
|
||||||
|
from app.slm.dashboard import router as slm_dashboard_router
|
||||||
|
|
||||||
|
# Import API aggregation layer (placeholder for now)
|
||||||
|
from app.api import dashboard as api_dashboard
|
||||||
|
from app.api import roster as api_roster
|
||||||
|
|
||||||
|
# Initialize database tables
|
||||||
|
from app.seismo.database import engine as seismo_engine, Base as SeismoBase
|
||||||
|
SeismoBase.metadata.create_all(bind=seismo_engine)
|
||||||
|
|
||||||
|
from app.slm.database import engine as slm_engine, Base as SlmBase
|
||||||
|
SlmBase.metadata.create_all(bind=slm_engine)
|
||||||
|
|
||||||
|
# Initialize FastAPI app
|
||||||
|
app = FastAPI(
|
||||||
|
title=APP_NAME,
|
||||||
|
description="Unified monitoring platform for seismograph, modem, and sound level meter fleets",
|
||||||
|
version=VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add validation error handler to log details
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||||
|
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||||
|
logger.error(f"Body: {await request.body()}")
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=400,
|
||||||
|
content={"detail": exc.errors()}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Configure CORS
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
# Mount static files
|
||||||
|
app.mount("/static", StaticFiles(directory="app/ui/static"), name="static")
|
||||||
|
|
||||||
|
# Middleware to add environment to request state
|
||||||
|
@app.middleware("http")
|
||||||
|
async def add_environment_to_context(request: Request, call_next):
|
||||||
|
"""Middleware to add environment variable to request state"""
|
||||||
|
request.state.environment = ENVIRONMENT
|
||||||
|
response = await call_next(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
# ===== INCLUDE ROUTERS =====
|
||||||
|
|
||||||
|
# UI Layer (HTML pages)
|
||||||
|
app.include_router(ui_routes.router)
|
||||||
|
|
||||||
|
# Seismograph Feature Module APIs
|
||||||
|
app.include_router(seismo_roster.router)
|
||||||
|
app.include_router(seismo_units.router)
|
||||||
|
app.include_router(seismo_photos.router)
|
||||||
|
app.include_router(seismo_roster_edit.router)
|
||||||
|
app.include_router(seismo_dashboard.router)
|
||||||
|
app.include_router(seismo_dashboard_tabs.router)
|
||||||
|
app.include_router(seismo_activity.router)
|
||||||
|
app.include_router(seismo_seismo_dashboard.router)
|
||||||
|
app.include_router(seismo_settings.router)
|
||||||
|
app.include_router(seismo_partials.router, prefix="/partials")
|
||||||
|
app.include_router(seismo_legacy_routes.router)
|
||||||
|
|
||||||
|
# SLM Feature Module APIs
|
||||||
|
app.include_router(slm_router)
|
||||||
|
app.include_router(slm_dashboard_router)
|
||||||
|
|
||||||
|
# SLMM Backend Proxy (forward /api/slmm/* to SLMM service)
|
||||||
|
from app.api import slmm_proxy
|
||||||
|
app.include_router(slmm_proxy.router)
|
||||||
|
|
||||||
|
# API Aggregation Layer (future cross-feature endpoints)
|
||||||
|
# app.include_router(api_dashboard.router) # TODO: Implement aggregation
|
||||||
|
# app.include_router(api_roster.router) # TODO: Implement aggregation
|
||||||
|
|
||||||
|
# ===== ADDITIONAL ROUTES FROM OLD MAIN.PY =====
|
||||||
|
# These will need to be migrated to appropriate modules
|
||||||
|
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from typing import List, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
from app.seismo.models import IgnoredUnit
|
||||||
|
|
||||||
|
# TODO: Move these to appropriate feature modules or UI layer
|
||||||
|
|
||||||
|
@app.post("/api/sync-edits")
|
||||||
|
async def sync_edits(request: dict, db: Session = Depends(get_db)):
|
||||||
|
"""Process offline edit queue and sync to database"""
|
||||||
|
# TODO: Move to seismo module
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
class EditItem(BaseModel):
|
||||||
|
id: int
|
||||||
|
unitId: str
|
||||||
|
changes: Dict
|
||||||
|
timestamp: int
|
||||||
|
|
||||||
|
class SyncEditsRequest(BaseModel):
|
||||||
|
edits: List[EditItem]
|
||||||
|
|
||||||
|
sync_request = SyncEditsRequest(**request)
|
||||||
|
results = []
|
||||||
|
synced_ids = []
|
||||||
|
|
||||||
|
for edit in sync_request.edits:
|
||||||
|
try:
|
||||||
|
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": f"Unit {edit.unitId} not found"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
for key, value in edit.changes.items():
|
||||||
|
if hasattr(unit, key):
|
||||||
|
if key in ['deployed', 'retired']:
|
||||||
|
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||||
|
else:
|
||||||
|
setattr(unit, key, value if value != '' else None)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "success"
|
||||||
|
})
|
||||||
|
synced_ids.append(edit.id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
results.append({
|
||||||
|
"id": edit.id,
|
||||||
|
"status": "error",
|
||||||
|
"reason": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
synced_count = len(synced_ids)
|
||||||
|
|
||||||
|
return JSONResponse({
|
||||||
|
"synced": synced_count,
|
||||||
|
"total": len(sync_request.edits),
|
||||||
|
"synced_ids": synced_ids,
|
||||||
|
"results": results
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health_check():
|
||||||
|
"""Health check endpoint"""
|
||||||
|
return {
|
||||||
|
"message": f"{APP_NAME} v{VERSION}",
|
||||||
|
"status": "running",
|
||||||
|
"version": VERSION,
|
||||||
|
"modules": ["seismo", "slm"]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
from app.core.config import PORT
|
||||||
|
uvicorn.run(app, host="0.0.0.0", port=PORT)
|
||||||
0
app/modem/__init__.py
Normal file
0
app/seismo/__init__.py
Normal file
36
app/seismo/database.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
"""
|
||||||
|
Seismograph feature module database connection
|
||||||
|
"""
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
# For now, we'll use the old database (seismo_fleet.db) until we migrate
|
||||||
|
# TODO: Migrate to seismo.db
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions"""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)"""
|
||||||
|
return SessionLocal()
|
||||||
110
app/seismo/models.py
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||||
|
from datetime import datetime
|
||||||
|
from app.seismo.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Emitter(Base):
|
||||||
|
__tablename__ = "emitters"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, nullable=False)
|
||||||
|
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||||
|
last_file = Column(String, nullable=False)
|
||||||
|
status = Column(String, nullable=False)
|
||||||
|
notes = Column(String, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RosterUnit(Base):
|
||||||
|
"""
|
||||||
|
Roster table: represents our *intended assignment* of a unit.
|
||||||
|
This is editable from the GUI.
|
||||||
|
|
||||||
|
Supports multiple device types (seismograph, modem, sound_level_meter) with type-specific fields.
|
||||||
|
"""
|
||||||
|
__tablename__ = "roster"
|
||||||
|
|
||||||
|
# Core fields (all device types)
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
unit_type = Column(String, default="series3") # Backward compatibility
|
||||||
|
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "sound_level_meter"
|
||||||
|
deployed = Column(Boolean, default=True)
|
||||||
|
retired = Column(Boolean, default=False)
|
||||||
|
note = Column(String, nullable=True)
|
||||||
|
project_id = Column(String, nullable=True)
|
||||||
|
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
|
||||||
|
address = Column(String, nullable=True) # Human-readable address
|
||||||
|
coordinates = Column(String, nullable=True) # Lat,Lon format: "34.0522,-118.2437"
|
||||||
|
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
# Seismograph-specific fields (nullable for modems and SLMs)
|
||||||
|
last_calibrated = Column(Date, nullable=True)
|
||||||
|
next_calibration_due = Column(Date, nullable=True)
|
||||||
|
|
||||||
|
# Modem assignment (shared by seismographs and SLMs)
|
||||||
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit (device_type=modem)
|
||||||
|
|
||||||
|
# Modem-specific fields (nullable for seismographs and SLMs)
|
||||||
|
ip_address = Column(String, nullable=True)
|
||||||
|
phone_number = Column(String, nullable=True)
|
||||||
|
hardware_model = Column(String, nullable=True)
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields (nullable for seismographs and modems)
|
||||||
|
slm_host = Column(String, nullable=True) # Device IP or hostname
|
||||||
|
slm_tcp_port = Column(Integer, nullable=True) # TCP control port (default 2255)
|
||||||
|
slm_ftp_port = Column(Integer, nullable=True) # FTP data retrieval port (default 21)
|
||||||
|
slm_model = Column(String, nullable=True) # NL-43, NL-53, etc.
|
||||||
|
slm_serial_number = Column(String, nullable=True) # Device serial number
|
||||||
|
slm_frequency_weighting = Column(String, nullable=True) # A, C, Z
|
||||||
|
slm_time_weighting = Column(String, nullable=True) # F (Fast), S (Slow), I (Impulse)
|
||||||
|
slm_measurement_range = Column(String, nullable=True) # e.g., "30-130 dB"
|
||||||
|
slm_last_check = Column(DateTime, nullable=True) # Last communication check
|
||||||
|
|
||||||
|
|
||||||
|
class IgnoredUnit(Base):
|
||||||
|
"""
|
||||||
|
Ignored units: units that report but should be filtered out from unknown emitters.
|
||||||
|
Used to suppress noise from old projects.
|
||||||
|
"""
|
||||||
|
__tablename__ = "ignored_units"
|
||||||
|
|
||||||
|
id = Column(String, primary_key=True, index=True)
|
||||||
|
reason = Column(String, nullable=True)
|
||||||
|
ignored_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitHistory(Base):
|
||||||
|
"""
|
||||||
|
Unit history: complete timeline of changes to each unit.
|
||||||
|
Tracks note changes, status changes, deployment/benched events, and more.
|
||||||
|
"""
|
||||||
|
__tablename__ = "unit_history"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||||
|
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
|
||||||
|
field_name = Column(String, nullable=True) # Which field changed
|
||||||
|
old_value = Column(Text, nullable=True) # Previous value
|
||||||
|
new_value = Column(Text, nullable=True) # New value
|
||||||
|
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||||
|
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
|
||||||
|
notes = Column(Text, nullable=True) # Optional reason/context for the change
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences: persistent storage for application settings.
|
||||||
|
Single-row table (id=1) to store global user preferences.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
timezone = Column(String, default="America/New_York")
|
||||||
|
theme = Column(String, default="auto") # auto, light, dark
|
||||||
|
auto_refresh_interval = Column(Integer, default=10) # seconds
|
||||||
|
date_format = Column(String, default="MM/DD/YYYY")
|
||||||
|
table_rows_per_page = Column(Integer, default=25)
|
||||||
|
calibration_interval_days = Column(Integer, default=365)
|
||||||
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
0
app/seismo/routers/__init__.py
Normal file
@@ -4,8 +4,8 @@ from sqlalchemy import desc
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from typing import List, Dict, Any
|
from typing import List, Dict, Any
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import UnitHistory, Emitter, RosterUnit
|
from app.seismo.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["activity"])
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
25
app/seismo/routers/dashboard.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from fastapi import APIRouter, Request, Depends
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/active")
|
||||||
|
def dashboard_active(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/active_table.html",
|
||||||
|
{"request": request, "units": snapshot["active"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/dashboard/benched")
|
||||||
|
def dashboard_benched(request: Request):
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/benched_table.html",
|
||||||
|
{"request": request, "units": snapshot["benched"]}
|
||||||
|
)
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||||
|
|
||||||
140
app/seismo/routers/partials.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""
|
||||||
|
Partial routes for HTMX dynamic content loading.
|
||||||
|
These routes return HTML fragments that are loaded into the page via HTMX.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unknown-emitters", response_class=HTMLResponse)
|
||||||
|
async def get_unknown_emitters(request: Request):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with unknown emitters (units reporting but not in roster).
|
||||||
|
Called periodically via HTMX (every 10s) from the roster page.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
# Convert unknown units dict to list and add required fields
|
||||||
|
unknown_list = []
|
||||||
|
for unit_id, unit_data in snapshot.get("unknown", {}).items():
|
||||||
|
unknown_list.append({
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"fname": unit_data.get("fname", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by ID for consistent display
|
||||||
|
unknown_list.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/unknown_emitters.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"unknown_units": unknown_list
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/devices-all", response_class=HTMLResponse)
|
||||||
|
async def get_all_devices(request: Request):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with all devices (deployed, benched, retired, ignored).
|
||||||
|
Called on page load and when filters are applied.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
# Combine all units from different buckets
|
||||||
|
all_units = []
|
||||||
|
|
||||||
|
# Add active units (deployed)
|
||||||
|
for unit_id, unit_data in snapshot.get("active", {}).items():
|
||||||
|
unit_info = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data.get("last", ""),
|
||||||
|
"fname": unit_data.get("fname", ""),
|
||||||
|
"deployed": True,
|
||||||
|
"retired": False,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"location": unit_data.get("location", ""),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
}
|
||||||
|
all_units.append(unit_info)
|
||||||
|
|
||||||
|
# Add benched units (not deployed, not retired)
|
||||||
|
for unit_id, unit_data in snapshot.get("benched", {}).items():
|
||||||
|
unit_info = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data.get("last", ""),
|
||||||
|
"fname": unit_data.get("fname", ""),
|
||||||
|
"deployed": False,
|
||||||
|
"retired": False,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"location": unit_data.get("location", ""),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
}
|
||||||
|
all_units.append(unit_info)
|
||||||
|
|
||||||
|
# Add retired units
|
||||||
|
for unit_id, unit_data in snapshot.get("retired", {}).items():
|
||||||
|
unit_info = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": "Retired",
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data.get("last", ""),
|
||||||
|
"fname": unit_data.get("fname", ""),
|
||||||
|
"deployed": False,
|
||||||
|
"retired": True,
|
||||||
|
"ignored": False,
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"device_type": unit_data.get("device_type", "seismograph"),
|
||||||
|
"location": unit_data.get("location", ""),
|
||||||
|
"address": unit_data.get("address", ""),
|
||||||
|
"coordinates": unit_data.get("coordinates", ""),
|
||||||
|
"last_calibrated": unit_data.get("last_calibrated"),
|
||||||
|
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||||
|
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||||
|
"ip_address": unit_data.get("ip_address"),
|
||||||
|
"phone_number": unit_data.get("phone_number"),
|
||||||
|
"hardware_model": unit_data.get("hardware_model"),
|
||||||
|
}
|
||||||
|
all_units.append(unit_info)
|
||||||
|
|
||||||
|
# Sort by ID for consistent display
|
||||||
|
all_units.sort(key=lambda x: x["id"])
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/devices_table.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": all_units
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -8,8 +8,8 @@ import shutil
|
|||||||
from PIL import Image
|
from PIL import Image
|
||||||
from PIL.ExifTags import TAGS, GPSTAGS
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import RosterUnit
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["photos"])
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
@@ -2,32 +2,20 @@ from fastapi import APIRouter, Depends
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import random
|
import random
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
from backend.services.slm_status_sync import sync_slm_status_to_emitters
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api", tags=["roster"])
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/status-snapshot")
|
@router.get("/status-snapshot")
|
||||||
async def get_status_snapshot(db: Session = Depends(get_db)):
|
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Calls emit_status_snapshot() to get current fleet status.
|
Calls emit_status_snapshot() to get current fleet status.
|
||||||
Syncs SLM status from SLMM before generating snapshot.
|
This will be replaced with real Series3 emitter logic later.
|
||||||
"""
|
"""
|
||||||
# Sync SLM status from SLMM (with timeout to prevent blocking)
|
|
||||||
try:
|
|
||||||
await asyncio.wait_for(sync_slm_status_to_emitters(), timeout=2.0)
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
logger.warning("SLM status sync timed out, using cached data")
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"SLM status sync failed: {e}")
|
|
||||||
|
|
||||||
return emit_status_snapshot()
|
return emit_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
720
app/seismo/routers/roster_edit.py
Normal file
@@ -0,0 +1,720 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File, Request
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
import httpx
|
||||||
|
import os
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# SLMM backend URL for syncing device configs to cache
|
||||||
|
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||||
|
|
||||||
|
|
||||||
|
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||||
|
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||||
|
"""Helper function to record a change in unit history"""
|
||||||
|
history_entry = UnitHistory(
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type=change_type,
|
||||||
|
field_name=field_name,
|
||||||
|
old_value=old_value,
|
||||||
|
new_value=new_value,
|
||||||
|
changed_at=datetime.utcnow(),
|
||||||
|
source=source,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
db.add(history_entry)
|
||||||
|
# Note: caller is responsible for db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
unit = RosterUnit(id=unit_id)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(unit)
|
||||||
|
return unit
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_slm_to_slmm_cache(
|
||||||
|
unit_id: str,
|
||||||
|
host: str = None,
|
||||||
|
tcp_port: int = None,
|
||||||
|
ftp_port: int = None,
|
||||||
|
ftp_username: str = None,
|
||||||
|
ftp_password: str = None,
|
||||||
|
deployed_with_modem_id: str = None,
|
||||||
|
db: Session = None
|
||||||
|
) -> dict:
|
||||||
|
"""
|
||||||
|
Sync SLM device configuration to SLMM backend cache.
|
||||||
|
|
||||||
|
Terra-View is the source of truth for device configs. This function updates
|
||||||
|
SLMM's config cache (NL43Config table) so SLMM can look up device connection
|
||||||
|
info by unit_id without Terra-View passing host:port with every request.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
unit_id: Unique identifier for the SLM device
|
||||||
|
host: Direct IP address/hostname OR will be resolved from modem
|
||||||
|
tcp_port: TCP control port (default: 2255)
|
||||||
|
ftp_port: FTP port (default: 21)
|
||||||
|
ftp_username: FTP username (optional)
|
||||||
|
ftp_password: FTP password (optional)
|
||||||
|
deployed_with_modem_id: If set, resolve modem IP as host
|
||||||
|
db: Database session for modem lookup
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: {"success": bool, "message": str}
|
||||||
|
"""
|
||||||
|
# Resolve host from modem if assigned
|
||||||
|
if deployed_with_modem_id and db:
|
||||||
|
modem = db.query(RosterUnit).filter_by(
|
||||||
|
id=deployed_with_modem_id,
|
||||||
|
device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.ip_address:
|
||||||
|
host = modem.ip_address
|
||||||
|
logger.info(f"Resolved host from modem {deployed_with_modem_id}: {host}")
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
|
if not host:
|
||||||
|
logger.warning(f"Cannot sync SLM {unit_id} to SLMM: no host/IP address provided")
|
||||||
|
return {"success": False, "message": "No host IP address available"}
|
||||||
|
|
||||||
|
# Set defaults
|
||||||
|
tcp_port = tcp_port or 2255
|
||||||
|
ftp_port = ftp_port or 21
|
||||||
|
|
||||||
|
# Build SLMM cache payload
|
||||||
|
config_payload = {
|
||||||
|
"host": host,
|
||||||
|
"tcp_port": tcp_port,
|
||||||
|
"tcp_enabled": True,
|
||||||
|
"ftp_enabled": bool(ftp_username and ftp_password),
|
||||||
|
"web_enabled": False
|
||||||
|
}
|
||||||
|
|
||||||
|
if ftp_username and ftp_password:
|
||||||
|
config_payload["ftp_username"] = ftp_username
|
||||||
|
config_payload["ftp_password"] = ftp_password
|
||||||
|
|
||||||
|
# Call SLMM cache update API
|
||||||
|
slmm_url = f"{SLMM_BASE_URL}/api/nl43/{unit_id}/config"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||||
|
response = await client.put(slmm_url, json=config_payload)
|
||||||
|
|
||||||
|
if response.status_code in [200, 201]:
|
||||||
|
logger.info(f"Successfully synced SLM {unit_id} to SLMM cache")
|
||||||
|
return {"success": True, "message": "Device config cached in SLMM"}
|
||||||
|
else:
|
||||||
|
logger.error(f"SLMM cache sync failed for {unit_id}: HTTP {response.status_code}")
|
||||||
|
return {"success": False, "message": f"SLMM returned status {response.status_code}"}
|
||||||
|
|
||||||
|
except httpx.ConnectError:
|
||||||
|
logger.error(f"Cannot connect to SLMM service at {SLMM_BASE_URL}")
|
||||||
|
return {"success": False, "message": "SLMM service unavailable"}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error syncing SLM {unit_id} to SLMM: {e}")
|
||||||
|
return {"success": False, "message": str(e)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add")
|
||||||
|
async def add_roster_unit(
|
||||||
|
id: str = Form(...),
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
logger.info(f"Adding unit: id={id}, device_type={device_type}, deployed={deployed}, retired={retired}")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
unit = RosterUnit(
|
||||||
|
id=id,
|
||||||
|
device_type=device_type,
|
||||||
|
unit_type=unit_type,
|
||||||
|
deployed=deployed_bool,
|
||||||
|
retired=retired_bool,
|
||||||
|
note=note,
|
||||||
|
project_id=project_id,
|
||||||
|
location=location,
|
||||||
|
address=address,
|
||||||
|
coordinates=coordinates,
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated=last_cal_date,
|
||||||
|
next_calibration_due=next_cal_date,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None,
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address=ip_address if ip_address else None,
|
||||||
|
phone_number=phone_number if phone_number else None,
|
||||||
|
hardware_model=hardware_model if hardware_model else None,
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host=slm_host if slm_host else None,
|
||||||
|
slm_tcp_port=slm_tcp_port_int,
|
||||||
|
slm_ftp_port=slm_ftp_port_int,
|
||||||
|
slm_model=slm_model if slm_model else None,
|
||||||
|
slm_serial_number=slm_serial_number if slm_serial_number else None,
|
||||||
|
slm_frequency_weighting=slm_frequency_weighting if slm_frequency_weighting else None,
|
||||||
|
slm_time_weighting=slm_time_weighting if slm_time_weighting else None,
|
||||||
|
slm_measurement_range=slm_measurement_range if slm_measurement_range else None,
|
||||||
|
)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# If sound level meter, sync config to SLMM cache
|
||||||
|
if device_type == "sound_level_meter":
|
||||||
|
logger.info(f"Syncing SLM {id} config to SLMM cache...")
|
||||||
|
result = await sync_slm_to_slmm_cache(
|
||||||
|
unit_id=id,
|
||||||
|
host=slm_host,
|
||||||
|
tcp_port=slm_tcp_port_int,
|
||||||
|
ftp_port=slm_ftp_port_int,
|
||||||
|
deployed_with_modem_id=deployed_with_modem_id,
|
||||||
|
db=db
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result["success"]:
|
||||||
|
logger.warning(f"SLMM cache sync warning for {id}: {result['message']}")
|
||||||
|
# Don't fail the operation - device is still added to Terra-View roster
|
||||||
|
# User can manually sync later or SLMM will be synced on next config update
|
||||||
|
|
||||||
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/modems")
|
||||||
|
def get_modems_list(db: Session = Depends(get_db)):
|
||||||
|
"""Get list of all modem units for dropdown selection"""
|
||||||
|
modems = db.query(RosterUnit).filter_by(device_type="modem", retired=False).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": modem.id,
|
||||||
|
"ip_address": modem.ip_address,
|
||||||
|
"phone_number": modem.phone_number,
|
||||||
|
"hardware_model": modem.hardware_model,
|
||||||
|
"deployed": modem.deployed
|
||||||
|
}
|
||||||
|
for modem in modems
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{unit_id}")
|
||||||
|
def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Get a single roster unit by ID"""
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"unit_type": unit.unit_type,
|
||||||
|
"deployed": unit.deployed,
|
||||||
|
"retired": unit.retired,
|
||||||
|
"note": unit.note or "",
|
||||||
|
"project_id": unit.project_id or "",
|
||||||
|
"location": unit.location or "",
|
||||||
|
"address": unit.address or "",
|
||||||
|
"coordinates": unit.coordinates or "",
|
||||||
|
"last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "",
|
||||||
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "",
|
||||||
|
"deployed_with_modem_id": unit.deployed_with_modem_id or "",
|
||||||
|
"ip_address": unit.ip_address or "",
|
||||||
|
"phone_number": unit.phone_number or "",
|
||||||
|
"hardware_model": unit.hardware_model or "",
|
||||||
|
"slm_host": unit.slm_host or "",
|
||||||
|
"slm_tcp_port": unit.slm_tcp_port or "",
|
||||||
|
"slm_ftp_port": unit.slm_ftp_port or "",
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/edit/{unit_id}")
|
||||||
|
def edit_roster_unit(
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: str = Form(None),
|
||||||
|
retired: str = Form(None),
|
||||||
|
note: str = Form(""),
|
||||||
|
project_id: str = Form(None),
|
||||||
|
location: str = Form(None),
|
||||||
|
address: str = Form(None),
|
||||||
|
coordinates: str = Form(None),
|
||||||
|
# Seismograph-specific fields
|
||||||
|
last_calibrated: str = Form(None),
|
||||||
|
next_calibration_due: str = Form(None),
|
||||||
|
deployed_with_modem_id: str = Form(None),
|
||||||
|
# Modem-specific fields
|
||||||
|
ip_address: str = Form(None),
|
||||||
|
phone_number: str = Form(None),
|
||||||
|
hardware_model: str = Form(None),
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
slm_host: str = Form(None),
|
||||||
|
slm_tcp_port: str = Form(None),
|
||||||
|
slm_ftp_port: str = Form(None),
|
||||||
|
slm_model: str = Form(None),
|
||||||
|
slm_serial_number: str = Form(None),
|
||||||
|
slm_frequency_weighting: str = Form(None),
|
||||||
|
slm_time_weighting: str = Form(None),
|
||||||
|
slm_measurement_range: str = Form(None),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
# Convert boolean strings to actual booleans
|
||||||
|
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
|
||||||
|
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
|
||||||
|
|
||||||
|
# Convert port strings to integers
|
||||||
|
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
|
||||||
|
slm_ftp_port_int = int(slm_ftp_port) if slm_ftp_port and slm_ftp_port.strip() else None
|
||||||
|
|
||||||
|
# Parse date fields if provided
|
||||||
|
last_cal_date = None
|
||||||
|
if last_calibrated:
|
||||||
|
try:
|
||||||
|
last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
next_cal_date = None
|
||||||
|
if next_calibration_due:
|
||||||
|
try:
|
||||||
|
next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||||
|
|
||||||
|
# Track changes for history
|
||||||
|
old_note = unit.note
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
old_retired = unit.retired
|
||||||
|
|
||||||
|
# Update all fields
|
||||||
|
unit.device_type = device_type
|
||||||
|
unit.unit_type = unit_type
|
||||||
|
unit.deployed = deployed_bool
|
||||||
|
unit.retired = retired_bool
|
||||||
|
unit.note = note
|
||||||
|
unit.project_id = project_id
|
||||||
|
unit.location = location
|
||||||
|
unit.address = address
|
||||||
|
unit.coordinates = coordinates
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Seismograph-specific fields
|
||||||
|
unit.last_calibrated = last_cal_date
|
||||||
|
unit.next_calibration_due = next_cal_date
|
||||||
|
unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None
|
||||||
|
|
||||||
|
# Modem-specific fields
|
||||||
|
unit.ip_address = ip_address if ip_address else None
|
||||||
|
unit.phone_number = phone_number if phone_number else None
|
||||||
|
unit.hardware_model = hardware_model if hardware_model else None
|
||||||
|
|
||||||
|
# Sound Level Meter-specific fields
|
||||||
|
unit.slm_host = slm_host if slm_host else None
|
||||||
|
unit.slm_tcp_port = slm_tcp_port_int
|
||||||
|
unit.slm_ftp_port = slm_ftp_port_int
|
||||||
|
unit.slm_model = slm_model if slm_model else None
|
||||||
|
unit.slm_serial_number = slm_serial_number if slm_serial_number else None
|
||||||
|
unit.slm_frequency_weighting = slm_frequency_weighting if slm_frequency_weighting else None
|
||||||
|
unit.slm_time_weighting = slm_time_weighting if slm_time_weighting else None
|
||||||
|
unit.slm_measurement_range = slm_measurement_range if slm_measurement_range else None
|
||||||
|
|
||||||
|
# Record history entries for changed fields
|
||||||
|
if old_note != note:
|
||||||
|
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||||
|
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-deployed/{unit_id}")
|
||||||
|
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_deployed = unit.deployed
|
||||||
|
unit.deployed = deployed
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for deployed status change
|
||||||
|
if old_deployed != deployed:
|
||||||
|
status_text = "deployed" if deployed else "benched"
|
||||||
|
old_status_text = "deployed" if old_deployed else "benched"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="deployed_change",
|
||||||
|
field_name="deployed",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-retired/{unit_id}")
|
||||||
|
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_retired = unit.retired
|
||||||
|
unit.retired = retired
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for retired status change
|
||||||
|
if old_retired != retired:
|
||||||
|
status_text = "retired" if retired else "active"
|
||||||
|
old_status_text = "retired" if old_retired else "active"
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="retired_change",
|
||||||
|
field_name="retired",
|
||||||
|
old_value=old_status_text,
|
||||||
|
new_value=status_text,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{unit_id}")
|
||||||
|
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Permanently delete a unit from the database.
|
||||||
|
Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists.
|
||||||
|
"""
|
||||||
|
deleted = False
|
||||||
|
|
||||||
|
# Try to delete from roster table
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
if roster_unit:
|
||||||
|
db.delete(roster_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from emitters table
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == unit_id).first()
|
||||||
|
if emitter:
|
||||||
|
db.delete(emitter)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# Try to delete from ignored_units table
|
||||||
|
ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if ignored_unit:
|
||||||
|
db.delete(ignored_unit)
|
||||||
|
deleted = True
|
||||||
|
|
||||||
|
# If not found in any table, return error
|
||||||
|
if not deleted:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit deleted", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/set-note/{unit_id}")
|
||||||
|
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_note = unit.note
|
||||||
|
unit.note = note
|
||||||
|
unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
# Record history entry for note change
|
||||||
|
if old_note != note:
|
||||||
|
record_history(
|
||||||
|
db=db,
|
||||||
|
unit_id=unit_id,
|
||||||
|
change_type="note_change",
|
||||||
|
field_name="note",
|
||||||
|
old_value=old_note,
|
||||||
|
new_value=note,
|
||||||
|
source="manual"
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-csv")
|
||||||
|
async def import_csv(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
update_existing: bool = Form(True),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Import roster units from CSV file.
|
||||||
|
|
||||||
|
Expected CSV columns (unit_id is required, others are optional):
|
||||||
|
- unit_id: Unique identifier for the unit
|
||||||
|
- unit_type: Type of unit (default: "series3")
|
||||||
|
- deployed: Boolean for deployment status (default: False)
|
||||||
|
- retired: Boolean for retirement status (default: False)
|
||||||
|
- note: Notes about the unit
|
||||||
|
- project_id: Project identifier
|
||||||
|
- location: Location description
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file: CSV file upload
|
||||||
|
update_existing: If True, update existing units; if False, skip them
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||||
|
|
||||||
|
# Read file content
|
||||||
|
contents = await file.read()
|
||||||
|
csv_text = contents.decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
|
results = {
|
||||||
|
"added": [],
|
||||||
|
"updated": [],
|
||||||
|
"skipped": [],
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
||||||
|
try:
|
||||||
|
# Validate required field
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"error": "Missing required field: unit_id"
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check if unit exists
|
||||||
|
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if existing_unit:
|
||||||
|
if not update_existing:
|
||||||
|
results["skipped"].append(unit_id)
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update existing unit
|
||||||
|
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||||
|
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||||
|
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||||
|
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||||
|
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||||
|
existing_unit.location = row.get('location', existing_unit.location)
|
||||||
|
existing_unit.address = row.get('address', existing_unit.address)
|
||||||
|
existing_unit.coordinates = row.get('coordinates', existing_unit.coordinates)
|
||||||
|
existing_unit.last_updated = datetime.utcnow()
|
||||||
|
|
||||||
|
results["updated"].append(unit_id)
|
||||||
|
else:
|
||||||
|
# Create new unit
|
||||||
|
new_unit = RosterUnit(
|
||||||
|
id=unit_id,
|
||||||
|
unit_type=row.get('unit_type', 'series3'),
|
||||||
|
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||||
|
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||||
|
note=row.get('note', ''),
|
||||||
|
project_id=row.get('project_id'),
|
||||||
|
location=row.get('location'),
|
||||||
|
address=row.get('address'),
|
||||||
|
coordinates=row.get('coordinates'),
|
||||||
|
last_updated=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(new_unit)
|
||||||
|
results["added"].append(unit_id)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
results["errors"].append({
|
||||||
|
"row": row_num,
|
||||||
|
"unit_id": row.get('unit_id', 'unknown'),
|
||||||
|
"error": str(e)
|
||||||
|
})
|
||||||
|
|
||||||
|
# Commit all changes
|
||||||
|
try:
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "CSV import completed",
|
||||||
|
"summary": {
|
||||||
|
"added": len(results["added"]),
|
||||||
|
"updated": len(results["updated"]),
|
||||||
|
"skipped": len(results["skipped"]),
|
||||||
|
"errors": len(results["errors"])
|
||||||
|
},
|
||||||
|
"details": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ignore/{unit_id}")
|
||||||
|
def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Add a unit to the ignore list to suppress it from unknown emitters.
|
||||||
|
"""
|
||||||
|
# Check if already ignored
|
||||||
|
if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first():
|
||||||
|
raise HTTPException(status_code=400, detail="Unit already ignored")
|
||||||
|
|
||||||
|
ignored = IgnoredUnit(
|
||||||
|
id=unit_id,
|
||||||
|
reason=reason,
|
||||||
|
ignored_at=datetime.utcnow()
|
||||||
|
)
|
||||||
|
db.add(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit ignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/ignore/{unit_id}")
|
||||||
|
def unignore_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Remove a unit from the ignore list.
|
||||||
|
"""
|
||||||
|
ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first()
|
||||||
|
if not ignored:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit not in ignore list")
|
||||||
|
|
||||||
|
db.delete(ignored)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit unignored", "id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ignored")
|
||||||
|
def list_ignored_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get list of all ignored units.
|
||||||
|
"""
|
||||||
|
ignored_units = db.query(IgnoredUnit).all()
|
||||||
|
return {
|
||||||
|
"ignored": [
|
||||||
|
{
|
||||||
|
"id": unit.id,
|
||||||
|
"reason": unit.reason,
|
||||||
|
"ignored_at": unit.ignored_at.isoformat()
|
||||||
|
}
|
||||||
|
for unit in ignored_units
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/history/{unit_id}")
|
||||||
|
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get complete history timeline for a unit.
|
||||||
|
Returns all historical changes ordered by most recent first.
|
||||||
|
"""
|
||||||
|
history_entries = db.query(UnitHistory).filter(
|
||||||
|
UnitHistory.unit_id == unit_id
|
||||||
|
).order_by(UnitHistory.changed_at.desc()).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"id": entry.id,
|
||||||
|
"change_type": entry.change_type,
|
||||||
|
"field_name": entry.field_name,
|
||||||
|
"old_value": entry.old_value,
|
||||||
|
"new_value": entry.new_value,
|
||||||
|
"changed_at": entry.changed_at.isoformat(),
|
||||||
|
"source": entry.source,
|
||||||
|
"notes": entry.notes
|
||||||
|
}
|
||||||
|
for entry in history_entries
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/history/{history_id}")
|
||||||
|
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Delete a specific history entry by ID.
|
||||||
|
Allows manual cleanup of old history entries.
|
||||||
|
"""
|
||||||
|
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
|
||||||
|
if not history_entry:
|
||||||
|
raise HTTPException(status_code=404, detail="History entry not found")
|
||||||
|
|
||||||
|
db.delete(history_entry)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "History entry deleted", "id": history_id}
|
||||||
@@ -5,12 +5,13 @@ Provides endpoints for the seismograph-specific dashboard
|
|||||||
|
|
||||||
from fastapi import APIRouter, Request, Depends, Query
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
from fastapi.responses import HTMLResponse
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import RosterUnit
|
from app.seismo.models import RosterUnit
|
||||||
from backend.templates_config import templates
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/stats", response_class=HTMLResponse)
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
@@ -9,9 +9,9 @@ import io
|
|||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from app.seismo.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from app.seismo.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -477,75 +477,3 @@ async def upload_snapshot(file: UploadFile = File(...)):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
|
||||||
# SLMM SYNC ENDPOINTS
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
@router.post("/slmm/sync-all")
|
|
||||||
async def sync_all_slms(db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Manually trigger full sync of all SLM devices from Terra-View roster to SLMM.
|
|
||||||
|
|
||||||
This ensures SLMM database matches Terra-View roster (source of truth).
|
|
||||||
Also cleans up orphaned devices in SLMM that are not in Terra-View.
|
|
||||||
"""
|
|
||||||
from backend.services.slmm_sync import sync_all_slms_to_slmm, cleanup_orphaned_slmm_devices
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Sync all SLMs
|
|
||||||
sync_results = await sync_all_slms_to_slmm(db)
|
|
||||||
|
|
||||||
# Clean up orphaned devices
|
|
||||||
cleanup_results = await cleanup_orphaned_slmm_devices(db)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"sync": sync_results,
|
|
||||||
"cleanup": cleanup_results
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}")
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/slmm/status")
|
|
||||||
async def get_slmm_sync_status(db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Get status of SLMM synchronization.
|
|
||||||
|
|
||||||
Shows which devices are in Terra-View roster vs SLMM database.
|
|
||||||
"""
|
|
||||||
from backend.services.slmm_sync import get_slmm_devices
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Get devices from both systems
|
|
||||||
roster_slms = db.query(RosterUnit).filter_by(device_type="slm").all()
|
|
||||||
slmm_devices = await get_slmm_devices()
|
|
||||||
|
|
||||||
if slmm_devices is None:
|
|
||||||
raise HTTPException(status_code=503, detail="SLMM service unavailable")
|
|
||||||
|
|
||||||
roster_unit_ids = {unit.unit_type for unit in roster_slms}
|
|
||||||
slmm_unit_ids = set(slmm_devices)
|
|
||||||
|
|
||||||
# Find differences
|
|
||||||
in_roster_only = roster_unit_ids - slmm_unit_ids
|
|
||||||
in_slmm_only = slmm_unit_ids - roster_unit_ids
|
|
||||||
in_both = roster_unit_ids & slmm_unit_ids
|
|
||||||
|
|
||||||
return {
|
|
||||||
"status": "ok",
|
|
||||||
"terra_view_total": len(roster_unit_ids),
|
|
||||||
"slmm_total": len(slmm_unit_ids),
|
|
||||||
"synced": len(in_both),
|
|
||||||
"missing_from_slmm": list(in_roster_only),
|
|
||||||
"orphaned_in_slmm": list(in_slmm_only),
|
|
||||||
"in_sync": len(in_roster_only) == 0 and len(in_slmm_only) == 0
|
|
||||||
}
|
|
||||||
|
|
||||||
except HTTPException:
|
|
||||||
raise
|
|
||||||
except Exception as e:
|
|
||||||
raise HTTPException(status_code=500, detail=f"Status check failed: {str(e)}")
|
|
||||||
@@ -3,9 +3,8 @@ from sqlalchemy.orm import Session
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Dict, Any
|
from typing import Dict, Any
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from app.seismo.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
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ from pydantic import BaseModel
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
from backend.database import get_db
|
from app.seismo.database import get_db
|
||||||
from backend.models import Emitter
|
from app.seismo.models import Emitter
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
0
app/seismo/services/__init__.py
Normal file
@@ -10,7 +10,7 @@ from datetime import datetime
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from backend.services.database_backup import DatabaseBackupService
|
from app.seismo.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from backend.database import get_db_session
|
from app.seismo.database import get_db_session
|
||||||
from backend.models import Emitter, RosterUnit, IgnoredUnit
|
from app.seismo.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
|
||||||
|
|
||||||
def ensure_utc(dt):
|
def ensure_utc(dt):
|
||||||
@@ -60,7 +60,7 @@ def emit_status_snapshot():
|
|||||||
db = get_db_session()
|
db = get_db_session()
|
||||||
try:
|
try:
|
||||||
# Get user preferences for status thresholds
|
# Get user preferences for status thresholds
|
||||||
from backend.models import UserPreferences
|
from app.seismo.models import UserPreferences
|
||||||
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||||
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||||
@@ -108,7 +108,6 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
|
||||||
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
"next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None,
|
||||||
"deployed_with_modem_id": r.deployed_with_modem_id,
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
"deployed_with_unit_id": r.deployed_with_unit_id,
|
|
||||||
"ip_address": r.ip_address,
|
"ip_address": r.ip_address,
|
||||||
"phone_number": r.phone_number,
|
"phone_number": r.phone_number,
|
||||||
"hardware_model": r.hardware_model,
|
"hardware_model": r.hardware_model,
|
||||||
@@ -138,7 +137,6 @@ def emit_status_snapshot():
|
|||||||
"last_calibrated": None,
|
"last_calibrated": None,
|
||||||
"next_calibration_due": None,
|
"next_calibration_due": None,
|
||||||
"deployed_with_modem_id": None,
|
"deployed_with_modem_id": None,
|
||||||
"deployed_with_unit_id": None,
|
|
||||||
"ip_address": None,
|
"ip_address": None,
|
||||||
"phone_number": None,
|
"phone_number": None,
|
||||||
"hardware_model": None,
|
"hardware_model": None,
|
||||||
@@ -148,34 +146,6 @@ def emit_status_snapshot():
|
|||||||
"coordinates": "",
|
"coordinates": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Derive modem status from paired devices ---
|
|
||||||
# Modems don't have their own check-in system, so we inherit status
|
|
||||||
# from whatever device they're paired with (seismograph or SLM)
|
|
||||||
# Check both directions: modem.deployed_with_unit_id OR device.deployed_with_modem_id
|
|
||||||
for unit_id, unit_data in units.items():
|
|
||||||
if unit_data.get("device_type") == "modem" and not unit_data.get("retired"):
|
|
||||||
paired_unit_id = None
|
|
||||||
roster_unit = roster.get(unit_id)
|
|
||||||
|
|
||||||
# First, check if modem has deployed_with_unit_id set
|
|
||||||
if roster_unit and roster_unit.deployed_with_unit_id:
|
|
||||||
paired_unit_id = roster_unit.deployed_with_unit_id
|
|
||||||
else:
|
|
||||||
# Fallback: check if any device has this modem in deployed_with_modem_id
|
|
||||||
for other_id, other_roster in roster.items():
|
|
||||||
if other_roster.deployed_with_modem_id == unit_id:
|
|
||||||
paired_unit_id = other_id
|
|
||||||
break
|
|
||||||
|
|
||||||
if paired_unit_id:
|
|
||||||
paired_unit = units.get(paired_unit_id)
|
|
||||||
if paired_unit:
|
|
||||||
# Inherit status from paired device
|
|
||||||
unit_data["status"] = paired_unit.get("status", "Missing")
|
|
||||||
unit_data["age"] = paired_unit.get("age", "N/A")
|
|
||||||
unit_data["last"] = paired_unit.get("last")
|
|
||||||
unit_data["derived_from"] = paired_unit_id
|
|
||||||
|
|
||||||
# Separate buckets for UI
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
uid: u for uid, u in units.items()
|
||||||
1
app/slm/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# SLMM addon package for NL43 integration.
|
||||||
317
app/slm/dashboard.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""
|
||||||
|
Dashboard API endpoints for SLM/NL43 devices.
|
||||||
|
This layer aggregates and transforms data from the device API for UI consumption.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import func
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.slm.database import get_db as get_slm_db
|
||||||
|
from app.slm.models import NL43Config, NL43Status
|
||||||
|
from app.slm.services import NL43Client
|
||||||
|
# Import seismo database for roster data
|
||||||
|
from app.seismo.database import get_db as get_seismo_db
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_dashboard_stats(request: Request, db: Session = Depends(get_seismo_db)):
|
||||||
|
"""Get aggregate statistics for the SLM dashboard from roster (returns HTML)."""
|
||||||
|
# Query SLMs from the roster
|
||||||
|
slms = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="sound_level_meter",
|
||||||
|
retired=False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total_units = len(slms)
|
||||||
|
deployed = sum(1 for s in slms if s.deployed)
|
||||||
|
benched = sum(1 for s in slms if not s.deployed)
|
||||||
|
|
||||||
|
# For "active", count SLMs with recent check-ins (within last hour)
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
one_hour_ago = datetime.now(timezone.utc) - timedelta(hours=1)
|
||||||
|
active = sum(1 for s in slms if s.slm_last_check and s.slm_last_check >= one_hour_ago)
|
||||||
|
|
||||||
|
# Map to template variable names
|
||||||
|
# total_count, deployed_count, active_count, benched_count
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/slm_stats.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"total_count": total_units,
|
||||||
|
"deployed_count": deployed,
|
||||||
|
"active_count": active,
|
||||||
|
"benched_count": benched
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_units_list(request: Request, db: Session = Depends(get_seismo_db)):
|
||||||
|
"""Get list of all SLM units from roster (returns HTML)."""
|
||||||
|
# Query SLMs from the roster (not retired)
|
||||||
|
slms = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="sound_level_meter",
|
||||||
|
retired=False
|
||||||
|
).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
units = []
|
||||||
|
for slm in slms:
|
||||||
|
# Map to template field names
|
||||||
|
unit_data = {
|
||||||
|
"id": slm.id,
|
||||||
|
"slm_host": slm.slm_host,
|
||||||
|
"slm_tcp_port": slm.slm_tcp_port,
|
||||||
|
"slm_last_check": slm.slm_last_check,
|
||||||
|
"slm_model": slm.slm_model or "NL-43",
|
||||||
|
"address": slm.address,
|
||||||
|
"deployed_with_modem_id": slm.deployed_with_modem_id,
|
||||||
|
}
|
||||||
|
units.append(unit_data)
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/slm_unit_list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": units
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/live-view/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def get_live_view(unit_id: str, request: Request, slm_db: Session = Depends(get_slm_db), roster_db: Session = Depends(get_seismo_db)):
|
||||||
|
"""Get live measurement data for a specific unit (returns HTML)."""
|
||||||
|
# Get unit from roster
|
||||||
|
unit = roster_db.query(RosterUnit).filter_by(
|
||||||
|
id=unit_id,
|
||||||
|
device_type="sound_level_meter"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/slm_live_view_error.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"error": f"Unit {unit_id} not found in roster"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get status from monitoring database (may not exist yet)
|
||||||
|
status = slm_db.query(NL43Status).filter_by(unit_id=unit_id).first()
|
||||||
|
|
||||||
|
# Get modem info if available
|
||||||
|
modem = None
|
||||||
|
modem_ip = None
|
||||||
|
if unit.deployed_with_modem_id:
|
||||||
|
modem = roster_db.query(RosterUnit).filter_by(
|
||||||
|
id=unit.deployed_with_modem_id,
|
||||||
|
device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem:
|
||||||
|
modem_ip = modem.ip_address
|
||||||
|
elif unit.slm_host:
|
||||||
|
modem_ip = unit.slm_host
|
||||||
|
|
||||||
|
# Determine if measuring
|
||||||
|
is_measuring = False
|
||||||
|
if status and status.measurement_state:
|
||||||
|
is_measuring = status.measurement_state.lower() == 'start'
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/slm_live_view.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"unit": unit,
|
||||||
|
"modem": modem,
|
||||||
|
"modem_ip": modem_ip,
|
||||||
|
"current_status": status,
|
||||||
|
"is_measuring": is_measuring
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def get_unit_config(unit_id: str, request: Request, roster_db: Session = Depends(get_seismo_db)):
|
||||||
|
"""Return the HTML config form for a specific unit."""
|
||||||
|
unit = roster_db.query(RosterUnit).filter_by(
|
||||||
|
id=unit_id,
|
||||||
|
device_type="sound_level_meter"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/slm_config_form.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"unit": unit
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/config/{unit_id}")
|
||||||
|
async def update_unit_config(
|
||||||
|
unit_id: str,
|
||||||
|
request: Request,
|
||||||
|
roster_db: Session = Depends(get_seismo_db),
|
||||||
|
slm_db: Session = Depends(get_slm_db)
|
||||||
|
):
|
||||||
|
"""Update configuration for a specific unit from the form submission."""
|
||||||
|
unit = roster_db.query(RosterUnit).filter_by(
|
||||||
|
id=unit_id,
|
||||||
|
device_type="sound_level_meter"
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not unit:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
||||||
|
|
||||||
|
form = await request.form()
|
||||||
|
|
||||||
|
def get_int(value, default=None):
|
||||||
|
try:
|
||||||
|
return int(value) if value not in (None, "") else default
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return default
|
||||||
|
|
||||||
|
# Update roster fields
|
||||||
|
unit.slm_model = form.get("slm_model") or unit.slm_model
|
||||||
|
unit.slm_serial_number = form.get("slm_serial_number") or unit.slm_serial_number
|
||||||
|
unit.slm_frequency_weighting = form.get("slm_frequency_weighting") or unit.slm_frequency_weighting
|
||||||
|
unit.slm_time_weighting = form.get("slm_time_weighting") or unit.slm_time_weighting
|
||||||
|
unit.slm_measurement_range = form.get("slm_measurement_range") or unit.slm_measurement_range
|
||||||
|
|
||||||
|
unit.slm_host = form.get("slm_host") or None
|
||||||
|
unit.slm_tcp_port = get_int(form.get("slm_tcp_port"), unit.slm_tcp_port or 2255)
|
||||||
|
unit.slm_ftp_port = get_int(form.get("slm_ftp_port"), unit.slm_ftp_port or 21)
|
||||||
|
|
||||||
|
deployed_with_modem_id = form.get("deployed_with_modem_id") or None
|
||||||
|
unit.deployed_with_modem_id = deployed_with_modem_id
|
||||||
|
|
||||||
|
roster_db.commit()
|
||||||
|
roster_db.refresh(unit)
|
||||||
|
|
||||||
|
# Update or create NL43 config so SLMM can reach the device
|
||||||
|
config = slm_db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not config:
|
||||||
|
config = NL43Config(unit_id=unit_id)
|
||||||
|
slm_db.add(config)
|
||||||
|
|
||||||
|
# Resolve host from modem if present, otherwise fall back to direct IP or existing config
|
||||||
|
host_for_config = None
|
||||||
|
if deployed_with_modem_id:
|
||||||
|
modem = roster_db.query(RosterUnit).filter_by(
|
||||||
|
id=deployed_with_modem_id,
|
||||||
|
device_type="modem"
|
||||||
|
).first()
|
||||||
|
if modem and modem.ip_address:
|
||||||
|
host_for_config = modem.ip_address
|
||||||
|
if not host_for_config:
|
||||||
|
host_for_config = unit.slm_host or config.host or "127.0.0.1"
|
||||||
|
|
||||||
|
config.host = host_for_config
|
||||||
|
config.tcp_port = get_int(form.get("slm_tcp_port"), config.tcp_port or 2255)
|
||||||
|
config.tcp_enabled = True
|
||||||
|
config.ftp_enabled = bool(config.ftp_username and config.ftp_password)
|
||||||
|
|
||||||
|
slm_db.commit()
|
||||||
|
slm_db.refresh(config)
|
||||||
|
|
||||||
|
return {"success": True, "unit_id": unit_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/control/{unit_id}/{action}")
|
||||||
|
async def control_unit(unit_id: str, action: str, db: Session = Depends(get_slm_db)):
|
||||||
|
"""Send control command to a unit (start, stop, pause, resume, etc.)."""
|
||||||
|
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
||||||
|
|
||||||
|
if not config.tcp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
|
||||||
|
|
||||||
|
# Create NL43Client
|
||||||
|
client = NL43Client(
|
||||||
|
host=config.host,
|
||||||
|
port=config.tcp_port,
|
||||||
|
timeout=5.0,
|
||||||
|
ftp_username=config.ftp_username,
|
||||||
|
ftp_password=config.ftp_password
|
||||||
|
)
|
||||||
|
|
||||||
|
# Map action to command
|
||||||
|
action_map = {
|
||||||
|
"start": "start_measurement",
|
||||||
|
"stop": "stop_measurement",
|
||||||
|
"pause": "pause_measurement",
|
||||||
|
"resume": "resume_measurement",
|
||||||
|
"reset": "reset_measurement",
|
||||||
|
"sleep": "sleep_mode",
|
||||||
|
"wake": "wake_from_sleep",
|
||||||
|
}
|
||||||
|
|
||||||
|
if action not in action_map:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Unknown action: {action}")
|
||||||
|
|
||||||
|
method_name = action_map[action]
|
||||||
|
method = getattr(client, method_name, None)
|
||||||
|
|
||||||
|
if not method:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Method {method_name} not implemented")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await method()
|
||||||
|
return {"success": True, "action": action, "result": result}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error executing {action} on {unit_id}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/test-modem/{unit_id}")
|
||||||
|
async def test_modem(unit_id: str, db: Session = Depends(get_slm_db)):
|
||||||
|
"""Test connectivity to a unit's modem/device."""
|
||||||
|
config = db.query(NL43Config).filter_by(unit_id=unit_id).first()
|
||||||
|
if not config:
|
||||||
|
raise HTTPException(status_code=404, detail="Unit configuration not found")
|
||||||
|
|
||||||
|
if not config.tcp_enabled:
|
||||||
|
raise HTTPException(status_code=400, detail="TCP control not enabled for this unit")
|
||||||
|
|
||||||
|
client = NL43Client(
|
||||||
|
host=config.host,
|
||||||
|
port=config.tcp_port,
|
||||||
|
timeout=5.0,
|
||||||
|
ftp_username=config.ftp_username,
|
||||||
|
ftp_password=config.ftp_password
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to get measurement state as a connectivity test
|
||||||
|
state = await client.get_measurement_state()
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"host": config.host,
|
||||||
|
"port": config.tcp_port,
|
||||||
|
"reachable": True,
|
||||||
|
"measurement_state": state
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Modem test failed for {unit_id}: {e}")
|
||||||
|
return {
|
||||||
|
"success": False,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"host": config.host,
|
||||||
|
"port": config.tcp_port,
|
||||||
|
"reachable": False,
|
||||||
|
"error": str(e)
|
||||||
|
}
|
||||||
27
app/slm/database.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Ensure data directory exists for the SLMM addon
|
||||||
|
os.makedirs("data", exist_ok=True)
|
||||||
|
|
||||||
|
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/slmm.db"
|
||||||
|
|
||||||
|
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
"""Dependency for database sessions."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db_session():
|
||||||
|
"""Get a database session directly (not as a dependency)."""
|
||||||
|
return SessionLocal()
|
||||||
116
app/slm/main.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
|
from app.slm.database import Base, engine
|
||||||
|
from app.slm import routers
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.StreamHandler(),
|
||||||
|
logging.FileHandler("data/slmm.log"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Ensure database tables exist for the addon
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
logger.info("Database tables initialized")
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title="SLMM NL43 Addon",
|
||||||
|
description="Standalone module for NL43 configuration and status APIs",
|
||||||
|
version="0.1.0",
|
||||||
|
)
|
||||||
|
|
||||||
|
# CORS configuration - use environment variable for allowed origins
|
||||||
|
# Default to "*" for development, but should be restricted in production
|
||||||
|
allowed_origins = os.getenv("CORS_ORIGINS", "*").split(",")
|
||||||
|
logger.info(f"CORS allowed origins: {allowed_origins}")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=allowed_origins,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
templates = Jinja2Templates(directory="templates")
|
||||||
|
|
||||||
|
app.include_router(routers.router)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/", response_class=HTMLResponse)
|
||||||
|
def index(request: Request):
|
||||||
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
"""Basic health check endpoint."""
|
||||||
|
return {"status": "ok", "service": "slmm-nl43-addon"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health/devices")
|
||||||
|
async def health_devices():
|
||||||
|
"""Enhanced health check that tests device connectivity."""
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.slm.database import SessionLocal
|
||||||
|
from app.slm.services import NL43Client
|
||||||
|
from app.slm.models import NL43Config
|
||||||
|
|
||||||
|
db: Session = SessionLocal()
|
||||||
|
device_status = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
configs = db.query(NL43Config).filter_by(tcp_enabled=True).all()
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
client = NL43Client(cfg.host, cfg.tcp_port, timeout=2.0, ftp_username=cfg.ftp_username, ftp_password=cfg.ftp_password)
|
||||||
|
status = {
|
||||||
|
"unit_id": cfg.unit_id,
|
||||||
|
"host": cfg.host,
|
||||||
|
"port": cfg.tcp_port,
|
||||||
|
"reachable": False,
|
||||||
|
"error": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Try to connect (don't send command to avoid rate limiting issues)
|
||||||
|
import asyncio
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(cfg.host, cfg.tcp_port), timeout=2.0
|
||||||
|
)
|
||||||
|
writer.close()
|
||||||
|
await writer.wait_closed()
|
||||||
|
status["reachable"] = True
|
||||||
|
except Exception as e:
|
||||||
|
status["error"] = str(type(e).__name__)
|
||||||
|
logger.warning(f"Device {cfg.unit_id} health check failed: {e}")
|
||||||
|
|
||||||
|
device_status.append(status)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
all_reachable = all(d["reachable"] for d in device_status) if device_status else True
|
||||||
|
|
||||||
|
return {
|
||||||
|
"status": "ok" if all_reachable else "degraded",
|
||||||
|
"devices": device_status,
|
||||||
|
"total_devices": len(device_status),
|
||||||
|
"reachable_devices": sum(1 for d in device_status if d["reachable"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
|
||||||
|
uvicorn.run("app.main:app", host="0.0.0.0", port=int(os.getenv("PORT", "8100")), reload=True)
|
||||||
43
app/slm/models.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from sqlalchemy import Column, String, DateTime, Boolean, Integer, Text, func
|
||||||
|
from app.slm.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Config(Base):
|
||||||
|
"""
|
||||||
|
NL43 connection/config metadata for the standalone SLMM addon.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_config"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
host = Column(String, default="127.0.0.1")
|
||||||
|
tcp_port = Column(Integer, default=80) # NL43 TCP control port (via RX55)
|
||||||
|
tcp_enabled = Column(Boolean, default=True)
|
||||||
|
ftp_enabled = Column(Boolean, default=False)
|
||||||
|
ftp_username = Column(String, nullable=True) # FTP login username
|
||||||
|
ftp_password = Column(String, nullable=True) # FTP login password
|
||||||
|
web_enabled = Column(Boolean, default=False)
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Status(Base):
|
||||||
|
"""
|
||||||
|
Latest NL43 status snapshot for quick dashboard/API access.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "nl43_status"
|
||||||
|
|
||||||
|
unit_id = Column(String, primary_key=True, index=True)
|
||||||
|
last_seen = Column(DateTime, default=func.now())
|
||||||
|
measurement_state = Column(String, default="unknown") # Measure/Stop
|
||||||
|
measurement_start_time = Column(DateTime, nullable=True) # When measurement started (UTC)
|
||||||
|
counter = Column(String, nullable=True) # d0: Measurement interval counter (1-600)
|
||||||
|
lp = Column(String, nullable=True) # Instantaneous sound pressure level
|
||||||
|
leq = Column(String, nullable=True) # Equivalent continuous sound level
|
||||||
|
lmax = Column(String, nullable=True) # Maximum level
|
||||||
|
lmin = Column(String, nullable=True) # Minimum level
|
||||||
|
lpeak = Column(String, nullable=True) # Peak level
|
||||||
|
battery_level = Column(String, nullable=True)
|
||||||
|
power_source = Column(String, nullable=True)
|
||||||
|
sd_remaining_mb = Column(String, nullable=True)
|
||||||
|
sd_free_ratio = Column(String, nullable=True)
|
||||||
|
raw_payload = Column(Text, nullable=True)
|
||||||
1333
app/slm/routers.py
Normal file
828
app/slm/services.py
Normal file
@@ -0,0 +1,828 @@
|
|||||||
|
"""
|
||||||
|
NL43 TCP connector and snapshot persistence.
|
||||||
|
|
||||||
|
Implements simple per-request TCP calls to avoid long-lived socket complexity.
|
||||||
|
Extend to pooled connections/DRD streaming later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import contextlib
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from ftplib import FTP
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.slm.models import NL43Status
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NL43Snapshot:
|
||||||
|
unit_id: str
|
||||||
|
measurement_state: str = "unknown"
|
||||||
|
counter: Optional[str] = None # d0: Measurement interval counter (1-600)
|
||||||
|
lp: Optional[str] = None # Instantaneous sound pressure level
|
||||||
|
leq: Optional[str] = None # Equivalent continuous sound level
|
||||||
|
lmax: Optional[str] = None # Maximum level
|
||||||
|
lmin: Optional[str] = None # Minimum level
|
||||||
|
lpeak: Optional[str] = None # Peak level
|
||||||
|
battery_level: Optional[str] = None
|
||||||
|
power_source: Optional[str] = None
|
||||||
|
sd_remaining_mb: Optional[str] = None
|
||||||
|
sd_free_ratio: Optional[str] = None
|
||||||
|
raw_payload: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def persist_snapshot(s: NL43Snapshot, db: Session):
|
||||||
|
"""Persist the latest snapshot for API/dashboard use."""
|
||||||
|
try:
|
||||||
|
row = db.query(NL43Status).filter_by(unit_id=s.unit_id).first()
|
||||||
|
if not row:
|
||||||
|
row = NL43Status(unit_id=s.unit_id)
|
||||||
|
db.add(row)
|
||||||
|
|
||||||
|
row.last_seen = datetime.utcnow()
|
||||||
|
|
||||||
|
# Track measurement start time by detecting state transition
|
||||||
|
previous_state = row.measurement_state
|
||||||
|
new_state = s.measurement_state
|
||||||
|
|
||||||
|
logger.info(f"State transition check for {s.unit_id}: '{previous_state}' -> '{new_state}'")
|
||||||
|
|
||||||
|
# Device returns "Start" when measuring, "Stop" when stopped
|
||||||
|
# Normalize to previous behavior for backward compatibility
|
||||||
|
is_measuring = new_state == "Start"
|
||||||
|
was_measuring = previous_state == "Start"
|
||||||
|
|
||||||
|
if not was_measuring and is_measuring:
|
||||||
|
# Measurement just started - record the start time
|
||||||
|
row.measurement_start_time = datetime.utcnow()
|
||||||
|
logger.info(f"✓ Measurement started on {s.unit_id} at {row.measurement_start_time}")
|
||||||
|
elif was_measuring and not is_measuring:
|
||||||
|
# Measurement stopped - clear the start time
|
||||||
|
row.measurement_start_time = None
|
||||||
|
logger.info(f"✓ Measurement stopped on {s.unit_id}")
|
||||||
|
|
||||||
|
row.measurement_state = new_state
|
||||||
|
row.counter = s.counter
|
||||||
|
row.lp = s.lp
|
||||||
|
row.leq = s.leq
|
||||||
|
row.lmax = s.lmax
|
||||||
|
row.lmin = s.lmin
|
||||||
|
row.lpeak = s.lpeak
|
||||||
|
row.battery_level = s.battery_level
|
||||||
|
row.power_source = s.power_source
|
||||||
|
row.sd_remaining_mb = s.sd_remaining_mb
|
||||||
|
row.sd_free_ratio = s.sd_free_ratio
|
||||||
|
row.raw_payload = s.raw_payload
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error(f"Failed to persist snapshot for unit {s.unit_id}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
# Rate limiting: NL43 requires ≥1 second between commands
|
||||||
|
_last_command_time = {}
|
||||||
|
_rate_limit_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class NL43Client:
|
||||||
|
def __init__(self, host: str, port: int, timeout: float = 5.0, ftp_username: str = None, ftp_password: str = None):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.timeout = timeout
|
||||||
|
self.ftp_username = ftp_username or "anonymous"
|
||||||
|
self.ftp_password = ftp_password or ""
|
||||||
|
self.device_key = f"{host}:{port}"
|
||||||
|
|
||||||
|
async def _enforce_rate_limit(self):
|
||||||
|
"""Ensure ≥1 second between commands to the same device."""
|
||||||
|
async with _rate_limit_lock:
|
||||||
|
last_time = _last_command_time.get(self.device_key, 0)
|
||||||
|
elapsed = time.time() - last_time
|
||||||
|
if elapsed < 1.0:
|
||||||
|
wait_time = 1.0 - elapsed
|
||||||
|
logger.debug(f"Rate limiting: waiting {wait_time:.2f}s for {self.device_key}")
|
||||||
|
await asyncio.sleep(wait_time)
|
||||||
|
_last_command_time[self.device_key] = time.time()
|
||||||
|
|
||||||
|
async def _send_command(self, cmd: str) -> str:
|
||||||
|
"""Send ASCII command to NL43 device via TCP.
|
||||||
|
|
||||||
|
NL43 protocol returns two lines for query commands:
|
||||||
|
Line 1: Result code (R+0000 for success, error codes otherwise)
|
||||||
|
Line 2: Actual data (for query commands ending with '?')
|
||||||
|
"""
|
||||||
|
await self._enforce_rate_limit()
|
||||||
|
|
||||||
|
logger.info(f"Sending command to {self.device_key}: {cmd.strip()}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Connection timeout to {self.device_key}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Connection failed to {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer.write(cmd.encode("ascii"))
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Read first line (result code)
|
||||||
|
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
result_code = first_line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
# Remove leading $ prompt if present
|
||||||
|
if result_code.startswith("$"):
|
||||||
|
result_code = result_code[1:].strip()
|
||||||
|
|
||||||
|
logger.info(f"Result code from {self.device_key}: {result_code}")
|
||||||
|
|
||||||
|
# Check result code
|
||||||
|
if result_code == "R+0000":
|
||||||
|
# Success - for query commands, read the second line with actual data
|
||||||
|
is_query = cmd.strip().endswith("?")
|
||||||
|
if is_query:
|
||||||
|
data_line = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
response = data_line.decode(errors="ignore").strip()
|
||||||
|
logger.debug(f"Data line from {self.device_key}: {response}")
|
||||||
|
return response
|
||||||
|
else:
|
||||||
|
# Setting command - return success code
|
||||||
|
return result_code
|
||||||
|
elif result_code == "R+0001":
|
||||||
|
raise ValueError("Command error - device did not recognize command")
|
||||||
|
elif result_code == "R+0002":
|
||||||
|
raise ValueError("Parameter error - invalid parameter value")
|
||||||
|
elif result_code == "R+0003":
|
||||||
|
raise ValueError("Spec/type error - command not supported by this device model")
|
||||||
|
elif result_code == "R+0004":
|
||||||
|
raise ValueError("Status error - device is in wrong state for this command")
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unknown result code: {result_code}")
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"Response timeout from {self.device_key}")
|
||||||
|
raise TimeoutError(f"Device did not respond within {self.timeout}s")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Communication error with {self.device_key}: {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
async def request_dod(self) -> NL43Snapshot:
|
||||||
|
"""Request DOD (Data Output Display) snapshot from device.
|
||||||
|
|
||||||
|
Returns parsed measurement data from the device display.
|
||||||
|
"""
|
||||||
|
# _send_command now handles result code validation and returns the data line
|
||||||
|
resp = await self._send_command("DOD?\r\n")
|
||||||
|
|
||||||
|
# Validate response format
|
||||||
|
if not resp:
|
||||||
|
logger.warning(f"Empty data response from DOD command on {self.device_key}")
|
||||||
|
raise ValueError("Device returned empty data for DOD? command")
|
||||||
|
|
||||||
|
# Remove leading $ prompt if present (shouldn't be there after _send_command, but be safe)
|
||||||
|
if resp.startswith("$"):
|
||||||
|
resp = resp[1:].strip()
|
||||||
|
|
||||||
|
parts = [p.strip() for p in resp.split(",") if p.strip() != ""]
|
||||||
|
|
||||||
|
# DOD should return at least some data points
|
||||||
|
if len(parts) < 2:
|
||||||
|
logger.error(f"Malformed DOD data from {self.device_key}: {resp}")
|
||||||
|
raise ValueError(f"Malformed DOD data: expected comma-separated values, got: {resp}")
|
||||||
|
|
||||||
|
logger.info(f"Parsed {len(parts)} data points from DOD response")
|
||||||
|
|
||||||
|
# Query actual measurement state (DOD doesn't include this information)
|
||||||
|
try:
|
||||||
|
measurement_state = await self.get_measurement_state()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to get measurement state, defaulting to 'Measure': {e}")
|
||||||
|
measurement_state = "Measure"
|
||||||
|
|
||||||
|
snap = NL43Snapshot(unit_id="", raw_payload=resp, measurement_state=measurement_state)
|
||||||
|
|
||||||
|
# Parse known positions (based on NL43 communication guide - DRD format)
|
||||||
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
|
try:
|
||||||
|
# Capture d0 (counter) for timer synchronization
|
||||||
|
if len(parts) >= 1:
|
||||||
|
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
|
if len(parts) >= 3:
|
||||||
|
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
||||||
|
if len(parts) >= 4:
|
||||||
|
snap.lmax = parts[3] # d3: Maximum level
|
||||||
|
if len(parts) >= 5:
|
||||||
|
snap.lmin = parts[4] # d4: Minimum level
|
||||||
|
if len(parts) >= 6:
|
||||||
|
snap.lpeak = parts[5] # d5: Peak level
|
||||||
|
except (IndexError, ValueError) as e:
|
||||||
|
logger.warning(f"Error parsing DOD data points: {e}")
|
||||||
|
|
||||||
|
return snap
|
||||||
|
|
||||||
|
async def start(self):
|
||||||
|
"""Start measurement on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: Measure,Start (no $ prefix, capitalized param)
|
||||||
|
"""
|
||||||
|
await self._send_command("Measure,Start\r\n")
|
||||||
|
|
||||||
|
async def stop(self):
|
||||||
|
"""Stop measurement on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: Measure,Stop (no $ prefix, capitalized param)
|
||||||
|
"""
|
||||||
|
await self._send_command("Measure,Stop\r\n")
|
||||||
|
|
||||||
|
async def set_store_mode_manual(self):
|
||||||
|
"""Set the device to Manual Store mode.
|
||||||
|
|
||||||
|
According to NL43 protocol: Store Mode,Manual sets manual storage mode
|
||||||
|
"""
|
||||||
|
await self._send_command("Store Mode,Manual\r\n")
|
||||||
|
logger.info(f"Store mode set to Manual on {self.device_key}")
|
||||||
|
|
||||||
|
async def manual_store(self):
|
||||||
|
"""Manually store the current measurement data.
|
||||||
|
|
||||||
|
According to NL43 protocol: Manual Store,Start executes storing
|
||||||
|
Parameter p1="Start" executes the storage operation
|
||||||
|
Device must be in Manual Store mode first
|
||||||
|
"""
|
||||||
|
await self._send_command("Manual Store,Start\r\n")
|
||||||
|
logger.info(f"Manual store executed on {self.device_key}")
|
||||||
|
|
||||||
|
async def pause(self):
|
||||||
|
"""Pause the current measurement."""
|
||||||
|
await self._send_command("Pause,On\r\n")
|
||||||
|
logger.info(f"Measurement paused on {self.device_key}")
|
||||||
|
|
||||||
|
async def resume(self):
|
||||||
|
"""Resume a paused measurement."""
|
||||||
|
await self._send_command("Pause,Off\r\n")
|
||||||
|
logger.info(f"Measurement resumed on {self.device_key}")
|
||||||
|
|
||||||
|
async def reset(self):
|
||||||
|
"""Reset the measurement data."""
|
||||||
|
await self._send_command("Reset\r\n")
|
||||||
|
logger.info(f"Measurement data reset on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_measurement_state(self) -> str:
|
||||||
|
"""Get the current measurement state.
|
||||||
|
|
||||||
|
Returns: "Start" if measuring, "Stop" if stopped
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Measure?\r\n")
|
||||||
|
state = resp.strip()
|
||||||
|
logger.info(f"Measurement state on {self.device_key}: {state}")
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def get_battery_level(self) -> str:
|
||||||
|
"""Get the battery level."""
|
||||||
|
resp = await self._send_command("Battery Level?\r\n")
|
||||||
|
logger.info(f"Battery level on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_clock(self) -> str:
|
||||||
|
"""Get the device clock time."""
|
||||||
|
resp = await self._send_command("Clock?\r\n")
|
||||||
|
logger.info(f"Clock on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_clock(self, datetime_str: str):
|
||||||
|
"""Set the device clock time.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
datetime_str: Time in format YYYY/MM/DD,HH:MM:SS or YYYY/MM/DD HH:MM:SS
|
||||||
|
"""
|
||||||
|
# Device expects format: Clock,YYYY/MM/DD HH:MM:SS (space between date and time)
|
||||||
|
# Replace comma with space if present to normalize format
|
||||||
|
normalized = datetime_str.replace(',', ' ', 1)
|
||||||
|
await self._send_command(f"Clock,{normalized}\r\n")
|
||||||
|
logger.info(f"Clock set on {self.device_key} to {normalized}")
|
||||||
|
|
||||||
|
async def get_frequency_weighting(self, channel: str = "Main") -> str:
|
||||||
|
"""Get frequency weighting (A, C, Z, etc.).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
resp = await self._send_command(f"Frequency Weighting ({channel})?\r\n")
|
||||||
|
logger.info(f"Frequency weighting ({channel}) on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_frequency_weighting(self, weighting: str, channel: str = "Main"):
|
||||||
|
"""Set frequency weighting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weighting: A, C, or Z
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Frequency Weighting ({channel}),{weighting}\r\n")
|
||||||
|
logger.info(f"Frequency weighting ({channel}) set to {weighting} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_time_weighting(self, channel: str = "Main") -> str:
|
||||||
|
"""Get time weighting (F, S, I).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
resp = await self._send_command(f"Time Weighting ({channel})?\r\n")
|
||||||
|
logger.info(f"Time weighting ({channel}) on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_time_weighting(self, weighting: str, channel: str = "Main"):
|
||||||
|
"""Set time weighting.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weighting: F (Fast), S (Slow), or I (Impulse)
|
||||||
|
channel: Main, Sub1, Sub2, or Sub3
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Time Weighting ({channel}),{weighting}\r\n")
|
||||||
|
logger.info(f"Time weighting ({channel}) set to {weighting} on {self.device_key}")
|
||||||
|
|
||||||
|
async def request_dlc(self) -> dict:
|
||||||
|
"""Request DLC (Data Last Calculation) - final stored measurement results.
|
||||||
|
|
||||||
|
This retrieves the complete calculation results from the last/current measurement,
|
||||||
|
including all statistical data. Similar to DOD but for final results.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with parsed DLC data
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("DLC?\r\n")
|
||||||
|
logger.info(f"DLC data received from {self.device_key}: {resp[:100]}...")
|
||||||
|
|
||||||
|
# Parse DLC response - similar format to DOD
|
||||||
|
# The exact format depends on device configuration
|
||||||
|
# For now, return raw data - can be enhanced based on actual response format
|
||||||
|
return {
|
||||||
|
"raw_data": resp.strip(),
|
||||||
|
"device_key": self.device_key,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def sleep(self):
|
||||||
|
"""Put the device into sleep mode to conserve battery.
|
||||||
|
|
||||||
|
Sleep mode is useful for battery conservation between scheduled measurements.
|
||||||
|
Device can be woken up remotely via TCP command or by pressing a button.
|
||||||
|
"""
|
||||||
|
await self._send_command("Sleep Mode,On\r\n")
|
||||||
|
logger.info(f"Device {self.device_key} entering sleep mode")
|
||||||
|
|
||||||
|
async def wake(self):
|
||||||
|
"""Wake the device from sleep mode.
|
||||||
|
|
||||||
|
Note: This may not work if the device is in deep sleep.
|
||||||
|
Physical button press might be required in some cases.
|
||||||
|
"""
|
||||||
|
await self._send_command("Sleep Mode,Off\r\n")
|
||||||
|
logger.info(f"Device {self.device_key} waking from sleep mode")
|
||||||
|
|
||||||
|
async def get_sleep_status(self) -> str:
|
||||||
|
"""Get the current sleep mode status."""
|
||||||
|
resp = await self._send_command("Sleep Mode?\r\n")
|
||||||
|
logger.info(f"Sleep mode status on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def stream_drd(self, callback):
|
||||||
|
"""Stream continuous DRD output from the device.
|
||||||
|
|
||||||
|
Opens a persistent connection and streams DRD data lines.
|
||||||
|
Calls the provided callback function with each parsed snapshot.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
callback: Async function that receives NL43Snapshot objects
|
||||||
|
|
||||||
|
The stream continues until an exception occurs or the connection is closed.
|
||||||
|
Send SUB character (0x1A) to stop the stream.
|
||||||
|
"""
|
||||||
|
await self._enforce_rate_limit()
|
||||||
|
|
||||||
|
logger.info(f"Starting DRD stream for {self.device_key}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
reader, writer = await asyncio.wait_for(
|
||||||
|
asyncio.open_connection(self.host, self.port), timeout=self.timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.error(f"DRD stream connection timeout to {self.device_key}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device at {self.host}:{self.port}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"DRD stream connection failed to {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"Failed to connect to device: {str(e)}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Start DRD streaming
|
||||||
|
writer.write(b"DRD?\r\n")
|
||||||
|
await writer.drain()
|
||||||
|
|
||||||
|
# Read initial result code
|
||||||
|
first_line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=self.timeout)
|
||||||
|
result_code = first_line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
if result_code.startswith("$"):
|
||||||
|
result_code = result_code[1:].strip()
|
||||||
|
|
||||||
|
logger.debug(f"DRD stream result code from {self.device_key}: {result_code}")
|
||||||
|
|
||||||
|
if result_code != "R+0000":
|
||||||
|
raise ValueError(f"DRD stream failed to start: {result_code}")
|
||||||
|
|
||||||
|
logger.info(f"DRD stream started successfully for {self.device_key}")
|
||||||
|
|
||||||
|
# Continuously read data lines
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
line_data = await asyncio.wait_for(reader.readuntil(b"\n"), timeout=30.0)
|
||||||
|
line = line_data.decode(errors="ignore").strip()
|
||||||
|
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Remove leading $ if present
|
||||||
|
if line.startswith("$"):
|
||||||
|
line = line[1:].strip()
|
||||||
|
|
||||||
|
# Parse the DRD data (same format as DOD)
|
||||||
|
parts = [p.strip() for p in line.split(",") if p.strip() != ""]
|
||||||
|
|
||||||
|
if len(parts) < 2:
|
||||||
|
logger.warning(f"Malformed DRD data from {self.device_key}: {line}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
snap = NL43Snapshot(unit_id="", raw_payload=line, measurement_state="Measure")
|
||||||
|
|
||||||
|
# Parse known positions (DRD format - same as DOD)
|
||||||
|
# DRD format: d0=counter, d1=Lp, d2=Leq, d3=Lmax, d4=Lmin, d5=Lpeak, d6=LIeq, ...
|
||||||
|
try:
|
||||||
|
# Capture d0 (counter) for timer synchronization
|
||||||
|
if len(parts) >= 1:
|
||||||
|
snap.counter = parts[0] # d0: Measurement interval counter (1-600)
|
||||||
|
if len(parts) >= 2:
|
||||||
|
snap.lp = parts[1] # d1: Instantaneous sound pressure level
|
||||||
|
if len(parts) >= 3:
|
||||||
|
snap.leq = parts[2] # d2: Equivalent continuous sound level
|
||||||
|
if len(parts) >= 4:
|
||||||
|
snap.lmax = parts[3] # d3: Maximum level
|
||||||
|
if len(parts) >= 5:
|
||||||
|
snap.lmin = parts[4] # d4: Minimum level
|
||||||
|
if len(parts) >= 6:
|
||||||
|
snap.lpeak = parts[5] # d5: Peak level
|
||||||
|
except (IndexError, ValueError) as e:
|
||||||
|
logger.warning(f"Error parsing DRD data points: {e}")
|
||||||
|
|
||||||
|
# Call the callback with the snapshot
|
||||||
|
await callback(snap)
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning(f"DRD stream timeout (no data for 30s) from {self.device_key}")
|
||||||
|
break
|
||||||
|
except asyncio.IncompleteReadError:
|
||||||
|
logger.info(f"DRD stream closed by device {self.device_key}")
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
# Send SUB character to stop streaming
|
||||||
|
try:
|
||||||
|
writer.write(b"\x1A")
|
||||||
|
await writer.drain()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
with contextlib.suppress(Exception):
|
||||||
|
await writer.wait_closed()
|
||||||
|
|
||||||
|
logger.info(f"DRD stream ended for {self.device_key}")
|
||||||
|
|
||||||
|
async def set_measurement_time(self, preset: str):
|
||||||
|
"""Set measurement time preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Time preset (10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Measurement Time Preset Manual,{preset}\r\n")
|
||||||
|
logger.info(f"Set measurement time to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_measurement_time(self) -> str:
|
||||||
|
"""Get current measurement time preset.
|
||||||
|
|
||||||
|
Returns: Current time preset setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Measurement Time Preset Manual?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_leq_interval(self, preset: str):
|
||||||
|
"""Set Leq calculation interval preset.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Interval preset (Off, 10s, 1m, 5m, 10m, 15m, 30m, 1h, 8h, 24h, or custom like "00:05:30")
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Leq Calculation Interval Preset,{preset}\r\n")
|
||||||
|
logger.info(f"Set Leq interval to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_leq_interval(self) -> str:
|
||||||
|
"""Get current Leq calculation interval preset.
|
||||||
|
|
||||||
|
Returns: Current interval preset setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Leq Calculation Interval Preset?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_lp_interval(self, preset: str):
|
||||||
|
"""Set Lp store interval.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
preset: Store interval (Off, 10ms, 25ms, 100ms, 200ms, 1s)
|
||||||
|
"""
|
||||||
|
await self._send_command(f"Lp Store Interval,{preset}\r\n")
|
||||||
|
logger.info(f"Set Lp interval to {preset} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_lp_interval(self) -> str:
|
||||||
|
"""Get current Lp store interval.
|
||||||
|
|
||||||
|
Returns: Current store interval setting
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Lp Store Interval?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def set_index_number(self, index: int):
|
||||||
|
"""Set index number for file numbering (Store Name).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
index: Index number (0000-9999)
|
||||||
|
"""
|
||||||
|
if not 0 <= index <= 9999:
|
||||||
|
raise ValueError("Index must be between 0000 and 9999")
|
||||||
|
await self._send_command(f"Store Name,{index:04d}\r\n")
|
||||||
|
logger.info(f"Set store name (index) to {index:04d} on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_index_number(self) -> str:
|
||||||
|
"""Get current index number (Store Name).
|
||||||
|
|
||||||
|
Returns: Current index number
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Store Name?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_overwrite_status(self) -> str:
|
||||||
|
"""Check if saved data exists at current store target.
|
||||||
|
|
||||||
|
This command checks whether saved data exists in the set store target
|
||||||
|
(store mode / store name / store address). Use this before storing
|
||||||
|
to prevent accidentally overwriting data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"None" - No data exists (safe to store)
|
||||||
|
"Exist" - Data exists (would overwrite)
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("Overwrite?\r\n")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def get_all_settings(self) -> dict:
|
||||||
|
"""Query all device settings for verification.
|
||||||
|
|
||||||
|
Returns: Dictionary with all current device settings
|
||||||
|
"""
|
||||||
|
settings = {}
|
||||||
|
|
||||||
|
# Measurement settings
|
||||||
|
try:
|
||||||
|
settings["measurement_state"] = await self.get_measurement_state()
|
||||||
|
except Exception as e:
|
||||||
|
settings["measurement_state"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["frequency_weighting"] = await self.get_frequency_weighting()
|
||||||
|
except Exception as e:
|
||||||
|
settings["frequency_weighting"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["time_weighting"] = await self.get_time_weighting()
|
||||||
|
except Exception as e:
|
||||||
|
settings["time_weighting"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Timing/interval settings
|
||||||
|
try:
|
||||||
|
settings["measurement_time"] = await self.get_measurement_time()
|
||||||
|
except Exception as e:
|
||||||
|
settings["measurement_time"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["leq_interval"] = await self.get_leq_interval()
|
||||||
|
except Exception as e:
|
||||||
|
settings["leq_interval"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["lp_interval"] = await self.get_lp_interval()
|
||||||
|
except Exception as e:
|
||||||
|
settings["lp_interval"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["index_number"] = await self.get_index_number()
|
||||||
|
except Exception as e:
|
||||||
|
settings["index_number"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Device info
|
||||||
|
try:
|
||||||
|
settings["battery_level"] = await self.get_battery_level()
|
||||||
|
except Exception as e:
|
||||||
|
settings["battery_level"] = f"Error: {e}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
settings["clock"] = await self.get_clock()
|
||||||
|
except Exception as e:
|
||||||
|
settings["clock"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# Sleep mode
|
||||||
|
try:
|
||||||
|
settings["sleep_mode"] = await self.get_sleep_status()
|
||||||
|
except Exception as e:
|
||||||
|
settings["sleep_mode"] = f"Error: {e}"
|
||||||
|
|
||||||
|
# FTP status
|
||||||
|
try:
|
||||||
|
settings["ftp_status"] = await self.get_ftp_status()
|
||||||
|
except Exception as e:
|
||||||
|
settings["ftp_status"] = f"Error: {e}"
|
||||||
|
|
||||||
|
logger.info(f"Retrieved all settings for {self.device_key}")
|
||||||
|
return settings
|
||||||
|
|
||||||
|
async def enable_ftp(self):
|
||||||
|
"""Enable FTP server on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: FTP,On enables the FTP server
|
||||||
|
"""
|
||||||
|
await self._send_command("FTP,On\r\n")
|
||||||
|
logger.info(f"FTP enabled on {self.device_key}")
|
||||||
|
|
||||||
|
async def disable_ftp(self):
|
||||||
|
"""Disable FTP server on the device.
|
||||||
|
|
||||||
|
According to NL43 protocol: FTP,Off disables the FTP server
|
||||||
|
"""
|
||||||
|
await self._send_command("FTP,Off\r\n")
|
||||||
|
logger.info(f"FTP disabled on {self.device_key}")
|
||||||
|
|
||||||
|
async def get_ftp_status(self) -> str:
|
||||||
|
"""Query FTP server status on the device.
|
||||||
|
|
||||||
|
Returns: "On" or "Off"
|
||||||
|
"""
|
||||||
|
resp = await self._send_command("FTP?\r\n")
|
||||||
|
logger.info(f"FTP status on {self.device_key}: {resp}")
|
||||||
|
return resp.strip()
|
||||||
|
|
||||||
|
async def list_ftp_files(self, remote_path: str = "/") -> List[dict]:
|
||||||
|
"""List files on the device via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_path: Directory path on the device (default: root)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of file info dicts with 'name', 'size', 'modified', 'is_dir'
|
||||||
|
"""
|
||||||
|
logger.info(f"Listing FTP files on {self.device_key} at {remote_path}")
|
||||||
|
|
||||||
|
def _list_ftp_sync():
|
||||||
|
"""Synchronous FTP listing using ftplib (supports active mode)."""
|
||||||
|
ftp = FTP()
|
||||||
|
ftp.set_debuglevel(0)
|
||||||
|
try:
|
||||||
|
# Connect and login
|
||||||
|
ftp.connect(self.host, 21, timeout=10)
|
||||||
|
ftp.login(self.ftp_username, self.ftp_password)
|
||||||
|
ftp.set_pasv(False) # Force active mode
|
||||||
|
|
||||||
|
# Change to target directory
|
||||||
|
if remote_path != "/":
|
||||||
|
ftp.cwd(remote_path)
|
||||||
|
|
||||||
|
# Get directory listing with details
|
||||||
|
files = []
|
||||||
|
lines = []
|
||||||
|
ftp.retrlines('LIST', lines.append)
|
||||||
|
|
||||||
|
for line in lines:
|
||||||
|
# Parse Unix-style ls output
|
||||||
|
parts = line.split(None, 8)
|
||||||
|
if len(parts) < 9:
|
||||||
|
continue
|
||||||
|
|
||||||
|
is_dir = parts[0].startswith('d')
|
||||||
|
size = int(parts[4]) if not is_dir else 0
|
||||||
|
name = parts[8]
|
||||||
|
|
||||||
|
# Skip . and ..
|
||||||
|
if name in ('.', '..'):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse modification time
|
||||||
|
# Format: "Jan 07 14:23" or "Dec 25 2025"
|
||||||
|
modified_str = f"{parts[5]} {parts[6]} {parts[7]}"
|
||||||
|
modified_timestamp = None
|
||||||
|
try:
|
||||||
|
from datetime import datetime
|
||||||
|
# Try parsing with time (recent files: "Jan 07 14:23")
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(modified_str, "%b %d %H:%M")
|
||||||
|
# Add current year since it's not in the format
|
||||||
|
dt = dt.replace(year=datetime.now().year)
|
||||||
|
|
||||||
|
# If the resulting date is in the future, it's actually from last year
|
||||||
|
if dt > datetime.now():
|
||||||
|
dt = dt.replace(year=dt.year - 1)
|
||||||
|
|
||||||
|
modified_timestamp = dt.isoformat()
|
||||||
|
except ValueError:
|
||||||
|
# Try parsing with year (older files: "Dec 25 2025")
|
||||||
|
dt = datetime.strptime(modified_str, "%b %d %Y")
|
||||||
|
modified_timestamp = dt.isoformat()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to parse timestamp '{modified_str}': {e}")
|
||||||
|
|
||||||
|
file_info = {
|
||||||
|
"name": name,
|
||||||
|
"path": f"{remote_path.rstrip('/')}/{name}",
|
||||||
|
"size": size,
|
||||||
|
"modified": modified_str, # Keep original string
|
||||||
|
"modified_timestamp": modified_timestamp, # Add parsed timestamp
|
||||||
|
"is_dir": is_dir,
|
||||||
|
}
|
||||||
|
files.append(file_info)
|
||||||
|
logger.debug(f"Found file: {file_info}")
|
||||||
|
|
||||||
|
logger.info(f"Found {len(files)} files/directories on {self.device_key}")
|
||||||
|
return files
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ftp.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run synchronous FTP in thread pool
|
||||||
|
return await asyncio.to_thread(_list_ftp_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list FTP files on {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"FTP connection failed: {str(e)}")
|
||||||
|
|
||||||
|
async def download_ftp_file(self, remote_path: str, local_path: str):
|
||||||
|
"""Download a file from the device via FTP.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
remote_path: Full path to file on the device
|
||||||
|
local_path: Local path where file will be saved
|
||||||
|
"""
|
||||||
|
logger.info(f"Downloading {remote_path} from {self.device_key} to {local_path}")
|
||||||
|
|
||||||
|
def _download_ftp_sync():
|
||||||
|
"""Synchronous FTP download using ftplib (supports active mode)."""
|
||||||
|
ftp = FTP()
|
||||||
|
ftp.set_debuglevel(0)
|
||||||
|
try:
|
||||||
|
# Connect and login
|
||||||
|
ftp.connect(self.host, 21, timeout=10)
|
||||||
|
ftp.login(self.ftp_username, self.ftp_password)
|
||||||
|
ftp.set_pasv(False) # Force active mode
|
||||||
|
|
||||||
|
# Download file
|
||||||
|
with open(local_path, 'wb') as f:
|
||||||
|
ftp.retrbinary(f'RETR {remote_path}', f.write)
|
||||||
|
|
||||||
|
logger.info(f"Successfully downloaded {remote_path} to {local_path}")
|
||||||
|
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ftp.quit()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Run synchronous FTP in thread pool
|
||||||
|
await asyncio.to_thread(_download_ftp_sync)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to download {remote_path} from {self.device_key}: {e}")
|
||||||
|
raise ConnectionError(f"FTP download failed: {str(e)}")
|
||||||
0
app/ui/__init__.py
Normal file
92
app/ui/routes.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
"""
|
||||||
|
UI Layer Routes - HTML page routes only (no business logic)
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Request
|
||||||
|
from fastapi.responses import HTMLResponse, FileResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
import os
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
# Setup Jinja2 templates
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
# Read environment (development or production)
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
VERSION = "1.0.0" # Terra-View version
|
||||||
|
|
||||||
|
# Override TemplateResponse to include environment and version in context
|
||||||
|
original_template_response = templates.TemplateResponse
|
||||||
|
def custom_template_response(name, context=None, *args, **kwargs):
|
||||||
|
if context is None:
|
||||||
|
context = {}
|
||||||
|
context["environment"] = ENVIRONMENT
|
||||||
|
context["version"] = VERSION
|
||||||
|
return original_template_response(name, context, *args, **kwargs)
|
||||||
|
templates.TemplateResponse = custom_template_response
|
||||||
|
|
||||||
|
|
||||||
|
# ===== HTML PAGE ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/", response_class=HTMLResponse)
|
||||||
|
async def dashboard(request: Request):
|
||||||
|
"""Dashboard home page"""
|
||||||
|
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster", response_class=HTMLResponse)
|
||||||
|
async def roster_page(request: Request):
|
||||||
|
"""Fleet roster page"""
|
||||||
|
return templates.TemplateResponse("roster.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||||
|
async def unit_detail_page(request: Request, unit_id: str):
|
||||||
|
"""Unit detail page"""
|
||||||
|
return templates.TemplateResponse("unit_detail.html", {
|
||||||
|
"request": request,
|
||||||
|
"unit_id": unit_id
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/settings", response_class=HTMLResponse)
|
||||||
|
async def settings_page(request: Request):
|
||||||
|
"""Settings page for roster management"""
|
||||||
|
return templates.TemplateResponse("settings.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sound-level-meters", response_class=HTMLResponse)
|
||||||
|
async def sound_level_meters_page(request: Request):
|
||||||
|
"""Sound Level Meters management dashboard"""
|
||||||
|
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/seismographs", response_class=HTMLResponse)
|
||||||
|
async def seismographs_page(request: Request):
|
||||||
|
"""Seismographs management dashboard"""
|
||||||
|
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== PWA ROUTES =====
|
||||||
|
|
||||||
|
@router.get("/sw.js")
|
||||||
|
async def service_worker():
|
||||||
|
"""Serve service worker with proper headers for PWA"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/sw.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={
|
||||||
|
"Service-Worker-Allowed": "/",
|
||||||
|
"Cache-Control": "no-cache"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/offline-db.js")
|
||||||
|
async def offline_db_script():
|
||||||
|
"""Serve offline database script"""
|
||||||
|
return FileResponse(
|
||||||
|
"app/ui/static/offline-db.js",
|
||||||
|
media_type="application/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"}
|
||||||
|
)
|
||||||
BIN
app/ui/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 287 B After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
|
Before Width: | Height: | Size: 288 B After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
BIN
app/ui/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 283 B After Width: | Height: | Size: 283 B |
@@ -20,9 +20,6 @@
|
|||||||
|
|
||||||
<!-- PWA Manifest -->
|
<!-- PWA Manifest -->
|
||||||
<link rel="manifest" href="/static/manifest.json">
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
<link rel="icon" type="image/png" sizes="32x32" href="/static/icons/favicon-32.png">
|
|
||||||
<link rel="icon" type="image/png" sizes="16x16" href="/static/icons/favicon-16.png">
|
|
||||||
<link rel="apple-touch-icon" sizes="180x180" href="/static/icons/icon-192.png">
|
|
||||||
<meta name="theme-color" content="#f48b1c">
|
<meta name="theme-color" content="#f48b1c">
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
@@ -71,7 +68,7 @@
|
|||||||
|
|
||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-slate-800 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
<!-- Offline Indicator -->
|
<!-- Offline Indicator -->
|
||||||
<div id="offlineIndicator" class="offline-indicator">
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
@@ -88,10 +85,10 @@
|
|||||||
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
<!-- Logo -->
|
<!-- Logo -->
|
||||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
<a href="/" class="block">
|
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||||
<img src="/static/terra-view-logo-light.png" srcset="/static/terra-view-logo-light.png 1x, /static/terra-view-logo-light@2x.png 2x" alt="Terra-View" class="block dark:hidden w-44 h-auto">
|
Seismo<br>
|
||||||
<img src="/static/terra-view-logo-dark.png" srcset="/static/terra-view-logo-dark.png 1x, /static/terra-view-logo-dark@2x.png 2x" alt="Terra-View" class="hidden dark:block w-44 h-auto">
|
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||||
</a>
|
</h1>
|
||||||
<div class="flex items-center justify-between mt-2">
|
<div class="flex items-center justify-between mt-2">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
{% if environment == 'development' %}
|
{% if environment == 'development' %}
|
||||||
@@ -130,21 +127,7 @@
|
|||||||
Sound Level Meters
|
Sound Level Meters
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<a href="/modems" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/modems' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
<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">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
||||||
</svg>
|
|
||||||
Modems
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/pair-devices" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/pair-devices' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
||||||
</svg>
|
|
||||||
Pair Devices
|
|
||||||
</a>
|
|
||||||
|
|
||||||
<a href="/projects" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path.startswith('/projects') %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
|
||||||
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
||||||
@@ -391,10 +374,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.5.1"></script>
|
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.5.1"></script>
|
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
@@ -27,10 +27,10 @@
|
|||||||
hx-swap="none"
|
hx-swap="none"
|
||||||
hx-on::after-request="updateDashboard(event)">
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Fleet Summary Card -->
|
<!-- Fleet Summary Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-summary-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -118,7 +118,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Alerts Card -->
|
<!-- Recent Alerts Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-alerts-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -138,7 +138,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recently Called In Units Card -->
|
<!-- Recently Called In Units Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="recent-callins-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -162,95 +162,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Today's Scheduled Actions Card -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="todays-actions-card">
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('todays-actions')">
|
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Today's Schedule</h2>
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
||||||
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z">
|
|
||||||
</path>
|
|
||||||
</svg>
|
|
||||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="todays-actions-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-content" id="todays-actions-content"
|
|
||||||
hx-get="/dashboard/todays-actions"
|
|
||||||
hx-trigger="load, every 30s"
|
|
||||||
hx-swap="innerHTML">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading scheduled actions...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Dashboard Filters -->
|
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-4 mb-4" id="dashboard-filters-card">
|
|
||||||
<div class="flex items-center justify-between mb-3">
|
|
||||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Filter Dashboard</h3>
|
|
||||||
<button onclick="resetFilters()" class="text-xs text-gray-500 hover:text-seismo-orange dark:hover:text-seismo-orange transition-colors">
|
|
||||||
Reset Filters
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-6">
|
|
||||||
<!-- Device Type Filters -->
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Device Type</span>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-seismograph" checked
|
|
||||||
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Seismographs</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-slm" checked
|
|
||||||
class="rounded border-gray-300 text-purple-600 focus:ring-purple-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">SLMs</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-modem" checked
|
|
||||||
class="rounded border-gray-300 text-cyan-600 focus:ring-cyan-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Modems</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Status Filters -->
|
|
||||||
<div class="flex flex-col gap-1">
|
|
||||||
<span class="text-xs text-gray-500 dark:text-gray-400 font-medium uppercase tracking-wide">Status</span>
|
|
||||||
<div class="flex gap-4">
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-ok" checked
|
|
||||||
class="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-green-600 dark:text-green-400">OK</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-pending" checked
|
|
||||||
class="rounded border-gray-300 text-yellow-600 focus:ring-yellow-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-yellow-600 dark:text-yellow-400">Pending</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-1.5 cursor-pointer">
|
|
||||||
<input type="checkbox" id="filter-missing" checked
|
|
||||||
class="rounded border-gray-300 text-red-600 focus:ring-red-500 dark:border-gray-600 dark:bg-slate-800"
|
|
||||||
onchange="applyFilters()">
|
|
||||||
<span class="text-sm text-red-600 dark:text-red-400">Missing</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Map -->
|
<!-- Fleet Map -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="fleet-map-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -266,7 +181,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Photos Section -->
|
<!-- Recent Photos Section -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6 mb-8" id="recent-photos-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -286,7 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-700 p-6" id="fleet-status-card">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
|
||||||
|
|
||||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
@@ -364,254 +279,6 @@
|
|||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ===== Dashboard Filtering System =====
|
|
||||||
let currentSnapshotData = null; // Store latest snapshot data for re-filtering
|
|
||||||
|
|
||||||
// Filter state - tracks which device types and statuses to show
|
|
||||||
const filters = {
|
|
||||||
deviceTypes: {
|
|
||||||
seismograph: true,
|
|
||||||
sound_level_meter: true,
|
|
||||||
modem: true
|
|
||||||
},
|
|
||||||
statuses: {
|
|
||||||
OK: true,
|
|
||||||
Pending: true,
|
|
||||||
Missing: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load saved filter preferences from localStorage
|
|
||||||
function loadFilterPreferences() {
|
|
||||||
const saved = localStorage.getItem('dashboardFilters');
|
|
||||||
if (saved) {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(saved);
|
|
||||||
if (parsed.deviceTypes) Object.assign(filters.deviceTypes, parsed.deviceTypes);
|
|
||||||
if (parsed.statuses) Object.assign(filters.statuses, parsed.statuses);
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Error loading filter preferences:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync checkboxes with loaded state
|
|
||||||
const seismoCheck = document.getElementById('filter-seismograph');
|
|
||||||
const slmCheck = document.getElementById('filter-slm');
|
|
||||||
const modemCheck = document.getElementById('filter-modem');
|
|
||||||
const okCheck = document.getElementById('filter-ok');
|
|
||||||
const pendingCheck = document.getElementById('filter-pending');
|
|
||||||
const missingCheck = document.getElementById('filter-missing');
|
|
||||||
|
|
||||||
if (seismoCheck) seismoCheck.checked = filters.deviceTypes.seismograph;
|
|
||||||
if (slmCheck) slmCheck.checked = filters.deviceTypes.sound_level_meter;
|
|
||||||
if (modemCheck) modemCheck.checked = filters.deviceTypes.modem;
|
|
||||||
if (okCheck) okCheck.checked = filters.statuses.OK;
|
|
||||||
if (pendingCheck) pendingCheck.checked = filters.statuses.Pending;
|
|
||||||
if (missingCheck) missingCheck.checked = filters.statuses.Missing;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save filter preferences to localStorage
|
|
||||||
function saveFilterPreferences() {
|
|
||||||
localStorage.setItem('dashboardFilters', JSON.stringify(filters));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply filters - called when any checkbox changes
|
|
||||||
function applyFilters() {
|
|
||||||
// Update filter state from checkboxes
|
|
||||||
const seismoCheck = document.getElementById('filter-seismograph');
|
|
||||||
const slmCheck = document.getElementById('filter-slm');
|
|
||||||
const modemCheck = document.getElementById('filter-modem');
|
|
||||||
const okCheck = document.getElementById('filter-ok');
|
|
||||||
const pendingCheck = document.getElementById('filter-pending');
|
|
||||||
const missingCheck = document.getElementById('filter-missing');
|
|
||||||
|
|
||||||
if (seismoCheck) filters.deviceTypes.seismograph = seismoCheck.checked;
|
|
||||||
if (slmCheck) filters.deviceTypes.sound_level_meter = slmCheck.checked;
|
|
||||||
if (modemCheck) filters.deviceTypes.modem = modemCheck.checked;
|
|
||||||
if (okCheck) filters.statuses.OK = okCheck.checked;
|
|
||||||
if (pendingCheck) filters.statuses.Pending = pendingCheck.checked;
|
|
||||||
if (missingCheck) filters.statuses.Missing = missingCheck.checked;
|
|
||||||
|
|
||||||
saveFilterPreferences();
|
|
||||||
|
|
||||||
// Re-render with current data and filters
|
|
||||||
if (currentSnapshotData) {
|
|
||||||
renderFilteredDashboard(currentSnapshotData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset all filters to show everything
|
|
||||||
function resetFilters() {
|
|
||||||
filters.deviceTypes = { seismograph: true, sound_level_meter: true, modem: true };
|
|
||||||
filters.statuses = { OK: true, Pending: true, Missing: true };
|
|
||||||
|
|
||||||
// Update all checkboxes
|
|
||||||
const checkboxes = [
|
|
||||||
'filter-seismograph', 'filter-slm', 'filter-modem',
|
|
||||||
'filter-ok', 'filter-pending', 'filter-missing'
|
|
||||||
];
|
|
||||||
checkboxes.forEach(id => {
|
|
||||||
const el = document.getElementById(id);
|
|
||||||
if (el) el.checked = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
saveFilterPreferences();
|
|
||||||
|
|
||||||
if (currentSnapshotData) {
|
|
||||||
renderFilteredDashboard(currentSnapshotData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if a unit passes the current filters
|
|
||||||
function unitPassesFilter(unit) {
|
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
const status = unit.status || 'Missing';
|
|
||||||
|
|
||||||
// Check device type filter
|
|
||||||
if (!filters.deviceTypes[deviceType]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check status filter
|
|
||||||
if (!filters.statuses[status]) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get display label for device type
|
|
||||||
function getDeviceTypeLabel(deviceType) {
|
|
||||||
switch(deviceType) {
|
|
||||||
case 'sound_level_meter': return 'SLM';
|
|
||||||
case 'modem': return 'Modem';
|
|
||||||
default: return 'Seismograph';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render dashboard with filtered data
|
|
||||||
function renderFilteredDashboard(data) {
|
|
||||||
// Filter active units for alerts
|
|
||||||
const filteredActive = {};
|
|
||||||
Object.entries(data.active || {}).forEach(([id, unit]) => {
|
|
||||||
if (unitPassesFilter(unit)) {
|
|
||||||
filteredActive[id] = unit;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update alerts with filtered data
|
|
||||||
updateAlertsFiltered(filteredActive);
|
|
||||||
|
|
||||||
// Update map with filtered data
|
|
||||||
updateFleetMapFiltered(data.units);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the Recent Alerts section with filtering
|
|
||||||
function updateAlertsFiltered(filteredActive) {
|
|
||||||
const alertsList = document.getElementById('alerts-list');
|
|
||||||
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
|
|
||||||
|
|
||||||
if (!missingUnits.length) {
|
|
||||||
// Check if this is because of filters or genuinely no alerts
|
|
||||||
const anyMissing = currentSnapshotData && Object.values(currentSnapshotData.active || {}).some(u => u.status === 'Missing');
|
|
||||||
if (anyMissing) {
|
|
||||||
alertsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No alerts match current filters</p>';
|
|
||||||
} else {
|
|
||||||
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">All units reporting normally</p>';
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
let alertsHtml = '';
|
|
||||||
missingUnits.forEach(([id, unit]) => {
|
|
||||||
const deviceLabel = getDeviceTypeLabel(unit.device_type);
|
|
||||||
alertsHtml += `
|
|
||||||
<div class="flex items-start space-x-2 text-sm">
|
|
||||||
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
|
||||||
<div>
|
|
||||||
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
|
||||||
<span class="text-xs text-gray-500 ml-1">(${deviceLabel})</span>
|
|
||||||
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
|
||||||
</div>
|
|
||||||
</div>`;
|
|
||||||
});
|
|
||||||
alertsList.innerHTML = alertsHtml;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update map with filtered data
|
|
||||||
function updateFleetMapFiltered(allUnits) {
|
|
||||||
if (!fleetMap) return;
|
|
||||||
|
|
||||||
// Clear existing markers
|
|
||||||
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
|
||||||
fleetMarkers = [];
|
|
||||||
|
|
||||||
// Get deployed units with coordinates that pass the filter
|
|
||||||
const deployedUnits = Object.entries(allUnits || {})
|
|
||||||
.filter(([_, u]) => u.deployed && u.coordinates && unitPassesFilter(u));
|
|
||||||
|
|
||||||
if (deployedUnits.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const bounds = [];
|
|
||||||
|
|
||||||
deployedUnits.forEach(([id, unit]) => {
|
|
||||||
const coords = parseLocation(unit.coordinates);
|
|
||||||
if (coords) {
|
|
||||||
const [lat, lon] = coords;
|
|
||||||
|
|
||||||
// Color based on status
|
|
||||||
const markerColor = unit.status === 'OK' ? 'green' :
|
|
||||||
unit.status === 'Pending' ? 'orange' : 'red';
|
|
||||||
|
|
||||||
// Different marker style per device type
|
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
|
||||||
let radius = 8;
|
|
||||||
let weight = 2;
|
|
||||||
|
|
||||||
if (deviceType === 'modem') {
|
|
||||||
radius = 6;
|
|
||||||
weight = 2;
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
radius = 8;
|
|
||||||
weight = 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
const marker = L.circleMarker([lat, lon], {
|
|
||||||
radius: radius,
|
|
||||||
fillColor: markerColor,
|
|
||||||
color: '#fff',
|
|
||||||
weight: weight,
|
|
||||||
opacity: 1,
|
|
||||||
fillOpacity: 0.8
|
|
||||||
}).addTo(fleetMap);
|
|
||||||
|
|
||||||
// Popup with device type
|
|
||||||
const deviceLabel = getDeviceTypeLabel(deviceType);
|
|
||||||
|
|
||||||
marker.bindPopup(`
|
|
||||||
<div class="p-2">
|
|
||||||
<h3 class="font-bold text-lg">${id}</h3>
|
|
||||||
<p class="text-sm text-gray-600">${deviceLabel}</p>
|
|
||||||
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
|
||||||
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
|
||||||
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details</a>
|
|
||||||
</div>
|
|
||||||
`);
|
|
||||||
|
|
||||||
fleetMarkers.push(marker);
|
|
||||||
bounds.push([lat, lon]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fit bounds if we have markers
|
|
||||||
if (bounds.length > 0) {
|
|
||||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
|
||||||
fleetMap.fitBounds(bounds, { padding: padding });
|
|
||||||
fleetMapInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Toggle card collapse/expand (mobile only)
|
// Toggle card collapse/expand (mobile only)
|
||||||
function toggleCard(cardName) {
|
function toggleCard(cardName) {
|
||||||
// Only work on mobile
|
// Only work on mobile
|
||||||
@@ -649,7 +316,7 @@ function toggleCard(cardName) {
|
|||||||
// Restore card states from localStorage on page load
|
// Restore card states from localStorage on page load
|
||||||
function restoreCardStates() {
|
function restoreCardStates() {
|
||||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'todays-actions', 'fleet-map', 'fleet-status'];
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
cardNames.forEach(cardName => {
|
cardNames.forEach(cardName => {
|
||||||
const content = document.getElementById(`${cardName}-content`);
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
@@ -676,17 +343,8 @@ if (document.readyState === 'loading') {
|
|||||||
|
|
||||||
function updateDashboard(event) {
|
function updateDashboard(event) {
|
||||||
try {
|
try {
|
||||||
// Only process responses from /api/status-snapshot
|
|
||||||
const requestUrl = event.detail.xhr.responseURL || event.detail.pathInfo?.requestPath;
|
|
||||||
if (!requestUrl || !requestUrl.includes('/api/status-snapshot')) {
|
|
||||||
return; // Ignore responses from other endpoints (like /dashboard/todays-actions)
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.parse(event.detail.xhr.response);
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
// Store data for filter re-application
|
|
||||||
currentSnapshotData = data;
|
|
||||||
|
|
||||||
// Update "Last updated" timestamp with timezone
|
// Update "Last updated" timestamp with timezone
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
@@ -698,7 +356,7 @@ function updateDashboard(event) {
|
|||||||
timeZoneName: 'short'
|
timeZoneName: 'short'
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers (always unfiltered) =====
|
// ===== Fleet summary numbers =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
@@ -706,10 +364,9 @@ function updateDashboard(event) {
|
|||||||
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|
||||||
// ===== Device type counts (always unfiltered) =====
|
// ===== Device type counts =====
|
||||||
let seismoCount = 0;
|
let seismoCount = 0;
|
||||||
let slmCount = 0;
|
let slmCount = 0;
|
||||||
let modemCount = 0;
|
|
||||||
Object.values(data.units || {}).forEach(unit => {
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
if (unit.retired) return; // Don't count retired units
|
if (unit.retired) return; // Don't count retired units
|
||||||
const deviceType = unit.device_type || 'seismograph';
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
@@ -717,26 +374,46 @@ function updateDashboard(event) {
|
|||||||
seismoCount++;
|
seismoCount++;
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
slmCount++;
|
slmCount++;
|
||||||
} else if (deviceType === 'modem') {
|
|
||||||
modemCount++;
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
document.getElementById('seismo-count').textContent = seismoCount;
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
document.getElementById('slm-count').textContent = slmCount;
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
|
|
||||||
// ===== Apply filters and render map + alerts =====
|
// ===== Alerts =====
|
||||||
renderFilteredDashboard(data);
|
const alertsList = document.getElementById('alerts-list');
|
||||||
|
// Only show alerts for deployed units that are MISSING (not pending)
|
||||||
|
const missingUnits = Object.entries(data.active).filter(([_, u]) => u.status === 'Missing');
|
||||||
|
|
||||||
|
if (!missingUnits.length) {
|
||||||
|
alertsList.innerHTML =
|
||||||
|
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||||
|
} else {
|
||||||
|
let alertsHtml = '';
|
||||||
|
|
||||||
|
missingUnits.forEach(([id, unit]) => {
|
||||||
|
alertsHtml += `
|
||||||
|
<div class="flex items-start space-x-2 text-sm">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
alertsList.innerHTML = alertsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Update Fleet Map =====
|
||||||
|
updateFleetMap(data);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Dashboard update error:", err);
|
console.error("Dashboard update error:", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle tab switching and initialize components
|
// Handle tab switching
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Load filter preferences
|
|
||||||
loadFilterPreferences();
|
|
||||||
|
|
||||||
const tabButtons = document.querySelectorAll('.tab-button');
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
tabButtons.forEach(button => {
|
tabButtons.forEach(button => {
|
||||||
@@ -776,6 +453,64 @@ function initFleetMap() {
|
|||||||
}, 100);
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateFleetMap(data) {
|
||||||
|
if (!fleetMap) return;
|
||||||
|
|
||||||
|
// Clear existing markers
|
||||||
|
fleetMarkers.forEach(marker => fleetMap.removeLayer(marker));
|
||||||
|
fleetMarkers = [];
|
||||||
|
|
||||||
|
// Get deployed units with coordinates data
|
||||||
|
const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.coordinates);
|
||||||
|
|
||||||
|
if (deployedUnits.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds = [];
|
||||||
|
|
||||||
|
deployedUnits.forEach(([id, unit]) => {
|
||||||
|
const coords = parseLocation(unit.coordinates);
|
||||||
|
if (coords) {
|
||||||
|
const [lat, lon] = coords;
|
||||||
|
|
||||||
|
// Create marker with custom color based on status
|
||||||
|
const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red';
|
||||||
|
|
||||||
|
const marker = L.circleMarker([lat, lon], {
|
||||||
|
radius: 8,
|
||||||
|
fillColor: markerColor,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 2,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Add popup with unit info
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="p-2">
|
||||||
|
<h3 class="font-bold text-lg">${id}</h3>
|
||||||
|
<p class="text-sm">Status: <span style="color: ${markerColor}">${unit.status}</span></p>
|
||||||
|
<p class="text-sm">Type: ${unit.device_type}</p>
|
||||||
|
${unit.note ? `<p class="text-sm text-gray-600">${unit.note}</p>` : ''}
|
||||||
|
<a href="/unit/${id}" class="text-blue-600 hover:underline text-sm">View Details →</a>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
fleetMarkers.push(marker);
|
||||||
|
bounds.push([lat, lon]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to show all markers
|
||||||
|
if (bounds.length > 0) {
|
||||||
|
// Use different padding for mobile vs desktop
|
||||||
|
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||||
|
fleetMap.fitBounds(bounds, { padding: padding });
|
||||||
|
fleetMapInitialized = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function parseLocation(location) {
|
function parseLocation(location) {
|
||||||
if (!location) return null;
|
if (!location) return null;
|
||||||
|
|
||||||
@@ -85,10 +85,6 @@
|
|||||||
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
Modem
|
Modem
|
||||||
</span>
|
</span>
|
||||||
{% elif unit.device_type == 'slm' %}
|
|
||||||
<span class="px-2 py-1 rounded-full bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 text-xs font-medium">
|
|
||||||
SLM
|
|
||||||
</span>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
Seismograph
|
Seismograph
|
||||||
@@ -104,13 +100,8 @@
|
|||||||
{% if unit.phone_number %}
|
{% if unit.phone_number %}
|
||||||
<div>{{ unit.phone_number }}</div>
|
<div>{{ unit.phone_number }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if unit.deployed_with_unit_id %}
|
{% if unit.hardware_model %}
|
||||||
<div>
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
<span class="text-gray-500 dark:text-gray-500">Linked:</span>
|
|
||||||
<a href="/unit/{{ unit.deployed_with_unit_id }}" class="text-seismo-orange hover:underline font-medium">
|
|
||||||
{{ unit.deployed_with_unit_id }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% if unit.next_calibration_due %}
|
{% if unit.next_calibration_due %}
|
||||||
@@ -131,7 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm text-gray-500 dark:text-gray-400 last-seen-cell" data-iso="{{ unit.last_seen }}">{{ unit.last_seen }}</div>
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-6 py-4 whitespace-nowrap">
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
<div class="text-sm
|
<div class="text-sm
|
||||||
@@ -350,39 +341,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
|
||||||
// User's configured timezone from settings (defaults to America/New_York)
|
|
||||||
const userTimezone = '{{ user_timezone | default("America/New_York") }}';
|
|
||||||
|
|
||||||
// Format ISO timestamp to human-readable format in user's timezone
|
|
||||||
function formatLastSeenLocal(isoString) {
|
|
||||||
if (!isoString || isoString === 'Never' || isoString === 'N/A') {
|
|
||||||
return isoString || 'Never';
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const date = new Date(isoString);
|
|
||||||
if (isNaN(date.getTime())) return isoString;
|
|
||||||
|
|
||||||
// Format in user's configured timezone
|
|
||||||
return date.toLocaleString('en-US', {
|
|
||||||
timeZone: userTimezone,
|
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
hour12: true
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return isoString;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Format all last-seen cells on page load
|
|
||||||
document.querySelectorAll('.last-seen-cell').forEach(cell => {
|
|
||||||
const isoDate = cell.getAttribute('data-iso');
|
|
||||||
cell.textContent = formatLastSeenLocal(isoDate);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -403,23 +361,20 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
})();
|
|
||||||
|
|
||||||
// Sorting state (needs to persist across swaps)
|
// Sorting state
|
||||||
if (typeof window.currentSort === 'undefined') {
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
window.currentSort = { column: null, direction: 'asc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (window.currentSort.column === column) {
|
if (currentSort.column === column) {
|
||||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
window.currentSort.column = column;
|
currentSort.column = column;
|
||||||
window.currentSort.direction = 'asc';
|
currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -447,8 +402,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -484,10 +439,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (window.currentSort.column) {
|
if (currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +337,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
(function() {
|
|
||||||
// Update timestamp
|
// Update timestamp
|
||||||
const timestampElement = document.getElementById('last-updated');
|
const timestampElement = document.getElementById('last-updated');
|
||||||
if (timestampElement) {
|
if (timestampElement) {
|
||||||
@@ -358,23 +357,20 @@
|
|||||||
};
|
};
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
})();
|
|
||||||
|
|
||||||
// Sorting state (needs to persist across swaps)
|
// Sorting state
|
||||||
if (typeof window.currentSort === 'undefined') {
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
window.currentSort = { column: null, direction: 'asc' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTable(column) {
|
function sortTable(column) {
|
||||||
const tbody = document.getElementById('roster-tbody');
|
const tbody = document.getElementById('roster-tbody');
|
||||||
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
// Determine sort direction
|
// Determine sort direction
|
||||||
if (window.currentSort.column === column) {
|
if (currentSort.column === column) {
|
||||||
window.currentSort.direction = window.currentSort.direction === 'asc' ? 'desc' : 'asc';
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
} else {
|
} else {
|
||||||
window.currentSort.column = column;
|
currentSort.column = column;
|
||||||
window.currentSort.direction = 'asc';
|
currentSort.direction = 'asc';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort rows
|
// Sort rows
|
||||||
@@ -402,8 +398,8 @@
|
|||||||
bVal = bVal.toLowerCase();
|
bVal = bVal.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aVal < bVal) return window.currentSort.direction === 'asc' ? -1 : 1;
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
if (aVal > bVal) return window.currentSort.direction === 'asc' ? 1 : -1;
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -439,10 +435,10 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Set current indicator
|
// Set current indicator
|
||||||
if (window.currentSort.column) {
|
if (currentSort.column) {
|
||||||
const indicator = document.querySelector(`.sort-indicator[data-column="${window.currentSort.column}"]`);
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
if (indicator) {
|
if (indicator) {
|
||||||
indicator.className = `sort-indicator ${window.currentSort.direction}`;
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
1466
app/ui/templates/partials/slm_live_view.html
Normal file
@@ -57,7 +57,7 @@
|
|||||||
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
|
<button class="filter-btn filter-device-type active-filter" data-value="all">All</button>
|
||||||
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
|
<button class="filter-btn filter-device-type" data-value="seismograph">Seismographs</button>
|
||||||
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
|
<button class="filter-btn filter-device-type" data-value="modem">Modems</button>
|
||||||
<button class="filter-btn filter-device-type" data-value="slm">SLMs</button>
|
<button class="filter-btn filter-device-type" data-value="sound_level_meter">SLMs</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Status Filter -->
|
<!-- Status Filter -->
|
||||||
@@ -131,8 +131,10 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
{% include "partials/project_picker.html" with context %}
|
<input type="text" name="project_id"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
placeholder="PROJ-001">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||||
@@ -157,8 +159,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="modemPairingField" class="hidden">
|
<div id="modemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
{% set picker_id = "-add-seismo" %}
|
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
||||||
{% include "partials/modem_picker.html" with context %}
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,21 +183,6 @@
|
|||||||
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
|
||||||
<select name="deployment_type"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="">Not assigned</option>
|
|
||||||
<option value="seismograph">Seismograph</option>
|
|
||||||
<option value="slm">Sound Level Meter (SLM)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
|
||||||
{% set picker_id = "-add-modem" %}
|
|
||||||
{% set device_type_filter = "" %}
|
|
||||||
{% include "partials/unit_picker.html" with context %}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Level Meter-specific fields -->
|
<!-- Sound Level Meter-specific fields -->
|
||||||
@@ -301,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>
|
||||||
@@ -310,9 +296,9 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
{% set picker_id = "-edit" %}
|
<input type="text" name="project_id" id="editProjectId"
|
||||||
{% include "partials/project_picker.html" with context %}
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
@@ -340,8 +326,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="editModemPairingField" class="hidden">
|
<div id="editModemPairingField" class="hidden">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
{% set picker_id = "-edit-seismo" %}
|
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
||||||
{% include "partials/modem_picker.html" with context %}
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -363,71 +349,6 @@
|
|||||||
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployment Type</label>
|
|
||||||
<select name="deployment_type" id="editDeploymentType"
|
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
|
||||||
<option value="">Not assigned</option>
|
|
||||||
<option value="seismograph">Seismograph</option>
|
|
||||||
<option value="slm">Sound Level Meter (SLM)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Unit</label>
|
|
||||||
{% set picker_id = "-edit-modem" %}
|
|
||||||
{% set device_type_filter = "" %}
|
|
||||||
{% include "partials/unit_picker.html" with context %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 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>
|
||||||
|
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
@@ -447,65 +368,10 @@
|
|||||||
<textarea name="note" id="editNote" rows="3"
|
<textarea name="note" id="editNote" rows="3"
|
||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cascade to Paired Device Section -->
|
|
||||||
<div id="editCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
Also update paired device: <span id="editPairedDeviceName" class="text-seismo-orange"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="cascade_to_unit_id" id="editCascadeToUnitId" value="">
|
|
||||||
<div class="grid grid-cols-2 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_deployed" id="editCascadeDeployed" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_retired" id="editCascadeRetired" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_project" id="editCascadeProject" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_location" id="editCascadeLocation" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_coordinates" id="editCascadeCoordinates" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_note" id="editCascadeNote" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
Check the fields you want to sync to the paired device
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
Save Changes
|
Save Changes
|
||||||
</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>
|
||||||
@@ -514,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">
|
||||||
@@ -727,7 +540,11 @@
|
|||||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||||
if (event.detail.successful) {
|
if (event.detail.successful) {
|
||||||
closeAddUnitModal();
|
closeAddUnitModal();
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
// Show success message
|
// Show success message
|
||||||
alert('Unit added successfully!');
|
alert('Unit added successfully!');
|
||||||
} else {
|
} else {
|
||||||
@@ -765,33 +582,6 @@
|
|||||||
function closeEditUnitModal() {
|
function closeEditUnitModal() {
|
||||||
document.getElementById('editUnitModal').classList.add('hidden');
|
document.getElementById('editUnitModal').classList.add('hidden');
|
||||||
document.getElementById('editUnitForm').reset();
|
document.getElementById('editUnitForm').reset();
|
||||||
// Also clear the project picker
|
|
||||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
|
||||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
|
||||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
|
||||||
if (projectPickerValue) projectPickerValue.value = '';
|
|
||||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
|
||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch project display name for edit modal
|
|
||||||
async function fetchProjectDisplayForEdit(projectId) {
|
|
||||||
if (!projectId) return '';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const project = await response.json();
|
|
||||||
const parts = [
|
|
||||||
project.project_number,
|
|
||||||
project.client_name,
|
|
||||||
project.name
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(' - ') || projectId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch project:', e);
|
|
||||||
}
|
|
||||||
return projectId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle device-specific fields in edit modal
|
// Toggle device-specific fields in edit modal
|
||||||
@@ -799,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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,23 +633,7 @@
|
|||||||
document.getElementById('editUnitId').value = unit.id;
|
document.getElementById('editUnitId').value = unit.id;
|
||||||
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||||
document.getElementById('editUnitType').value = unit.unit_type;
|
document.getElementById('editUnitType').value = unit.unit_type;
|
||||||
|
document.getElementById('editProjectId').value = unit.project_id;
|
||||||
// Populate project picker (uses -edit suffix)
|
|
||||||
const projectPickerValue = document.getElementById('project-picker-value-edit');
|
|
||||||
const projectPickerSearch = document.getElementById('project-picker-search-edit');
|
|
||||||
const projectPickerClear = document.getElementById('project-picker-clear-edit');
|
|
||||||
if (projectPickerValue) projectPickerValue.value = unit.project_id || '';
|
|
||||||
if (unit.project_id) {
|
|
||||||
// Fetch project display name
|
|
||||||
fetchProjectDisplayForEdit(unit.project_id).then(displayText => {
|
|
||||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
|
||||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
|
||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('editAddress').value = unit.address;
|
document.getElementById('editAddress').value = unit.address;
|
||||||
document.getElementById('editCoordinates').value = unit.coordinates;
|
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||||
document.getElementById('editNote').value = unit.note;
|
document.getElementById('editNote').value = unit.note;
|
||||||
@@ -881,100 +645,12 @@
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||||
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||||
|
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
||||||
// Populate modem picker for seismograph (uses -edit-seismo suffix)
|
|
||||||
const modemPickerValue = document.getElementById('modem-picker-value-edit-seismo');
|
|
||||||
const modemPickerSearch = document.getElementById('modem-picker-search-edit-seismo');
|
|
||||||
const modemPickerClear = document.getElementById('modem-picker-clear-edit-seismo');
|
|
||||||
if (modemPickerValue) modemPickerValue.value = unit.deployed_with_modem_id || '';
|
|
||||||
if (unit.deployed_with_modem_id) {
|
|
||||||
// Fetch modem display (ID + IP + note)
|
|
||||||
fetch(`/api/roster/${unit.deployed_with_modem_id}`)
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(modem => {
|
|
||||||
if (modem && modemPickerSearch) {
|
|
||||||
let display = modem.id;
|
|
||||||
if (modem.ip_address) display += ` - ${modem.ip_address}`;
|
|
||||||
if (modem.note) display += ` - ${modem.note}`;
|
|
||||||
modemPickerSearch.value = display;
|
|
||||||
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (modemPickerSearch) modemPickerSearch.value = unit.deployed_with_modem_id;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (modemPickerSearch) modemPickerSearch.value = '';
|
|
||||||
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modem fields
|
// Modem fields
|
||||||
document.getElementById('editIpAddress').value = unit.ip_address;
|
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||||
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
document.getElementById('editDeploymentType').value = unit.deployment_type || '';
|
|
||||||
|
|
||||||
// Populate unit picker for modem (uses -edit-modem suffix)
|
|
||||||
const unitPickerValue = document.getElementById('unit-picker-value-edit-modem');
|
|
||||||
const unitPickerSearch = document.getElementById('unit-picker-search-edit-modem');
|
|
||||||
const unitPickerClear = document.getElementById('unit-picker-clear-edit-modem');
|
|
||||||
if (unitPickerValue) unitPickerValue.value = unit.deployed_with_unit_id || '';
|
|
||||||
if (unit.deployed_with_unit_id) {
|
|
||||||
// Fetch unit display (ID + note)
|
|
||||||
fetch(`/api/roster/${unit.deployed_with_unit_id}`)
|
|
||||||
.then(r => r.ok ? r.json() : null)
|
|
||||||
.then(linkedUnit => {
|
|
||||||
if (linkedUnit && unitPickerSearch) {
|
|
||||||
const display = linkedUnit.note ? `${linkedUnit.id} - ${linkedUnit.note}` : linkedUnit.id;
|
|
||||||
unitPickerSearch.value = display;
|
|
||||||
if (unitPickerClear) unitPickerClear.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
if (unitPickerSearch) unitPickerSearch.value = unit.deployed_with_unit_id;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (unitPickerSearch) unitPickerSearch.value = '';
|
|
||||||
if (unitPickerClear) unitPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// SLM fields
|
|
||||||
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 || '';
|
|
||||||
|
|
||||||
// Cascade section - show if there's a paired device
|
|
||||||
const cascadeSection = document.getElementById('editCascadeSection');
|
|
||||||
const cascadeToUnitId = document.getElementById('editCascadeToUnitId');
|
|
||||||
const pairedDeviceName = document.getElementById('editPairedDeviceName');
|
|
||||||
|
|
||||||
// Determine paired device based on device type
|
|
||||||
let pairedUnitId = null;
|
|
||||||
if (unit.device_type === 'modem' && unit.deployed_with_unit_id) {
|
|
||||||
pairedUnitId = unit.deployed_with_unit_id;
|
|
||||||
} else if ((unit.device_type === 'seismograph' || unit.device_type === 'sound_level_meter') && unit.deployed_with_modem_id) {
|
|
||||||
pairedUnitId = unit.deployed_with_modem_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pairedUnitId) {
|
|
||||||
cascadeToUnitId.value = pairedUnitId;
|
|
||||||
pairedDeviceName.textContent = pairedUnitId;
|
|
||||||
cascadeSection.classList.remove('hidden');
|
|
||||||
// Reset checkboxes
|
|
||||||
document.getElementById('editCascadeDeployed').checked = false;
|
|
||||||
document.getElementById('editCascadeRetired').checked = false;
|
|
||||||
document.getElementById('editCascadeProject').checked = false;
|
|
||||||
document.getElementById('editCascadeLocation').checked = false;
|
|
||||||
document.getElementById('editCascadeCoordinates').checked = false;
|
|
||||||
document.getElementById('editCascadeNote').checked = false;
|
|
||||||
} else {
|
|
||||||
cascadeToUnitId.value = '';
|
|
||||||
cascadeSection.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Store unit ID for form submission
|
// Store unit ID for form submission
|
||||||
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
@@ -1009,7 +685,11 @@
|
|||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
closeEditUnitModal();
|
closeEditUnitModal();
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
alert('Unit updated successfully!');
|
alert('Unit updated successfully!');
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -1037,7 +717,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -1065,7 +749,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
alert(`Unit ${unitId} moved to ignore list`);
|
alert(`Unit ${unitId} moved to ignore list`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -1088,7 +776,11 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
alert(`Unit ${unitId} deleted successfully`);
|
alert(`Unit ${unitId} deleted successfully`);
|
||||||
} else {
|
} else {
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
@@ -1127,7 +819,11 @@
|
|||||||
`;
|
`;
|
||||||
resultDiv.classList.remove('hidden');
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
refreshDeviceList();
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
|
||||||
// Close modal after 2 seconds
|
// Close modal after 2 seconds
|
||||||
setTimeout(() => closeImportModal(), 2000);
|
setTimeout(() => closeImportModal(), 2000);
|
||||||
@@ -1143,35 +839,35 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Refresh device list (applies current client-side filters after load)
|
// Handle roster tab switching with auto-refresh
|
||||||
function refreshDeviceList() {
|
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
||||||
htmx.ajax('GET', '/partials/devices-all', {
|
|
||||||
target: '#device-content',
|
|
||||||
swap: 'innerHTML'
|
|
||||||
}).then(() => {
|
|
||||||
// Re-apply filters after content loads
|
|
||||||
setTimeout(filterDevices, 100);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if any modal is currently open
|
|
||||||
function isAnyModalOpen() {
|
|
||||||
const modalIds = ['addUnitModal', 'editUnitModal', 'renameUnitModal', 'importModal', 'quickCreateProjectModal'];
|
|
||||||
return modalIds.some(id => {
|
|
||||||
const modal = document.getElementById(id);
|
|
||||||
return modal && !modal.classList.contains('hidden');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Auto-refresh device list every 30 seconds (increased from 10s to reduce flicker)
|
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
// Remove active-roster-tab class from all buttons
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
|
||||||
|
// Add active-roster-tab class to clicked button
|
||||||
|
this.classList.add('active-roster-tab');
|
||||||
|
|
||||||
|
// Update current endpoint for auto-refresh
|
||||||
|
currentRosterEndpoint = this.getAttribute('data-endpoint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh the current active tab every 10 seconds
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const deviceContent = document.getElementById('device-content');
|
const rosterContent = document.getElementById('roster-content');
|
||||||
if (deviceContent && !isAnyModalOpen()) {
|
if (rosterContent) {
|
||||||
// Only auto-refresh if no modal is open
|
// Use HTMX to trigger a refresh of the current endpoint
|
||||||
refreshDeviceList();
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, 30000);
|
}, 10000); // 10 seconds
|
||||||
});
|
});
|
||||||
|
|
||||||
// Un-ignore Unit (remove from ignored list)
|
// Un-ignore Unit (remove from ignored list)
|
||||||
@@ -1381,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>
|
||||||
@@ -1520,7 +1134,4 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<!-- Include Project Create Modal for inline project creation -->
|
|
||||||
{% include "partials/project_create_modal.html" %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -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) {
|
||||||
195
app/ui/templates/slm_detail.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/roster" 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>
|
||||||
|
Back to Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- 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 %}
|
||||||
257
app/ui/templates/sound_level_meters.html
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Sound Level Meters - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Sound Level Meters</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Monitor and manage sound level measurement devices</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Summary Stats -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8"
|
||||||
|
hx-get="/api/slm-dashboard/stats"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Main Content Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<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">Active Units</h2>
|
||||||
|
|
||||||
|
<!-- Search/Filter -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<input type="text"
|
||||||
|
placeholder="Search units..."
|
||||||
|
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#slm-list"
|
||||||
|
hx-include="this"
|
||||||
|
name="search">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SLM List -->
|
||||||
|
<div id="slm-list"
|
||||||
|
class="space-y-2 max-h-[600px] overflow-y-auto"
|
||||||
|
hx-get="/api/slm-dashboard/units"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<!-- Loading skeleton -->
|
||||||
|
<div class="animate-pulse space-y-2">
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 h-20 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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 -->
|
||||||
|
<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="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-2xl font-bold text-gray-900 dark:text-white">Configure SLM</h3>
|
||||||
|
<button onclick="closeConfigModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<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">
|
||||||
|
<!-- Content loaded via HTMX -->
|
||||||
|
<div class="animate-pulse space-y-4">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Function to select a unit and load live view
|
||||||
|
function selectUnit(unitId) {
|
||||||
|
// Remove active state from all items
|
||||||
|
document.querySelectorAll('.slm-unit-item').forEach(item => {
|
||||||
|
item.classList.remove('bg-seismo-orange', 'text-white');
|
||||||
|
item.classList.add('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add active state to clicked item
|
||||||
|
event.currentTarget.classList.remove('bg-gray-100', 'dark:bg-gray-700');
|
||||||
|
event.currentTarget.classList.add('bg-seismo-orange', 'text-white');
|
||||||
|
|
||||||
|
// Load live view for this unit
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configuration modal functions
|
||||||
|
function openConfigModal(unitId) {
|
||||||
|
const modal = document.getElementById('config-modal');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Load configuration form via HTMX
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||||
|
target: '#config-modal-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeConfigModal() {
|
||||||
|
document.getElementById('config-modal').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close modal on escape key
|
||||||
|
document.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal when clicking outside
|
||||||
|
document.getElementById('config-modal')?.addEventListener('click', function(e) {
|
||||||
|
if (e.target === this) {
|
||||||
|
closeConfigModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize WebSocket for selected unit
|
||||||
|
let currentWebSocket = null;
|
||||||
|
|
||||||
|
function initLiveDataStream(unitId) {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
|
currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
currentWebSocket.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onmessage = async function(event) {
|
||||||
|
try {
|
||||||
|
let payload = event.data;
|
||||||
|
if (payload instanceof Blob) {
|
||||||
|
payload = await payload.text();
|
||||||
|
}
|
||||||
|
const data = typeof payload === 'string' ? JSON.parse(payload) : payload;
|
||||||
|
updateLiveChart(data);
|
||||||
|
updateLiveMetrics(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
currentWebSocket.onclose = function() {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'flex';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveDataStream() {
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
currentWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live chart with new data point
|
||||||
|
let chartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function updateLiveChart(data) {
|
||||||
|
const now = new Date();
|
||||||
|
chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
|
chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
|
chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
|
// Keep only last 60 data points (1 minute at 1 sample/sec)
|
||||||
|
if (chartData.timestamps.length > 60) {
|
||||||
|
chartData.timestamps.shift();
|
||||||
|
chartData.lp.shift();
|
||||||
|
chartData.leq.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart (using Chart.js if available)
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = chartData.leq;
|
||||||
|
window.liveChart.update('none'); // Update without animation for smooth real-time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLiveMetrics(data) {
|
||||||
|
// Update metric displays
|
||||||
|
if (document.getElementById('live-lp')) {
|
||||||
|
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-leq')) {
|
||||||
|
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmax')) {
|
||||||
|
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmin')) {
|
||||||
|
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (currentWebSocket) {
|
||||||
|
currentWebSocket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -121,13 +121,8 @@
|
|||||||
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||||
<p id="viewProjectContainer" class="mt-1">
|
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
<a id="viewProjectLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
||||||
<span id="viewProjectText">--</span>
|
|
||||||
</a>
|
|
||||||
<span id="viewProjectNoLink" class="text-gray-900 dark:text-white font-medium">Not assigned</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
@@ -153,12 +148,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||||
<p id="viewDeployedWithModemContainer" class="mt-1">
|
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
<a id="viewDeployedWithModemLink" href="#" class="text-seismo-orange hover:text-orange-600 font-medium hover:underline hidden">
|
|
||||||
<span id="viewDeployedWithModemText">--</span>
|
|
||||||
</a>
|
|
||||||
<span id="viewDeployedWithModemNoLink" class="text-gray-900 dark:text-white font-medium">--</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,48 +172,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Paired Device (for modems only) -->
|
|
||||||
<div id="viewPairedDeviceSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Paired Device</h3>
|
|
||||||
<div id="pairedDeviceInfo">
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Connectivity (for modems only) -->
|
|
||||||
<div id="viewConnectivitySection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Connectivity</h3>
|
|
||||||
<div class="flex items-center gap-4 mb-4">
|
|
||||||
<button onclick="pingModem()" id="modemPingBtn"
|
|
||||||
class="px-4 py-2 bg-blue-100 hover:bg-blue-200 text-blue-700 dark:bg-blue-900/30 dark:hover:bg-blue-900/50 dark:text-blue-300 rounded-lg flex items-center gap-2 transition-colors">
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
|
||||||
</svg>
|
|
||||||
Ping Test
|
|
||||||
</button>
|
|
||||||
<span id="modemPingResult" class="text-sm text-gray-500 dark:text-gray-400">--</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Future Diagnostics Placeholders -->
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 opacity-60">
|
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Signal Strength</p>
|
|
||||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- dBm</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Data Usage</p>
|
|
||||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">-- MB</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-gray-100 dark:bg-gray-700 rounded-lg p-4">
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400">Uptime</p>
|
|
||||||
<p class="text-xl font-semibold text-gray-400 dark:text-gray-500">--</p>
|
|
||||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-1">ModemManager pending</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notes -->
|
<!-- Notes -->
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
@@ -303,11 +251,11 @@
|
|||||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Project -->
|
<!-- Project ID -->
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
{% set picker_id = "-detail" %}
|
<input type="text" name="project_id" id="projectId"
|
||||||
{% include "partials/project_picker.html" with context %}
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Address -->
|
<!-- Address -->
|
||||||
@@ -341,20 +289,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<div class="flex gap-2">
|
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID"
|
||||||
<div class="flex-1">
|
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">
|
||||||
{% set picker_id = "-detail-seismo" %}
|
|
||||||
{% set input_name = "deployed_with_modem_id" %}
|
|
||||||
{% include "partials/modem_picker.html" with context %}
|
|
||||||
</div>
|
|
||||||
<button type="button" onclick="openPairDeviceModal('seismograph')"
|
|
||||||
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
||||||
title="Pair with modem">
|
|
||||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -428,20 +364,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="md:col-span-2">
|
<div class="md:col-span-2">
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
<div class="flex gap-2">
|
<select name="deployed_with_modem_id" id="slmDeployedWithModemId"
|
||||||
<div class="flex-1">
|
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">
|
||||||
{% set picker_id = "-detail-slm" %}
|
<option value="">No modem assigned</option>
|
||||||
{% set input_name = "deployed_with_modem_id" %}
|
<!-- Options will be populated by JavaScript -->
|
||||||
{% include "partials/modem_picker.html" with context %}
|
</select>
|
||||||
</div>
|
|
||||||
<button type="button" onclick="openPairDeviceModal('sound_level_meter')"
|
|
||||||
class="px-3 py-2 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors flex items-center gap-1"
|
|
||||||
title="Pair with modem">
|
|
||||||
<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="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select the modem that provides network connectivity for this SLM</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -468,54 +395,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"></textarea>
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Cascade to Paired Device Section -->
|
|
||||||
<div id="detailCascadeSection" class="hidden border-t border-gray-200 dark:border-gray-700 pt-4">
|
|
||||||
<div class="flex items-center gap-2 mb-3">
|
|
||||||
<svg class="w-5 h-5 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"></path>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm font-semibold text-gray-700 dark:text-gray-300">
|
|
||||||
Also update paired device: <span id="detailPairedDeviceName" class="text-seismo-orange"></span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input type="hidden" name="cascade_to_unit_id" id="detailCascadeToUnitId" value="">
|
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 gap-2 bg-gray-50 dark:bg-slate-700/50 rounded-lg p-3">
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_deployed" id="detailCascadeDeployed" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed status</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_retired" id="detailCascadeRetired" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Retired status</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_project" id="detailCascadeProject" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Project</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_location" id="detailCascadeLocation" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Address</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_coordinates" id="detailCascadeCoordinates" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Coordinates</span>
|
|
||||||
</label>
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="cascade_note" id="detailCascadeNote" value="true"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">Notes</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
|
||||||
Check the fields you want to sync to the paired device
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Save/Cancel Buttons -->
|
<!-- Save/Cancel Buttons -->
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
@@ -536,64 +415,6 @@ let currentSnapshot = null;
|
|||||||
let unitMap = null;
|
let unitMap = null;
|
||||||
let mapMarker = null;
|
let mapMarker = null;
|
||||||
|
|
||||||
// Fetch project display name (combines project_number, client_name, name)
|
|
||||||
async function fetchProjectDisplay(projectId) {
|
|
||||||
if (!projectId) return '';
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/projects/${projectId}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const project = await response.json();
|
|
||||||
const parts = [
|
|
||||||
project.project_number,
|
|
||||||
project.client_name,
|
|
||||||
project.name
|
|
||||||
].filter(Boolean);
|
|
||||||
return parts.join(' - ') || projectId;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch project:', e);
|
|
||||||
}
|
|
||||||
return projectId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch modem display name (combines id, ip_address, hardware_model)
|
|
||||||
// Also returns the actual modem ID if found (for updating picker value)
|
|
||||||
async function fetchModemDisplay(modemIdOrIp) {
|
|
||||||
if (!modemIdOrIp) return { display: '', modemId: '' };
|
|
||||||
try {
|
|
||||||
// First try direct lookup by ID
|
|
||||||
let response = await fetch(`/api/roster/${encodeURIComponent(modemIdOrIp)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
const modem = await response.json();
|
|
||||||
const parts = [modem.id];
|
|
||||||
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
|
||||||
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
|
||||||
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
// If not found, maybe it's an IP address - search for it
|
|
||||||
response = await fetch(`/api/roster/search/modems?q=${encodeURIComponent(modemIdOrIp)}`);
|
|
||||||
if (response.ok) {
|
|
||||||
// The search returns HTML, so we need to look up differently
|
|
||||||
// Try fetching all modems and find by IP
|
|
||||||
const modemsResponse = await fetch('/api/roster/modems');
|
|
||||||
if (modemsResponse.ok) {
|
|
||||||
const modems = await modemsResponse.json();
|
|
||||||
const modem = modems.find(m => m.ip_address === modemIdOrIp);
|
|
||||||
if (modem) {
|
|
||||||
const parts = [modem.id];
|
|
||||||
if (modem.ip_address) parts.push(`(${modem.ip_address})`);
|
|
||||||
if (modem.hardware_model) parts.push(`- ${modem.hardware_model}`);
|
|
||||||
return { display: parts.join(' ') || modemIdOrIp, modemId: modem.id };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to fetch modem:', e);
|
|
||||||
}
|
|
||||||
return { display: modemIdOrIp, modemId: modemIdOrIp };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load unit data on page load
|
// Load unit data on page load
|
||||||
async function loadUnitData() {
|
async function loadUnitData() {
|
||||||
try {
|
try {
|
||||||
@@ -715,60 +536,14 @@ function populateViewMode() {
|
|||||||
// Basic info
|
// Basic info
|
||||||
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||||
|
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
||||||
// Project display with clickable link
|
|
||||||
const projectId = currentUnit.project_id;
|
|
||||||
const projectLink = document.getElementById('viewProjectLink');
|
|
||||||
const projectNoLink = document.getElementById('viewProjectNoLink');
|
|
||||||
const projectText = document.getElementById('viewProjectText');
|
|
||||||
|
|
||||||
if (projectId) {
|
|
||||||
// Fetch project display name and show link
|
|
||||||
fetchProjectDisplay(projectId).then(displayText => {
|
|
||||||
if (projectText) projectText.textContent = displayText;
|
|
||||||
if (projectLink) {
|
|
||||||
projectLink.href = `/projects/${projectId}`;
|
|
||||||
projectLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (projectNoLink) projectNoLink.classList.add('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (projectNoLink) {
|
|
||||||
projectNoLink.textContent = 'Not assigned';
|
|
||||||
projectNoLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (projectLink) projectLink.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
|
|
||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
||||||
|
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
|
||||||
// Deployed with modem - show as clickable link
|
|
||||||
const modemLink = document.getElementById('viewDeployedWithModemLink');
|
|
||||||
const modemNoLink = document.getElementById('viewDeployedWithModemNoLink');
|
|
||||||
const modemText = document.getElementById('viewDeployedWithModemText');
|
|
||||||
|
|
||||||
if (currentUnit.deployed_with_modem_id) {
|
|
||||||
// Fetch modem info to get the actual ID (in case stored as IP) and display text
|
|
||||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
||||||
if (modemText) modemText.textContent = result.display;
|
|
||||||
if (modemLink) {
|
|
||||||
modemLink.href = `/unit/${encodeURIComponent(result.modemId)}`;
|
|
||||||
modemLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (modemNoLink) modemNoLink.classList.add('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (modemNoLink) {
|
|
||||||
modemNoLink.textContent = '--';
|
|
||||||
modemNoLink.classList.remove('hidden');
|
|
||||||
}
|
|
||||||
if (modemLink) modemLink.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modem fields
|
// Modem fields
|
||||||
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
||||||
@@ -782,15 +557,9 @@ function populateViewMode() {
|
|||||||
if (currentUnit.device_type === 'modem') {
|
if (currentUnit.device_type === 'modem') {
|
||||||
document.getElementById('viewSeismographFields').classList.add('hidden');
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||||
document.getElementById('viewModemFields').classList.remove('hidden');
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||||
document.getElementById('viewPairedDeviceSection').classList.remove('hidden');
|
|
||||||
document.getElementById('viewConnectivitySection').classList.remove('hidden');
|
|
||||||
// Load paired device info
|
|
||||||
loadPairedDevice();
|
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||||
document.getElementById('viewModemFields').classList.add('hidden');
|
document.getElementById('viewModemFields').classList.add('hidden');
|
||||||
document.getElementById('viewPairedDeviceSection').classList.add('hidden');
|
|
||||||
document.getElementById('viewConnectivitySection').classList.add('hidden');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -798,22 +567,7 @@ function populateViewMode() {
|
|||||||
function populateEditForm() {
|
function populateEditForm() {
|
||||||
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||||
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||||
|
document.getElementById('projectId').value = currentUnit.project_id || '';
|
||||||
// Populate project picker (uses -detail suffix)
|
|
||||||
const projectPickerValue = document.getElementById('project-picker-value-detail');
|
|
||||||
const projectPickerSearch = document.getElementById('project-picker-search-detail');
|
|
||||||
const projectPickerClear = document.getElementById('project-picker-clear-detail');
|
|
||||||
if (projectPickerValue) projectPickerValue.value = currentUnit.project_id || '';
|
|
||||||
if (currentUnit.project_id) {
|
|
||||||
fetchProjectDisplay(currentUnit.project_id).then(displayText => {
|
|
||||||
if (projectPickerSearch) projectPickerSearch.value = displayText;
|
|
||||||
if (projectPickerClear) projectPickerClear.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (projectPickerSearch) projectPickerSearch.value = '';
|
|
||||||
if (projectPickerClear) projectPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
document.getElementById('address').value = currentUnit.address || '';
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
document.getElementById('deployed').checked = currentUnit.deployed;
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
@@ -823,24 +577,7 @@ function populateEditForm() {
|
|||||||
// Seismograph fields
|
// Seismograph fields
|
||||||
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||||
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
||||||
|
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
|
||||||
// Populate modem picker for seismograph (uses -detail-seismo suffix)
|
|
||||||
const modemPickerValue = document.getElementById('modem-picker-value-detail-seismo');
|
|
||||||
const modemPickerSearch = document.getElementById('modem-picker-search-detail-seismo');
|
|
||||||
const modemPickerClear = document.getElementById('modem-picker-clear-detail-seismo');
|
|
||||||
if (currentUnit.deployed_with_modem_id) {
|
|
||||||
// Fetch modem display info (handles both modem ID and IP address lookups)
|
|
||||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
||||||
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
|
||||||
if (modemPickerValue) modemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
|
||||||
if (modemPickerSearch) modemPickerSearch.value = result.display;
|
|
||||||
if (modemPickerClear) modemPickerClear.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (modemPickerValue) modemPickerValue.value = '';
|
|
||||||
if (modemPickerSearch) modemPickerSearch.value = '';
|
|
||||||
if (modemPickerClear) modemPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Modem fields
|
// Modem fields
|
||||||
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
||||||
@@ -854,69 +591,10 @@ function populateEditForm() {
|
|||||||
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
document.getElementById('slmFrequencyWeighting').value = currentUnit.slm_frequency_weighting || '';
|
||||||
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
document.getElementById('slmTimeWeighting').value = currentUnit.slm_time_weighting || '';
|
||||||
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
document.getElementById('slmMeasurementRange').value = currentUnit.slm_measurement_range || '';
|
||||||
|
document.getElementById('slmDeployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
|
||||||
// Populate modem picker for SLM (uses -detail-slm suffix)
|
|
||||||
const slmModemPickerValue = document.getElementById('modem-picker-value-detail-slm');
|
|
||||||
const slmModemPickerSearch = document.getElementById('modem-picker-search-detail-slm');
|
|
||||||
const slmModemPickerClear = document.getElementById('modem-picker-clear-detail-slm');
|
|
||||||
if (currentUnit.deployed_with_modem_id) {
|
|
||||||
// Fetch modem display info (handles both modem ID and IP address lookups)
|
|
||||||
fetchModemDisplay(currentUnit.deployed_with_modem_id).then(result => {
|
|
||||||
// Update the hidden value with the actual modem ID (in case it was stored as IP)
|
|
||||||
if (slmModemPickerValue) slmModemPickerValue.value = result.modemId || currentUnit.deployed_with_modem_id;
|
|
||||||
if (slmModemPickerSearch) slmModemPickerSearch.value = result.display;
|
|
||||||
if (slmModemPickerClear) slmModemPickerClear.classList.remove('hidden');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
if (slmModemPickerValue) slmModemPickerValue.value = '';
|
|
||||||
if (slmModemPickerSearch) slmModemPickerSearch.value = '';
|
|
||||||
if (slmModemPickerClear) slmModemPickerClear.classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show/hide fields based on device type
|
// Show/hide fields based on device type
|
||||||
toggleDetailFields();
|
toggleDetailFields();
|
||||||
|
|
||||||
// Check for paired device and show cascade section if applicable
|
|
||||||
checkAndShowCascadeSection();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for paired device and show/hide cascade section
|
|
||||||
async function checkAndShowCascadeSection() {
|
|
||||||
const cascadeSection = document.getElementById('detailCascadeSection');
|
|
||||||
const cascadeToUnitId = document.getElementById('detailCascadeToUnitId');
|
|
||||||
const pairedDeviceName = document.getElementById('detailPairedDeviceName');
|
|
||||||
|
|
||||||
if (!cascadeSection) return;
|
|
||||||
|
|
||||||
// Reset cascade section
|
|
||||||
cascadeSection.classList.add('hidden');
|
|
||||||
if (cascadeToUnitId) cascadeToUnitId.value = '';
|
|
||||||
if (pairedDeviceName) pairedDeviceName.textContent = '';
|
|
||||||
|
|
||||||
// Reset checkboxes
|
|
||||||
['detailCascadeDeployed', 'detailCascadeRetired', 'detailCascadeProject',
|
|
||||||
'detailCascadeLocation', 'detailCascadeCoordinates', 'detailCascadeNote'].forEach(id => {
|
|
||||||
const checkbox = document.getElementById(id);
|
|
||||||
if (checkbox) checkbox.checked = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
let pairedUnitId = null;
|
|
||||||
|
|
||||||
// Check based on device type
|
|
||||||
if (currentUnit.device_type === 'modem' && currentUnit.deployed_with_unit_id) {
|
|
||||||
// Modem is paired with a seismograph or SLM
|
|
||||||
pairedUnitId = currentUnit.deployed_with_unit_id;
|
|
||||||
} else if ((currentUnit.device_type === 'seismograph' || currentUnit.device_type === 'sound_level_meter') && currentUnit.deployed_with_modem_id) {
|
|
||||||
// Seismograph or SLM is paired with a modem
|
|
||||||
pairedUnitId = currentUnit.deployed_with_modem_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pairedUnitId) {
|
|
||||||
// Show cascade section
|
|
||||||
cascadeSection.classList.remove('hidden');
|
|
||||||
if (cascadeToUnitId) cascadeToUnitId.value = pairedUnitId;
|
|
||||||
if (pairedDeviceName) pairedDeviceName.textContent = pairedUnitId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Toggle device-specific fields
|
// Toggle device-specific fields
|
||||||
@@ -963,16 +641,6 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(this);
|
const formData = new FormData(this);
|
||||||
const deviceType = formData.get('device_type');
|
|
||||||
|
|
||||||
// Fix: FormData contains both modem picker hidden inputs (seismo and slm).
|
|
||||||
// We need to ensure only the correct one is submitted based on device type.
|
|
||||||
// Delete all deployed_with_modem_id entries and re-add the correct one.
|
|
||||||
const modemId = getCorrectModemPickerValue(deviceType);
|
|
||||||
formData.delete('deployed_with_modem_id');
|
|
||||||
if (modemId) {
|
|
||||||
formData.append('deployed_with_modem_id', modemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
@@ -994,19 +662,6 @@ document.getElementById('editForm').addEventListener('submit', async function(e)
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get the correct modem picker value based on device type
|
|
||||||
function getCorrectModemPickerValue(deviceType) {
|
|
||||||
if (deviceType === 'seismograph') {
|
|
||||||
const picker = document.getElementById('modem-picker-value-detail-seismo');
|
|
||||||
return picker ? picker.value : '';
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
const picker = document.getElementById('modem-picker-value-detail-slm');
|
|
||||||
return picker ? picker.value : '';
|
|
||||||
}
|
|
||||||
// Modems don't have a deployed_with_modem_id
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete unit
|
// Delete unit
|
||||||
async function deleteUnit() {
|
async function deleteUnit() {
|
||||||
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
@@ -1344,297 +999,10 @@ async function deleteHistoryEntry(historyId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load paired device info for modems
|
|
||||||
async function loadPairedDevice() {
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/modem-dashboard/${unitId}/paired-device-html`);
|
|
||||||
if (response.ok) {
|
|
||||||
const html = await response.text();
|
|
||||||
document.getElementById('pairedDeviceInfo').innerHTML = html;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading paired device:', error);
|
|
||||||
document.getElementById('pairedDeviceInfo').innerHTML = '<p class="text-red-500 text-sm">Failed to load paired device info</p>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ping modem and show result
|
|
||||||
async function pingModem() {
|
|
||||||
const btn = document.getElementById('modemPingBtn');
|
|
||||||
const resultSpan = document.getElementById('modemPingResult');
|
|
||||||
|
|
||||||
// Show loading state
|
|
||||||
const originalText = btn.innerHTML;
|
|
||||||
btn.innerHTML = `
|
|
||||||
<svg class="w-5 h-5 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
||||||
</svg>
|
|
||||||
Pinging...
|
|
||||||
`;
|
|
||||||
btn.disabled = true;
|
|
||||||
resultSpan.textContent = 'Testing connection...';
|
|
||||||
resultSpan.className = 'text-sm text-gray-500 dark:text-gray-400';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch(`/api/modem-dashboard/${unitId}/ping`);
|
|
||||||
const data = await response.json();
|
|
||||||
|
|
||||||
if (data.status === 'success') {
|
|
||||||
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-1"></span>Online (${data.response_time_ms}ms)`;
|
|
||||||
resultSpan.className = 'text-sm text-green-600 dark:text-green-400';
|
|
||||||
} else {
|
|
||||||
resultSpan.innerHTML = `<span class="inline-block w-2 h-2 bg-red-500 rounded-full mr-1"></span>${data.detail || 'Offline'}`;
|
|
||||||
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
resultSpan.textContent = 'Error: ' + error.message;
|
|
||||||
resultSpan.className = 'text-sm text-red-600 dark:text-red-400';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Restore button
|
|
||||||
btn.innerHTML = originalText;
|
|
||||||
btn.disabled = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load data when page loads
|
// Load data when page loads
|
||||||
loadUnitData().then(() => {
|
loadUnitData().then(() => {
|
||||||
loadPhotos();
|
loadPhotos();
|
||||||
loadUnitHistory();
|
loadUnitHistory();
|
||||||
});
|
});
|
||||||
|
|
||||||
// ===== Pair Device Modal Functions =====
|
|
||||||
let pairModalModems = []; // Cache loaded modems
|
|
||||||
let pairModalDeviceType = ''; // Current device type
|
|
||||||
|
|
||||||
function openPairDeviceModal(deviceType) {
|
|
||||||
const modal = document.getElementById('pairDeviceModal');
|
|
||||||
const searchInput = document.getElementById('pairModemSearch');
|
|
||||||
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
||||||
|
|
||||||
if (!modal) return;
|
|
||||||
|
|
||||||
pairModalDeviceType = deviceType;
|
|
||||||
|
|
||||||
// Reset search and filter
|
|
||||||
if (searchInput) searchInput.value = '';
|
|
||||||
if (hideBenchedCheckbox) hideBenchedCheckbox.checked = false;
|
|
||||||
|
|
||||||
// Show modal
|
|
||||||
modal.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Focus search input
|
|
||||||
setTimeout(() => {
|
|
||||||
if (searchInput) searchInput.focus();
|
|
||||||
}, 100);
|
|
||||||
|
|
||||||
// Load available modems
|
|
||||||
loadAvailableModems();
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePairDeviceModal() {
|
|
||||||
const modal = document.getElementById('pairDeviceModal');
|
|
||||||
if (modal) modal.classList.add('hidden');
|
|
||||||
pairModalModems = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadAvailableModems() {
|
|
||||||
const listContainer = document.getElementById('pairModemList');
|
|
||||||
listContainer.innerHTML = '<div class="text-center py-4"><div class="animate-spin inline-block w-6 h-6 border-2 border-seismo-orange border-t-transparent rounded-full"></div><p class="mt-2 text-sm text-gray-500">Loading modems...</p></div>';
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await fetch('/api/roster/modems');
|
|
||||||
if (!response.ok) throw new Error('Failed to load modems');
|
|
||||||
|
|
||||||
pairModalModems = await response.json();
|
|
||||||
|
|
||||||
if (pairModalModems.length === 0) {
|
|
||||||
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems found in roster</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Render the list
|
|
||||||
renderModemList();
|
|
||||||
} catch (error) {
|
|
||||||
listContainer.innerHTML = `<p class="text-center py-4 text-red-500">Error: ${error.message}</p>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPairModemList() {
|
|
||||||
renderModemList();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderModemList() {
|
|
||||||
const listContainer = document.getElementById('pairModemList');
|
|
||||||
const searchInput = document.getElementById('pairModemSearch');
|
|
||||||
const hideBenchedCheckbox = document.getElementById('pairHideBenched');
|
|
||||||
|
|
||||||
const searchTerm = (searchInput?.value || '').toLowerCase().trim();
|
|
||||||
const hideBenched = hideBenchedCheckbox?.checked || false;
|
|
||||||
|
|
||||||
// Filter modems
|
|
||||||
let filteredModems = pairModalModems.filter(modem => {
|
|
||||||
// Filter by benched status
|
|
||||||
if (hideBenched && !modem.deployed) return false;
|
|
||||||
|
|
||||||
// Filter by search term
|
|
||||||
if (searchTerm) {
|
|
||||||
const searchFields = [
|
|
||||||
modem.id,
|
|
||||||
modem.ip_address || '',
|
|
||||||
modem.phone_number || '',
|
|
||||||
modem.note || ''
|
|
||||||
].join(' ').toLowerCase();
|
|
||||||
if (!searchFields.includes(searchTerm)) return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (filteredModems.length === 0) {
|
|
||||||
listContainer.innerHTML = '<p class="text-center py-4 text-gray-500">No modems match your criteria</p>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build modem list
|
|
||||||
let html = '';
|
|
||||||
for (const modem of filteredModems) {
|
|
||||||
const displayParts = [modem.id];
|
|
||||||
if (modem.ip_address) displayParts.push(`(${modem.ip_address})`);
|
|
||||||
if (modem.note) displayParts.push(`- ${modem.note.substring(0, 30)}${modem.note.length > 30 ? '...' : ''}`);
|
|
||||||
|
|
||||||
const deployedBadge = modem.deployed
|
|
||||||
? '<span class="px-2 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300 text-xs rounded">Deployed</span>'
|
|
||||||
: '<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 text-xs rounded">Benched</span>';
|
|
||||||
|
|
||||||
const pairedBadge = modem.deployed_with_unit_id
|
|
||||||
? `<span class="px-2 py-0.5 bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs rounded">Paired: ${modem.deployed_with_unit_id}</span>`
|
|
||||||
: '';
|
|
||||||
|
|
||||||
html += `
|
|
||||||
<div class="flex items-center justify-between p-3 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer border-b border-gray-100 dark:border-gray-700 last:border-0"
|
|
||||||
onclick="selectModemForPairing('${modem.id}', '${displayParts.join(' ').replace(/'/g, "\\'")}')">
|
|
||||||
<div class="flex-1">
|
|
||||||
<div class="font-medium text-gray-900 dark:text-white">
|
|
||||||
<span class="text-seismo-orange">${modem.id}</span>
|
|
||||||
${modem.ip_address ? `<span class="text-gray-400 ml-2 font-mono text-sm">${modem.ip_address}</span>` : ''}
|
|
||||||
</div>
|
|
||||||
${modem.note ? `<div class="text-sm text-gray-500 dark:text-gray-400 truncate">${modem.note}</div>` : ''}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 ml-3">
|
|
||||||
${deployedBadge}
|
|
||||||
${pairedBadge}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
listContainer.innerHTML = html;
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectModemForPairing(modemId, displayText) {
|
|
||||||
// Update the correct picker based on device type
|
|
||||||
let pickerId = '';
|
|
||||||
if (pairModalDeviceType === 'seismograph') {
|
|
||||||
pickerId = '-detail-seismo';
|
|
||||||
} else if (pairModalDeviceType === 'sound_level_meter') {
|
|
||||||
pickerId = '-detail-slm';
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
|
||||||
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
|
||||||
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
|
||||||
|
|
||||||
if (valueInput) valueInput.value = modemId;
|
|
||||||
if (searchInput) searchInput.value = displayText;
|
|
||||||
if (clearBtn) clearBtn.classList.remove('hidden');
|
|
||||||
|
|
||||||
// Close modal
|
|
||||||
closePairDeviceModal();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear pairing (unpair device from modem)
|
|
||||||
function clearPairing(deviceType) {
|
|
||||||
let pickerId = '';
|
|
||||||
if (deviceType === 'seismograph') {
|
|
||||||
pickerId = '-detail-seismo';
|
|
||||||
} else if (deviceType === 'sound_level_meter') {
|
|
||||||
pickerId = '-detail-slm';
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueInput = document.getElementById('modem-picker-value' + pickerId);
|
|
||||||
const searchInput = document.getElementById('modem-picker-search' + pickerId);
|
|
||||||
const clearBtn = document.getElementById('modem-picker-clear' + pickerId);
|
|
||||||
|
|
||||||
if (valueInput) valueInput.value = '';
|
|
||||||
if (searchInput) searchInput.value = '';
|
|
||||||
if (clearBtn) clearBtn.classList.add('hidden');
|
|
||||||
|
|
||||||
closePairDeviceModal();
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Pair Device Modal -->
|
|
||||||
<div id="pairDeviceModal" class="hidden fixed inset-0 z-50 overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-center min-h-screen px-4">
|
|
||||||
<!-- Backdrop -->
|
|
||||||
<div class="fixed inset-0 bg-black/50" onclick="closePairDeviceModal()"></div>
|
|
||||||
|
|
||||||
<!-- Modal Content -->
|
|
||||||
<div class="relative bg-white dark:bg-slate-800 rounded-xl shadow-xl max-w-lg w-full max-h-[80vh] overflow-hidden">
|
|
||||||
<!-- Header -->
|
|
||||||
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Pair with Modem</h3>
|
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Select a modem to pair with this device</p>
|
|
||||||
</div>
|
|
||||||
<button onclick="closePairDeviceModal()" 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>
|
|
||||||
|
|
||||||
<!-- Search and Filter -->
|
|
||||||
<div class="px-6 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900/50">
|
|
||||||
<div class="flex gap-3 items-center">
|
|
||||||
<!-- Search Input -->
|
|
||||||
<div class="flex-1 relative">
|
|
||||||
<input type="text"
|
|
||||||
id="pairModemSearch"
|
|
||||||
placeholder="Search by ID, IP, or note..."
|
|
||||||
oninput="filterPairModemList()"
|
|
||||||
class="w-full px-4 py-2 pl-10 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-seismo-orange text-sm">
|
|
||||||
<svg class="w-5 h-5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<!-- Hide Benched Toggle -->
|
|
||||||
<label class="flex items-center gap-2 cursor-pointer whitespace-nowrap">
|
|
||||||
<input type="checkbox"
|
|
||||||
id="pairHideBenched"
|
|
||||||
onchange="filterPairModemList()"
|
|
||||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">Deployed only</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Modem List -->
|
|
||||||
<div id="pairModemList" class="max-h-80 overflow-y-auto">
|
|
||||||
<!-- Populated by JavaScript -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Footer -->
|
|
||||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-900">
|
|
||||||
<button onclick="closePairDeviceModal()" class="w-full px-4 py-2 bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors">
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Include Project Create Modal for inline project creation -->
|
|
||||||
{% include "partials/project_create_modal.html" %}
|
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
Before Width: | Height: | Size: 36 KiB |
@@ -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()
|
|
||||||
764
backend/main.py
@@ -1,764 +0,0 @@
|
|||||||
import os
|
|
||||||
import logging
|
|
||||||
from fastapi import FastAPI, Request, Depends, HTTPException
|
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
|
||||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
|
||||||
from fastapi.exceptions import RequestValidationError
|
|
||||||
from sqlalchemy.orm import Session
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from pydantic import BaseModel
|
|
||||||
|
|
||||||
# Configure logging
|
|
||||||
logging.basicConfig(
|
|
||||||
level=logging.INFO,
|
|
||||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
||||||
)
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
|
||||||
from backend.routers import roster, units, photos, roster_edit, roster_rename, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard, projects, project_locations, scheduler, modem_dashboard
|
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
|
||||||
from backend.models import IgnoredUnit
|
|
||||||
from backend.utils.timezone import get_user_timezone
|
|
||||||
|
|
||||||
# Create database tables
|
|
||||||
Base.metadata.create_all(bind=engine)
|
|
||||||
|
|
||||||
# Read environment (development or production)
|
|
||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
|
||||||
|
|
||||||
# Initialize FastAPI app
|
|
||||||
VERSION = "0.5.1"
|
|
||||||
app = FastAPI(
|
|
||||||
title="Seismo Fleet Manager",
|
|
||||||
description="Backend API for managing seismograph fleet status",
|
|
||||||
version=VERSION
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add validation error handler to log details
|
|
||||||
@app.exception_handler(RequestValidationError)
|
|
||||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
|
||||||
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
|
||||||
logger.error(f"Body: {await request.body()}")
|
|
||||||
return JSONResponse(
|
|
||||||
status_code=400,
|
|
||||||
content={"detail": exc.errors()}
|
|
||||||
)
|
|
||||||
|
|
||||||
# Configure CORS
|
|
||||||
app.add_middleware(
|
|
||||||
CORSMiddleware,
|
|
||||||
allow_origins=["*"],
|
|
||||||
allow_credentials=True,
|
|
||||||
allow_methods=["*"],
|
|
||||||
allow_headers=["*"],
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mount static files
|
|
||||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
|
||||||
|
|
||||||
# Use shared templates configuration with timezone filters
|
|
||||||
from backend.templates_config import templates
|
|
||||||
|
|
||||||
# Add custom context processor to inject environment variable into all templates
|
|
||||||
@app.middleware("http")
|
|
||||||
async def add_environment_to_context(request: Request, call_next):
|
|
||||||
"""Middleware to add environment variable to request state"""
|
|
||||||
request.state.environment = ENVIRONMENT
|
|
||||||
response = await call_next(request)
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Override TemplateResponse to include environment and version in context
|
|
||||||
original_template_response = templates.TemplateResponse
|
|
||||||
def custom_template_response(name, context=None, *args, **kwargs):
|
|
||||||
if context is None:
|
|
||||||
context = {}
|
|
||||||
context["environment"] = ENVIRONMENT
|
|
||||||
context["version"] = VERSION
|
|
||||||
return original_template_response(name, context, *args, **kwargs)
|
|
||||||
templates.TemplateResponse = custom_template_response
|
|
||||||
|
|
||||||
# Include API routers
|
|
||||||
app.include_router(roster.router)
|
|
||||||
app.include_router(units.router)
|
|
||||||
app.include_router(photos.router)
|
|
||||||
app.include_router(roster_edit.router)
|
|
||||||
app.include_router(roster_rename.router)
|
|
||||||
app.include_router(dashboard.router)
|
|
||||||
app.include_router(dashboard_tabs.router)
|
|
||||||
app.include_router(activity.router)
|
|
||||||
app.include_router(slmm.router)
|
|
||||||
app.include_router(slm_ui.router)
|
|
||||||
app.include_router(slm_dashboard.router)
|
|
||||||
app.include_router(seismo_dashboard.router)
|
|
||||||
app.include_router(modem_dashboard.router)
|
|
||||||
|
|
||||||
from backend.routers import settings
|
|
||||||
app.include_router(settings.router)
|
|
||||||
|
|
||||||
# Projects system routers
|
|
||||||
app.include_router(projects.router)
|
|
||||||
app.include_router(project_locations.router)
|
|
||||||
app.include_router(scheduler.router)
|
|
||||||
|
|
||||||
# Report templates router
|
|
||||||
from backend.routers import report_templates
|
|
||||||
app.include_router(report_templates.router)
|
|
||||||
|
|
||||||
# Alerts router
|
|
||||||
from backend.routers import alerts
|
|
||||||
app.include_router(alerts.router)
|
|
||||||
|
|
||||||
# Recurring schedules router
|
|
||||||
from backend.routers import recurring_schedules
|
|
||||||
app.include_router(recurring_schedules.router)
|
|
||||||
|
|
||||||
# Start scheduler service and device status monitor on application startup
|
|
||||||
from backend.services.scheduler import start_scheduler, stop_scheduler
|
|
||||||
from backend.services.device_status_monitor import start_device_status_monitor, stop_device_status_monitor
|
|
||||||
|
|
||||||
@app.on_event("startup")
|
|
||||||
async def startup_event():
|
|
||||||
"""Initialize services on app startup"""
|
|
||||||
logger.info("Starting scheduler service...")
|
|
||||||
await start_scheduler()
|
|
||||||
logger.info("Scheduler service started")
|
|
||||||
|
|
||||||
logger.info("Starting device status monitor...")
|
|
||||||
await start_device_status_monitor()
|
|
||||||
logger.info("Device status monitor started")
|
|
||||||
|
|
||||||
@app.on_event("shutdown")
|
|
||||||
def shutdown_event():
|
|
||||||
"""Clean up services on app shutdown"""
|
|
||||||
logger.info("Stopping device status monitor...")
|
|
||||||
stop_device_status_monitor()
|
|
||||||
logger.info("Device status monitor stopped")
|
|
||||||
|
|
||||||
logger.info("Stopping scheduler service...")
|
|
||||||
stop_scheduler()
|
|
||||||
logger.info("Scheduler service stopped")
|
|
||||||
|
|
||||||
|
|
||||||
# Legacy routes from the original backend
|
|
||||||
from backend import routes as legacy_routes
|
|
||||||
app.include_router(legacy_routes.router)
|
|
||||||
|
|
||||||
|
|
||||||
# HTML page routes
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
|
||||||
async def dashboard(request: Request):
|
|
||||||
"""Dashboard home page"""
|
|
||||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/roster", response_class=HTMLResponse)
|
|
||||||
async def roster_page(request: Request):
|
|
||||||
"""Fleet roster page"""
|
|
||||||
return templates.TemplateResponse("roster.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/unit/{unit_id}", response_class=HTMLResponse)
|
|
||||||
async def unit_detail_page(request: Request, unit_id: str):
|
|
||||||
"""Unit detail page"""
|
|
||||||
return templates.TemplateResponse("unit_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"unit_id": unit_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/settings", response_class=HTMLResponse)
|
|
||||||
async def settings_page(request: Request):
|
|
||||||
"""Settings page for roster management"""
|
|
||||||
return templates.TemplateResponse("settings.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/sound-level-meters", response_class=HTMLResponse)
|
|
||||||
async def sound_level_meters_page(request: Request):
|
|
||||||
"""Sound Level Meters management dashboard"""
|
|
||||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/slm/{unit_id}", response_class=HTMLResponse)
|
|
||||||
async def slm_legacy_dashboard(
|
|
||||||
request: Request,
|
|
||||||
unit_id: str,
|
|
||||||
from_project: Optional[str] = None,
|
|
||||||
from_nrl: Optional[str] = None,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""Legacy SLM control center dashboard for a specific unit"""
|
|
||||||
# Get project details if from_project is provided
|
|
||||||
project = None
|
|
||||||
if from_project:
|
|
||||||
from backend.models import Project
|
|
||||||
project = db.query(Project).filter_by(id=from_project).first()
|
|
||||||
|
|
||||||
# Get NRL location details if from_nrl is provided
|
|
||||||
nrl_location = None
|
|
||||||
if from_nrl:
|
|
||||||
from backend.models import NRLLocation
|
|
||||||
nrl_location = db.query(NRLLocation).filter_by(id=from_nrl).first()
|
|
||||||
|
|
||||||
return templates.TemplateResponse("slm_legacy_dashboard.html", {
|
|
||||||
"request": request,
|
|
||||||
"unit_id": unit_id,
|
|
||||||
"from_project": from_project,
|
|
||||||
"from_nrl": from_nrl,
|
|
||||||
"project": project,
|
|
||||||
"nrl_location": nrl_location
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/seismographs", response_class=HTMLResponse)
|
|
||||||
async def seismographs_page(request: Request):
|
|
||||||
"""Seismographs management dashboard"""
|
|
||||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/modems", response_class=HTMLResponse)
|
|
||||||
async def modems_page(request: Request):
|
|
||||||
"""Field modems management dashboard"""
|
|
||||||
return templates.TemplateResponse("modems.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/pair-devices", response_class=HTMLResponse)
|
|
||||||
async def pair_devices_page(request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""
|
|
||||||
Device pairing page - two-column layout for pairing recorders with modems.
|
|
||||||
"""
|
|
||||||
from backend.models import RosterUnit
|
|
||||||
|
|
||||||
# Get all non-retired recorders (seismographs and SLMs)
|
|
||||||
recorders = db.query(RosterUnit).filter(
|
|
||||||
RosterUnit.retired == False,
|
|
||||||
RosterUnit.device_type.in_(["seismograph", "slm", None]) # None defaults to seismograph
|
|
||||||
).order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
# Get all non-retired modems
|
|
||||||
modems = db.query(RosterUnit).filter(
|
|
||||||
RosterUnit.retired == False,
|
|
||||||
RosterUnit.device_type == "modem"
|
|
||||||
).order_by(RosterUnit.id).all()
|
|
||||||
|
|
||||||
# Build existing pairings list
|
|
||||||
pairings = []
|
|
||||||
for recorder in recorders:
|
|
||||||
if recorder.deployed_with_modem_id:
|
|
||||||
modem = next((m for m in modems if m.id == recorder.deployed_with_modem_id), None)
|
|
||||||
pairings.append({
|
|
||||||
"recorder_id": recorder.id,
|
|
||||||
"recorder_type": (recorder.device_type or "seismograph").upper(),
|
|
||||||
"modem_id": recorder.deployed_with_modem_id,
|
|
||||||
"modem_ip": modem.ip_address if modem else None
|
|
||||||
})
|
|
||||||
|
|
||||||
# Convert to dicts for template
|
|
||||||
recorders_data = [
|
|
||||||
{
|
|
||||||
"id": r.id,
|
|
||||||
"device_type": r.device_type or "seismograph",
|
|
||||||
"deployed": r.deployed,
|
|
||||||
"deployed_with_modem_id": r.deployed_with_modem_id
|
|
||||||
}
|
|
||||||
for r in recorders
|
|
||||||
]
|
|
||||||
|
|
||||||
modems_data = [
|
|
||||||
{
|
|
||||||
"id": m.id,
|
|
||||||
"deployed": m.deployed,
|
|
||||||
"deployed_with_unit_id": m.deployed_with_unit_id,
|
|
||||||
"ip_address": m.ip_address,
|
|
||||||
"phone_number": m.phone_number
|
|
||||||
}
|
|
||||||
for m in modems
|
|
||||||
]
|
|
||||||
|
|
||||||
return templates.TemplateResponse("pair_devices.html", {
|
|
||||||
"request": request,
|
|
||||||
"recorders": recorders_data,
|
|
||||||
"modems": modems_data,
|
|
||||||
"pairings": pairings
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects", response_class=HTMLResponse)
|
|
||||||
async def projects_page(request: Request):
|
|
||||||
"""Projects management and overview"""
|
|
||||||
return templates.TemplateResponse("projects/overview.html", {"request": request})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}", response_class=HTMLResponse)
|
|
||||||
async def project_detail_page(request: Request, project_id: str):
|
|
||||||
"""Project detail dashboard"""
|
|
||||||
return templates.TemplateResponse("projects/detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"project_id": project_id
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/projects/{project_id}/nrl/{location_id}", response_class=HTMLResponse)
|
|
||||||
async def nrl_detail_page(
|
|
||||||
request: Request,
|
|
||||||
project_id: str,
|
|
||||||
location_id: str,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""NRL (Noise Recording Location) detail page with tabs"""
|
|
||||||
from backend.models import Project, MonitoringLocation, UnitAssignment, RosterUnit, RecordingSession, DataFile
|
|
||||||
from sqlalchemy import and_
|
|
||||||
|
|
||||||
# Get project
|
|
||||||
project = db.query(Project).filter_by(id=project_id).first()
|
|
||||||
if not project:
|
|
||||||
return templates.TemplateResponse("404.html", {
|
|
||||||
"request": request,
|
|
||||||
"message": "Project not found"
|
|
||||||
}, status_code=404)
|
|
||||||
|
|
||||||
# Get location
|
|
||||||
location = db.query(MonitoringLocation).filter_by(
|
|
||||||
id=location_id,
|
|
||||||
project_id=project_id
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if not location:
|
|
||||||
return templates.TemplateResponse("404.html", {
|
|
||||||
"request": request,
|
|
||||||
"message": "Location not found"
|
|
||||||
}, status_code=404)
|
|
||||||
|
|
||||||
# Get active assignment
|
|
||||||
assignment = db.query(UnitAssignment).filter(
|
|
||||||
and_(
|
|
||||||
UnitAssignment.location_id == location_id,
|
|
||||||
UnitAssignment.status == "active"
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
assigned_unit = None
|
|
||||||
if assignment:
|
|
||||||
assigned_unit = db.query(RosterUnit).filter_by(id=assignment.unit_id).first()
|
|
||||||
|
|
||||||
# Get session count
|
|
||||||
session_count = db.query(RecordingSession).filter_by(location_id=location_id).count()
|
|
||||||
|
|
||||||
# Get file count (DataFile links to session, not directly to location)
|
|
||||||
file_count = db.query(DataFile).join(
|
|
||||||
RecordingSession,
|
|
||||||
DataFile.session_id == RecordingSession.id
|
|
||||||
).filter(RecordingSession.location_id == location_id).count()
|
|
||||||
|
|
||||||
# Check for active session
|
|
||||||
active_session = db.query(RecordingSession).filter(
|
|
||||||
and_(
|
|
||||||
RecordingSession.location_id == location_id,
|
|
||||||
RecordingSession.status == "recording"
|
|
||||||
)
|
|
||||||
).first()
|
|
||||||
|
|
||||||
return templates.TemplateResponse("nrl_detail.html", {
|
|
||||||
"request": request,
|
|
||||||
"project_id": project_id,
|
|
||||||
"location_id": location_id,
|
|
||||||
"project": project,
|
|
||||||
"location": location,
|
|
||||||
"assignment": assignment,
|
|
||||||
"assigned_unit": assigned_unit,
|
|
||||||
"session_count": session_count,
|
|
||||||
"file_count": file_count,
|
|
||||||
"active_session": active_session,
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
# ===== PWA ROUTES =====
|
|
||||||
|
|
||||||
@app.get("/sw.js")
|
|
||||||
async def service_worker():
|
|
||||||
"""Serve service worker with proper headers for PWA"""
|
|
||||||
return FileResponse(
|
|
||||||
"backend/static/sw.js",
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={
|
|
||||||
"Service-Worker-Allowed": "/",
|
|
||||||
"Cache-Control": "no-cache"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/offline-db.js")
|
|
||||||
async def offline_db_script():
|
|
||||||
"""Serve offline database script"""
|
|
||||||
return FileResponse(
|
|
||||||
"backend/static/offline-db.js",
|
|
||||||
media_type="application/javascript",
|
|
||||||
headers={"Cache-Control": "no-cache"}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Pydantic model for sync edits
|
|
||||||
class EditItem(BaseModel):
|
|
||||||
id: int
|
|
||||||
unitId: str
|
|
||||||
changes: Dict
|
|
||||||
timestamp: int
|
|
||||||
|
|
||||||
|
|
||||||
class SyncEditsRequest(BaseModel):
|
|
||||||
edits: List[EditItem]
|
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/sync-edits")
|
|
||||||
async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)):
|
|
||||||
"""Process offline edit queue and sync to database"""
|
|
||||||
from backend.models import RosterUnit
|
|
||||||
|
|
||||||
results = []
|
|
||||||
synced_ids = []
|
|
||||||
|
|
||||||
for edit in request.edits:
|
|
||||||
try:
|
|
||||||
# Find the unit
|
|
||||||
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
|
||||||
|
|
||||||
if not unit:
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "error",
|
|
||||||
"reason": f"Unit {edit.unitId} not found"
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Apply changes
|
|
||||||
for key, value in edit.changes.items():
|
|
||||||
if hasattr(unit, key):
|
|
||||||
# Handle boolean conversions
|
|
||||||
if key in ['deployed', 'retired']:
|
|
||||||
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
|
||||||
else:
|
|
||||||
setattr(unit, key, value if value != '' else None)
|
|
||||||
|
|
||||||
db.commit()
|
|
||||||
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "success"
|
|
||||||
})
|
|
||||||
synced_ids.append(edit.id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
db.rollback()
|
|
||||||
results.append({
|
|
||||||
"id": edit.id,
|
|
||||||
"status": "error",
|
|
||||||
"reason": str(e)
|
|
||||||
})
|
|
||||||
|
|
||||||
synced_count = len(synced_ids)
|
|
||||||
|
|
||||||
return JSONResponse({
|
|
||||||
"synced": synced_count,
|
|
||||||
"total": len(request.edits),
|
|
||||||
"synced_ids": synced_ids,
|
|
||||||
"results": results
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
|
|
||||||
async def roster_deployed_partial(request: Request):
|
|
||||||
"""Partial template for deployed units tab"""
|
|
||||||
from datetime import datetime
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
units_list = []
|
|
||||||
for unit_id, unit_data in snapshot["active"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data.get("status", "Unknown"),
|
|
||||||
"age": unit_data.get("age", "N/A"),
|
|
||||||
"last_seen": unit_data.get("last", "Never"),
|
|
||||||
"deployed": unit_data.get("deployed", False),
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"project_id": unit_data.get("project_id", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by status priority (Missing > Pending > OK) then by ID
|
|
||||||
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
|
||||||
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/roster_table.html", {
|
|
||||||
"request": request,
|
|
||||||
"units": units_list,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/roster-benched", response_class=HTMLResponse)
|
|
||||||
async def roster_benched_partial(request: Request):
|
|
||||||
"""Partial template for benched units tab"""
|
|
||||||
from datetime import datetime
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
units_list = []
|
|
||||||
for unit_id, unit_data in snapshot["benched"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data.get("status", "N/A"),
|
|
||||||
"age": unit_data.get("age", "N/A"),
|
|
||||||
"last_seen": unit_data.get("last", "Never"),
|
|
||||||
"deployed": unit_data.get("deployed", False),
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"project_id": unit_data.get("project_id", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by ID
|
|
||||||
units_list.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/roster_table.html", {
|
|
||||||
"request": request,
|
|
||||||
"units": units_list,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/roster-retired", response_class=HTMLResponse)
|
|
||||||
async def roster_retired_partial(request: Request):
|
|
||||||
"""Partial template for retired units tab"""
|
|
||||||
from datetime import datetime
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
units_list = []
|
|
||||||
for unit_id, unit_data in snapshot["retired"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data["status"],
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"last_seen": unit_data["last"],
|
|
||||||
"deployed": unit_data["deployed"],
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by ID
|
|
||||||
units_list.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/retired_table.html", {
|
|
||||||
"request": request,
|
|
||||||
"units": units_list,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/roster-ignored", response_class=HTMLResponse)
|
|
||||||
async def roster_ignored_partial(request: Request, db: Session = Depends(get_db)):
|
|
||||||
"""Partial template for ignored units tab"""
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
ignored = db.query(IgnoredUnit).all()
|
|
||||||
ignored_list = []
|
|
||||||
for unit in ignored:
|
|
||||||
ignored_list.append({
|
|
||||||
"id": unit.id,
|
|
||||||
"reason": unit.reason or "",
|
|
||||||
"ignored_at": unit.ignored_at.strftime("%Y-%m-%d %H:%M:%S") if unit.ignored_at else "Unknown"
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by ID
|
|
||||||
ignored_list.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/ignored_table.html", {
|
|
||||||
"request": request,
|
|
||||||
"ignored_units": ignored_list,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/unknown-emitters", response_class=HTMLResponse)
|
|
||||||
async def unknown_emitters_partial(request: Request):
|
|
||||||
"""Partial template for unknown emitters (HTMX)"""
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
unknown_list = []
|
|
||||||
for unit_id, unit_data in snapshot.get("unknown", {}).items():
|
|
||||||
unknown_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data["status"],
|
|
||||||
"age": unit_data["age"],
|
|
||||||
"fname": unit_data.get("fname", ""),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by ID
|
|
||||||
unknown_list.sort(key=lambda x: x["id"])
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/unknown_emitters.html", {
|
|
||||||
"request": request,
|
|
||||||
"unknown_units": unknown_list
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/partials/devices-all", response_class=HTMLResponse)
|
|
||||||
async def devices_all_partial(request: Request):
|
|
||||||
"""Unified partial template for ALL devices with comprehensive filtering support"""
|
|
||||||
from datetime import datetime
|
|
||||||
snapshot = emit_status_snapshot()
|
|
||||||
|
|
||||||
units_list = []
|
|
||||||
|
|
||||||
# Add deployed/active units
|
|
||||||
for unit_id, unit_data in snapshot["active"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data.get("status", "Unknown"),
|
|
||||||
"age": unit_data.get("age", "N/A"),
|
|
||||||
"last_seen": unit_data.get("last", "Never"),
|
|
||||||
"deployed": True,
|
|
||||||
"retired": False,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"project_id": unit_data.get("project_id", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add benched units
|
|
||||||
for unit_id, unit_data in snapshot["benched"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": unit_data.get("status", "N/A"),
|
|
||||||
"age": unit_data.get("age", "N/A"),
|
|
||||||
"last_seen": unit_data.get("last", "Never"),
|
|
||||||
"deployed": False,
|
|
||||||
"retired": False,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"project_id": unit_data.get("project_id", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add retired units
|
|
||||||
for unit_id, unit_data in snapshot["retired"].items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": "Retired",
|
|
||||||
"age": "N/A",
|
|
||||||
"last_seen": "N/A",
|
|
||||||
"deployed": False,
|
|
||||||
"retired": True,
|
|
||||||
"ignored": False,
|
|
||||||
"note": unit_data.get("note", ""),
|
|
||||||
"device_type": unit_data.get("device_type", "seismograph"),
|
|
||||||
"address": unit_data.get("address", ""),
|
|
||||||
"coordinates": unit_data.get("coordinates", ""),
|
|
||||||
"project_id": unit_data.get("project_id", ""),
|
|
||||||
"last_calibrated": unit_data.get("last_calibrated"),
|
|
||||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
|
||||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
|
||||||
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
|
|
||||||
"ip_address": unit_data.get("ip_address"),
|
|
||||||
"phone_number": unit_data.get("phone_number"),
|
|
||||||
"hardware_model": unit_data.get("hardware_model"),
|
|
||||||
})
|
|
||||||
|
|
||||||
# Add ignored units
|
|
||||||
for unit_id, unit_data in snapshot.get("ignored", {}).items():
|
|
||||||
units_list.append({
|
|
||||||
"id": unit_id,
|
|
||||||
"status": "Ignored",
|
|
||||||
"age": "N/A",
|
|
||||||
"last_seen": "N/A",
|
|
||||||
"deployed": False,
|
|
||||||
"retired": False,
|
|
||||||
"ignored": True,
|
|
||||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
|
||||||
"device_type": unit_data.get("device_type", "unknown"),
|
|
||||||
"address": "",
|
|
||||||
"coordinates": "",
|
|
||||||
"project_id": "",
|
|
||||||
"last_calibrated": None,
|
|
||||||
"next_calibration_due": None,
|
|
||||||
"deployed_with_modem_id": None,
|
|
||||||
"deployed_with_unit_id": None,
|
|
||||||
"ip_address": None,
|
|
||||||
"phone_number": None,
|
|
||||||
"hardware_model": None,
|
|
||||||
})
|
|
||||||
|
|
||||||
# Sort by status category, then by ID
|
|
||||||
def sort_key(unit):
|
|
||||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
|
||||||
if unit["deployed"]:
|
|
||||||
return (0, unit["id"])
|
|
||||||
elif not unit["retired"] and not unit["ignored"]:
|
|
||||||
return (1, unit["id"])
|
|
||||||
elif unit["retired"]:
|
|
||||||
return (2, unit["id"])
|
|
||||||
else:
|
|
||||||
return (3, unit["id"])
|
|
||||||
|
|
||||||
units_list.sort(key=sort_key)
|
|
||||||
|
|
||||||
return templates.TemplateResponse("partials/devices_table.html", {
|
|
||||||
"request": request,
|
|
||||||
"units": units_list,
|
|
||||||
"timestamp": datetime.now().strftime("%H:%M:%S"),
|
|
||||||
"user_timezone": get_user_timezone()
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health")
|
|
||||||
def health_check():
|
|
||||||
"""Health check endpoint"""
|
|
||||||
return {
|
|
||||||
"message": f"Seismo Fleet Manager v{VERSION}",
|
|
||||||
"status": "running",
|
|
||||||
"version": VERSION
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
import uvicorn
|
|
||||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration: Add auto_increment_index column to recurring_schedules table
|
|
||||||
|
|
||||||
This migration adds the auto_increment_index column that controls whether
|
|
||||||
the scheduler should automatically find an unused store index before starting
|
|
||||||
a new measurement.
|
|
||||||
|
|
||||||
Run this script once to update existing databases:
|
|
||||||
python -m backend.migrate_add_auto_increment_index
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
DB_PATH = "data/seismo_fleet.db"
|
|
||||||
|
|
||||||
|
|
||||||
def migrate():
|
|
||||||
"""Add auto_increment_index column to recurring_schedules table."""
|
|
||||||
if not os.path.exists(DB_PATH):
|
|
||||||
print(f"Database not found at {DB_PATH}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Check if recurring_schedules table exists
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT name FROM sqlite_master
|
|
||||||
WHERE type='table' AND name='recurring_schedules'
|
|
||||||
""")
|
|
||||||
if not cursor.fetchone():
|
|
||||||
print("recurring_schedules table does not exist yet. Will be created on app startup.")
|
|
||||||
conn.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Check if auto_increment_index column already exists
|
|
||||||
cursor.execute("PRAGMA table_info(recurring_schedules)")
|
|
||||||
columns = [row[1] for row in cursor.fetchall()]
|
|
||||||
|
|
||||||
if "auto_increment_index" in columns:
|
|
||||||
print("auto_increment_index column already exists in recurring_schedules table.")
|
|
||||||
conn.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
# Add the column
|
|
||||||
print("Adding auto_increment_index column to recurring_schedules table...")
|
|
||||||
cursor.execute("""
|
|
||||||
ALTER TABLE recurring_schedules
|
|
||||||
ADD COLUMN auto_increment_index BOOLEAN DEFAULT 1
|
|
||||||
""")
|
|
||||||
conn.commit()
|
|
||||||
print("Successfully added auto_increment_index column.")
|
|
||||||
|
|
||||||
conn.close()
|
|
||||||
return True
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Migration failed: {e}")
|
|
||||||
conn.close()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
success = migrate()
|
|
||||||
exit(0 if success else 1)
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
"""
|
|
||||||
Migration script to add deployment_type and deployed_with_unit_id fields to roster table.
|
|
||||||
|
|
||||||
deployment_type: tracks what type of device a modem is deployed with:
|
|
||||||
- "seismograph" - Modem is connected to a seismograph
|
|
||||||
- "slm" - Modem is connected to a sound level meter
|
|
||||||
- NULL/empty - Not assigned or unknown
|
|
||||||
|
|
||||||
deployed_with_unit_id: stores the ID of the seismograph/SLM this modem is deployed with
|
|
||||||
(reverse relationship of deployed_with_modem_id)
|
|
||||||
|
|
||||||
Run this script once to migrate an existing database.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import os
|
|
||||||
|
|
||||||
# Database path
|
|
||||||
DB_PATH = "./data/seismo_fleet.db"
|
|
||||||
|
|
||||||
|
|
||||||
def migrate_database():
|
|
||||||
"""Add deployment_type and deployed_with_unit_id columns to roster table"""
|
|
||||||
|
|
||||||
if not os.path.exists(DB_PATH):
|
|
||||||
print(f"Database not found at {DB_PATH}")
|
|
||||||
print("The database will be created automatically when you run the application.")
|
|
||||||
return
|
|
||||||
|
|
||||||
print(f"Migrating database: {DB_PATH}")
|
|
||||||
|
|
||||||
conn = sqlite3.connect(DB_PATH)
|
|
||||||
cursor = conn.cursor()
|
|
||||||
|
|
||||||
# Check if roster table exists
|
|
||||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='roster'")
|
|
||||||
table_exists = cursor.fetchone()
|
|
||||||
|
|
||||||
if not table_exists:
|
|
||||||
print("Roster table does not exist yet - will be created when app runs")
|
|
||||||
conn.close()
|
|
||||||
return
|
|
||||||
|
|
||||||
# Check existing columns
|
|
||||||
cursor.execute("PRAGMA table_info(roster)")
|
|
||||||
columns = [col[1] for col in cursor.fetchall()]
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Add deployment_type if not exists
|
|
||||||
if 'deployment_type' not in columns:
|
|
||||||
print("Adding deployment_type column to roster table...")
|
|
||||||
cursor.execute("ALTER TABLE roster ADD COLUMN deployment_type TEXT")
|
|
||||||
print(" Added deployment_type column")
|
|
||||||
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployment_type ON roster(deployment_type)")
|
|
||||||
print(" Created index on deployment_type")
|
|
||||||
else:
|
|
||||||
print("deployment_type column already exists")
|
|
||||||
|
|
||||||
# Add deployed_with_unit_id if not exists
|
|
||||||
if 'deployed_with_unit_id' not in columns:
|
|
||||||
print("Adding deployed_with_unit_id column to roster table...")
|
|
||||||
cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_unit_id TEXT")
|
|
||||||
print(" Added deployed_with_unit_id column")
|
|
||||||
|
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS ix_roster_deployed_with_unit_id ON roster(deployed_with_unit_id)")
|
|
||||||
print(" Created index on deployed_with_unit_id")
|
|
||||||
else:
|
|
||||||
print("deployed_with_unit_id column already exists")
|
|
||||||
|
|
||||||
conn.commit()
|
|
||||||
print("\nMigration completed successfully!")
|
|
||||||
|
|
||||||
except sqlite3.Error as e:
|
|
||||||
print(f"\nError during migration: {e}")
|
|
||||||
conn.rollback()
|
|
||||||
raise
|
|
||||||
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
migrate_database()
|
|
||||||