Compare commits
45 Commits
c1bdf17454
...
1.0-experi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7b9d51151a | ||
|
|
7715123053 | ||
|
|
94354da611 | ||
|
|
5b907c0cd7 | ||
|
|
ff438c1197 | ||
|
|
16eb9eb1fe | ||
|
|
991aaca34b | ||
|
|
893cb96e8d | ||
|
|
c30d7fac22 | ||
|
|
6d34e543fe | ||
|
|
4d74eda65f | ||
|
|
96cb27ef83 | ||
|
|
85b211e532 | ||
|
|
e16f61aca7 | ||
|
|
dba4ad168c | ||
|
|
e78d252cf3 | ||
|
|
ab9c650d93 | ||
|
|
2d22d0d329 | ||
|
|
7d17d355a7 | ||
|
|
7c89d203d7 | ||
|
|
27f8719e33 | ||
|
|
d97999e26f | ||
|
|
191dceff2b | ||
|
|
6db958ffa6 | ||
|
|
3a41b81bb6 | ||
|
|
3aff0cb076 | ||
|
|
7cadd972be | ||
|
|
274e390c3e | ||
|
|
195df967e4 | ||
|
|
6fc8721830 | ||
|
|
690669c697 | ||
|
|
83593f7b33 | ||
|
|
4cef580185 | ||
|
|
dc853806bb | ||
|
|
802601ae8d | ||
|
|
e46f668c34 | ||
|
|
90ecada35f | ||
|
|
938e950dd6 | ||
|
|
a6ad9fdecf | ||
|
|
02a99ea47d | ||
|
|
247405c361 | ||
|
|
e7e660a9c3 | ||
|
|
36ce63feb1 | ||
|
|
05c63367c8 | ||
|
|
f976e4e893 |
41
.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Python cache / compiled
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# VCS
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Databases (must live in volumes)
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Environment / virtualenv
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Runtime data (mounted volumes)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Editors / OS junk
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude
|
||||||
|
sfm.code-workspace
|
||||||
|
|
||||||
|
# Tests (optional)
|
||||||
|
tests/
|
||||||
6
.gitignore
vendored
@@ -205,3 +205,9 @@ cython_debug/
|
|||||||
marimo/_static/
|
marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
data/
|
||||||
|
|||||||
450
CHANGELOG.md
Normal file
@@ -0,0 +1,450 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
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/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.5.0] - 2026-01-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Unified Modular Monolith Architecture**: Complete architectural refactoring to modular monolith pattern
|
||||||
|
- **Three Feature Modules**: Seismo (seismograph fleet), SLM (sound level meters), UI (shared templates/static)
|
||||||
|
- **Module Isolation**: Each module has its own database, models, services, and routers
|
||||||
|
- **Shared Infrastructure**: Common utilities and API aggregation layer
|
||||||
|
- **Multi-Container Deployment**: Three Docker containers (terra-view, sfm, slmm) built from single codebase
|
||||||
|
- **SLMM Integration**: Sound Level Meter Manager fully integrated as `app/slm/` module
|
||||||
|
- Migrated from separate repository to unified codebase
|
||||||
|
- Complete NL43 device management API (`/api/nl43/*`)
|
||||||
|
- 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
|
||||||
|
- **Project Structure**: Complete reorganization following modular monolith pattern
|
||||||
|
- `app/seismo/` - Seismograph fleet module (formerly `backend/`)
|
||||||
|
- `app/slm/` - Sound level meter module (integrated from SLMM)
|
||||||
|
- `app/ui/` - Shared templates and static assets
|
||||||
|
- `app/api/` - Cross-module API aggregation layer
|
||||||
|
- Removed `backend/` and `templates/` directories
|
||||||
|
- **Import Paths**: All imports updated from `backend.*` to `app.seismo.*` or `app.slm.*`
|
||||||
|
- **Database Initialization**: Each module initializes its own database tables
|
||||||
|
- Seismo database: `app/seismo/database.py`
|
||||||
|
- SLM database: `app/slm/database.py`
|
||||||
|
- **Docker Architecture**: Three-container deployment from single codebase
|
||||||
|
- `terra-view` (port 8001): Main UI/orchestrator with all modules
|
||||||
|
- `sfm` (port 8002): Seismograph Fleet Module API
|
||||||
|
- `slmm` (port 8100): Sound Level Meter Manager API
|
||||||
|
- All containers built from same unified codebase with different entry points
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Template Path Issues**: Fixed seismo dashboard template references
|
||||||
|
- 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
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
- **Breaking Changes**: Import paths changed for all modules
|
||||||
|
- Old: `from backend.models import RosterUnit`
|
||||||
|
- New: `from app.seismo.models import RosterUnit`
|
||||||
|
- **Configuration Updates**: Environment variables for multi-container setup
|
||||||
|
- `SFM_API_URL=http://localhost:8002` - SFM backend endpoint
|
||||||
|
- `SLMM_API_URL=http://localhost:8100` - SLMM backend endpoint
|
||||||
|
- `MODULE_MODE=sfm|slmm` - Future flag for API-only containers
|
||||||
|
- **Repository Migration**: Update git remotes for renamed repository
|
||||||
|
```bash
|
||||||
|
git remote set-url origin ssh://git@10.0.0.2:2222/serversdown/terra-view.git
|
||||||
|
```
|
||||||
|
|
||||||
|
## [0.4.2] - 2026-01-05
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SLM Configuration Interface**: Sound Level Meters can now be configured directly from the SLM dashboard
|
||||||
|
- Configuration modal with comprehensive SLM parameter editing
|
||||||
|
- TCP port configuration for SLM control connections (default: 2255)
|
||||||
|
- FTP port configuration for SLM data retrieval (default: 21)
|
||||||
|
- Modem assignment for network access or direct IP connection support
|
||||||
|
- Test Modem button with ping-based connectivity verification (shows IP and response time)
|
||||||
|
- Test SLM Connection button for end-to-end connectivity validation
|
||||||
|
- Dynamic form fields that hide/show based on modem selection
|
||||||
|
- **SLM Dashboard Endpoints**: New API routes for SLM management
|
||||||
|
- `GET /api/slm-dashboard/config/{unit_id}` - Load SLM configuration form
|
||||||
|
- `POST /api/slm-dashboard/config/{unit_id}` - Save SLM configuration
|
||||||
|
- `GET /api/slm-dashboard/test-modem/{modem_id}` - Ping modem for connectivity test
|
||||||
|
- **Database Schema Updates**: Added `slm_ftp_port` column to roster table
|
||||||
|
- Migration script: `scripts/add_slm_ftp_port.py`
|
||||||
|
- Supports both TCP (control) and FTP (data) port configuration per SLM unit
|
||||||
|
- **Docker Environment Enhancements**:
|
||||||
|
- Added `iputils-ping` and `curl` packages to Docker image for network diagnostics
|
||||||
|
- Health check endpoint support via curl
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Form Validation**: Fixed 400 Bad Request error when adding modem units
|
||||||
|
- Form fields for device-specific parameters now properly disabled when hidden
|
||||||
|
- Empty string values for integer fields no longer cause validation failures
|
||||||
|
- JavaScript now disables hidden form sections to prevent unwanted data submission
|
||||||
|
- **Unit Status Accuracy**: Fixed issue where unit status was loading from a saved cache instead of actual last-heard time
|
||||||
|
- Unit status now accurately reflects real-time connectivity
|
||||||
|
- Status determination based on actual `slm_last_check` timestamp
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Roster Form Behavior**: Device-specific form fields are now disabled (not just hidden) when not applicable
|
||||||
|
- Prevents SLM fields from submitting when adding modems
|
||||||
|
- Prevents modem fields from submitting when adding SLMs
|
||||||
|
- Cleaner form submissions with only relevant data
|
||||||
|
- **Port Field Handling**: Backend now accepts port fields as strings and converts to integers
|
||||||
|
- Handles empty string values gracefully
|
||||||
|
- Proper type conversion with None fallback for empty values
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Added `setFieldsDisabled()` helper function for managing form field state
|
||||||
|
- Updated `toggleDeviceFields()` and `toggleEditDeviceFields()` to disable/enable fields
|
||||||
|
- Backend type conversion: `slm_tcp_port` and `slm_ftp_port` accept strings, convert to int with empty string handling
|
||||||
|
- Modem ping uses subprocess with 1 packet, 2-second timeout, returns response time in milliseconds
|
||||||
|
- Configuration form uses 3-column grid layout for TCP Port, FTP Port, and Direct IP fields
|
||||||
|
|
||||||
|
## [0.4.1] - 2026-01-05
|
||||||
|
### Added
|
||||||
|
- **SLM Integration**: Sound Level Meters are now manageable in SFM
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Fixed an issue where unit status was loading from a saved cache and not based on when it was actually heard from last. Unit status is now accurate.
|
||||||
|
|
||||||
|
|
||||||
|
## [0.4.0] - 2025-12-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Database Management System**: Comprehensive backup and restore capabilities
|
||||||
|
- **Manual Snapshots**: Create on-demand backups of the entire database with optional descriptions
|
||||||
|
- **Restore from Snapshot**: Restore database from any snapshot with automatic safety backup
|
||||||
|
- **Upload/Download Snapshots**: Transfer database snapshots to/from the server
|
||||||
|
- **Database Tab**: New dedicated tab in Settings for all database management operations
|
||||||
|
- **Database Statistics**: View database size, row counts by table, and last modified time
|
||||||
|
- **Snapshot Metadata**: Each snapshot includes creation time, description, size, and type (manual/automatic)
|
||||||
|
- **Safety Backups**: Automatic backup created before any restore operation
|
||||||
|
- **Remote Database Cloning**: Dev tools for cloning production database to remote development servers
|
||||||
|
- **Clone Script**: `scripts/clone_db_to_dev.py` for copying database over WAN
|
||||||
|
- **Network Upload**: Upload snapshots via HTTP to remote servers
|
||||||
|
- **Auto-restore**: Automatically restore uploaded database on target server
|
||||||
|
- **Authentication Support**: Optional token-based authentication for secure transfers
|
||||||
|
- **Automatic Backup Scheduler**: Background service for automated database backups
|
||||||
|
- **Configurable Intervals**: Set backup frequency (default: 24 hours)
|
||||||
|
- **Retention Management**: Automatically delete old backups (configurable keep count)
|
||||||
|
- **Manual Trigger**: Force immediate backup via API
|
||||||
|
- **Status Monitoring**: Check scheduler status and next scheduled run time
|
||||||
|
- **Background Thread**: Non-blocking operation using Python threading
|
||||||
|
- **Settings Reorganization**: Improved tab structure for better organization
|
||||||
|
- Renamed "Data Management" tab to "Roster Management"
|
||||||
|
- Moved CSV Replace Mode from Advanced tab to Roster Management tab
|
||||||
|
- Created dedicated Database tab for all backup/restore operations
|
||||||
|
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` guide covering:
|
||||||
|
- Manual snapshot creation and restoration workflows
|
||||||
|
- Download/upload procedures for off-site backups
|
||||||
|
- Remote database cloning setup and usage
|
||||||
|
- Automatic backup configuration and integration
|
||||||
|
- API reference for all database endpoints
|
||||||
|
- Best practices and troubleshooting guide
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Settings Tab Organization**: Restructured for better logical grouping
|
||||||
|
- **General**: Display preferences (timezone, theme, auto-refresh)
|
||||||
|
- **Roster Management**: CSV operations and roster table (now includes Replace Mode)
|
||||||
|
- **Database**: All backup/restore operations (NEW)
|
||||||
|
- **Advanced**: Power user settings (calibration, thresholds)
|
||||||
|
- **Danger Zone**: Destructive operations
|
||||||
|
- CSV Replace Mode warnings enhanced and moved to Roster Management context
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- **SQLite Backup API**: Uses native SQLite backup API for concurrent-safe snapshots
|
||||||
|
- **Metadata Tracking**: JSON sidecar files store snapshot metadata alongside database files
|
||||||
|
- **Atomic Operations**: Database restoration is atomic with automatic rollback on failure
|
||||||
|
- **File Structure**: Snapshots stored in `./data/backups/` with timestamped filenames
|
||||||
|
- **API Endpoints**: 7 new endpoints for database management operations
|
||||||
|
- **Backup Service**: `backend/services/database_backup.py` - Core backup/restore logic
|
||||||
|
- **Scheduler Service**: `backend/services/backup_scheduler.py` - Automatic backup automation
|
||||||
|
- **Clone Utility**: `scripts/clone_db_to_dev.py` - Remote database synchronization tool
|
||||||
|
|
||||||
|
### Security Considerations
|
||||||
|
- Snapshots contain full database data and should be secured appropriately
|
||||||
|
- Remote cloning supports optional authentication tokens
|
||||||
|
- Restore operations require safety backup creation by default
|
||||||
|
- All destructive operations remain in Danger Zone with warnings
|
||||||
|
|
||||||
|
### Migration Notes
|
||||||
|
No database migration required for v0.4.0. All new features use existing database structure and add new backup management capabilities without modifying the core schema.
|
||||||
|
|
||||||
|
## [0.3.3] - 2025-12-12
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Mobile Navigation**: Moved hamburger menu button from floating top-right to bottom navigation bar
|
||||||
|
- Bottom nav now shows: Menu (hamburger), Dashboard, Roster, Settings
|
||||||
|
- Removed "Add Unit" from bottom nav (still accessible via sidebar menu)
|
||||||
|
- Hamburger no longer floats over content on mobile
|
||||||
|
- **Status Dot Visibility**: Increased status dot size from 12px to 16px (w-3/h-3 → w-4/h-4) in dashboard fleet overview for better at-a-glance visibility
|
||||||
|
- Affects both Active and Benched tabs in dashboard
|
||||||
|
- Makes status colors (green/yellow/red) easier to spot during quick scroll
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Location Navigation**: Moved tap-to-navigate functionality from roster card view to unit detail modal only
|
||||||
|
- Roster cards now show simple location text with pin emoji
|
||||||
|
- Navigation links (opening Maps app) only appear in the modal when tapping a unit
|
||||||
|
- Reduces visual clutter and accidental navigation triggers
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Bottom navigation remains at 4 buttons, first button now triggers sidebar menu
|
||||||
|
- Removed standalone hamburger button element and associated CSS
|
||||||
|
- Modal already had navigation links, no changes needed there
|
||||||
|
|
||||||
|
## [0.3.2] - 2025-12-12
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Progressive Web App (PWA) Mobile Optimization**: Complete mobile-first redesign for field deployment usage
|
||||||
|
- **Responsive Navigation**: Hamburger menu with slide-in sidebar for mobile, always-visible sidebar for desktop
|
||||||
|
- **Bottom Navigation Bar**: Quick access to Dashboard, Roster, Add Unit, and Settings (mobile only)
|
||||||
|
- **Mobile Card View**: Compact card layout for roster units with status dots, location, and project ID
|
||||||
|
- **Tap-to-Navigate**: Location addresses and coordinates are clickable and open in user's default navigation app (Google Maps, Apple Maps, Waze, etc.)
|
||||||
|
- **Unit Detail Modal**: Bottom sheet modal showing full unit details with edit capabilities (tap any unit card to open)
|
||||||
|
- **Touch Optimization**: 44x44px minimum button targets following iOS/Android accessibility guidelines
|
||||||
|
- **Service Worker**: Network-first caching strategy for offline-capable operation
|
||||||
|
- **IndexedDB Storage**: Offline data persistence for unit information and pending edits
|
||||||
|
- **Background Sync**: Queues edits made while offline and syncs automatically when connection returns
|
||||||
|
- **Offline Indicator**: Visual banner showing offline status with manual sync button
|
||||||
|
- **PWA Manifest**: Installable as a standalone app on mobile devices with custom icons
|
||||||
|
- **Hard Reload Button**: "Clear Cache & Reload" utility in sidebar menu to force fresh JavaScript/CSS
|
||||||
|
- **Mobile-Specific Files**:
|
||||||
|
- `backend/static/mobile.css` - Mobile UI styles, hamburger menu, bottom nav, cards, modals
|
||||||
|
- `backend/static/mobile.js` - Mobile interactions, offline sync, modal management
|
||||||
|
- `backend/static/sw.js` - Service worker for PWA functionality
|
||||||
|
- `backend/static/offline-db.js` - IndexedDB wrapper for offline storage
|
||||||
|
- `backend/static/manifest.json` - PWA configuration
|
||||||
|
- `backend/static/icons/` - 8 PWA icon sizes (72px-512px)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Dashboard Alerts**: Only show Missing units in notifications (Pending units no longer appear in alerts)
|
||||||
|
- **Roster Template**: Mobile card view shows status from server-side render instead of fetching separately
|
||||||
|
- **Mobile Status Display**: Benched units show "Benched" label instead of "Unknown" or "N/A"
|
||||||
|
- **Base Template**: Added cache-busting query parameters to JavaScript files (e.g., `mobile.js?v=0.3.2`)
|
||||||
|
- **Sidebar Menu**: Added utility section with "Toggle theme" and "Clear Cache & Reload" buttons
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Modal Status Display**: Fixed unit detail modal showing "Unknown" status by passing status data from card to modal
|
||||||
|
- **Mobile Card Status**: Fixed grey dot with "Unknown" label for benched units - now properly shows deployment state
|
||||||
|
- **Status Data Passing**: Roster cards now pass status and age to modal via function parameters and global status map
|
||||||
|
- **Service Worker Caching**: Aggressive browser caching issue resolved with version query parameters and hard reload function
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- Mobile breakpoint at 768px (`md:` prefix in TailwindCSS)
|
||||||
|
- PWA installable via Add to Home Screen on iOS/Android
|
||||||
|
- Service worker caches all static assets with network-first strategy
|
||||||
|
- Google Maps search API used for universal navigation links (works across all map apps)
|
||||||
|
- Status map stored in `window.rosterStatusMap` from server-side rendered data
|
||||||
|
- Hard reload function clears service worker caches, unregisters workers, and deletes IndexedDB
|
||||||
|
|
||||||
|
## [0.3.1] - 2025-12-12
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Dashboard Notifications**: Removed Pending units from alert list - only Missing units now trigger notifications
|
||||||
|
- **Status Dots**: Verified deployed units display correct status dots (OK=green, Pending=yellow, Missing=red) in both active and benched tables
|
||||||
|
- **Mobile Card View**: Fixed roster cards showing "Unknown" status by using `.get()` with defaults in backend routes
|
||||||
|
- **Backend Status Handling**: Added default values for status, age, last_seen fields to prevent KeyError exceptions
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Backend roster partial routes (`/partials/roster-deployed`, `/partials/roster-benched`) now use `.get()` method with sensible defaults
|
||||||
|
- Deployed units default to "Unknown" status when data unavailable
|
||||||
|
- Benched units default to "N/A" status when data unavailable
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-12-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Series 4 (Micromate) Support**: New `/api/series4/heartbeat` endpoint for receiving telemetry from Series 4 Micromate units
|
||||||
|
- Auto-detection of Series 4 units via UM##### ID pattern
|
||||||
|
- Stores project hints from emitter payload in unit notes
|
||||||
|
- Automatic unit type classification across both Series 3 and Series 4 endpoints
|
||||||
|
- **Development Environment Labels**: Visual indicators to distinguish dev from production deployments
|
||||||
|
- Yellow "DEV" badge in sidebar navigation
|
||||||
|
- "[DEV]" prefix in browser title
|
||||||
|
- Yellow banner on dashboard when running in development mode
|
||||||
|
- Environment variable support in docker-compose.yml (ENVIRONMENT=production|development)
|
||||||
|
- **Quality of Life Improvements**:
|
||||||
|
- Human-readable relative timestamps (e.g., "2h 15m ago", "3d ago") with full date in tooltips
|
||||||
|
- "Last Updated" timestamp indicator on dashboard
|
||||||
|
- Status icons for colorblind accessibility (checkmark for OK, clock for Pending, X for Missing)
|
||||||
|
- Breadcrumb navigation on unit detail pages
|
||||||
|
- Copy-to-clipboard buttons for unit IDs
|
||||||
|
- Search/filter functionality for fleet roster table
|
||||||
|
- Improved empty state messages with icons
|
||||||
|
- **Timezone Support**: Comprehensive timezone handling across the application
|
||||||
|
- Timezone selector in Settings (defaults to America/New_York EST)
|
||||||
|
- Human-readable timestamp format (e.g., "9/10/2020 8:00 AM EST")
|
||||||
|
- Timezone-aware display for all timestamps site-wide
|
||||||
|
- Settings stored in localStorage for immediate effect
|
||||||
|
- **Settings Page Redesign**: Complete overhaul with tabbed interface and persistent preferences
|
||||||
|
- **General Tab**: Display preferences (timezone, theme, auto-refresh interval)
|
||||||
|
- **Data Management Tab**: Safe operations (CSV export, merge import, roster table)
|
||||||
|
- **Advanced Tab**: Power user settings (replace mode import, calibration defaults, status thresholds)
|
||||||
|
- **Danger Zone Tab**: Destructive operations isolated with enhanced warnings
|
||||||
|
- Backend preferences storage via new UserPreferences model
|
||||||
|
- Tab state persistence in localStorage
|
||||||
|
- Smooth animations and consistent styling with existing pages
|
||||||
|
- **User Preferences API**: New backend endpoints for persistent settings storage
|
||||||
|
- `GET /api/settings/preferences` - Retrieve all user preferences
|
||||||
|
- `PUT /api/settings/preferences` - Update preferences (supports partial updates)
|
||||||
|
- Database-backed storage for cross-device preference sync
|
||||||
|
- Migration script: `backend/migrate_add_user_preferences.py`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Timestamps now display in user-selected timezone with human-readable format throughout the application
|
||||||
|
- Settings page reorganized from 644-line flat layout to clean 4-tab interface
|
||||||
|
- CSV Replace Mode moved from Data Management to Advanced tab with additional warnings
|
||||||
|
- Import operations separated: safe merge in Data Management tab, destructive replace in Advanced tab
|
||||||
|
- Page title changed from "Roster Manager" to "Settings" for better clarity
|
||||||
|
- All preferences now persist to backend database instead of relying solely on localStorage
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Unit type classification now consistent across Series 3 and Series 4 heartbeat endpoints
|
||||||
|
- Auto-correction of misclassified unit types when they report to wrong endpoint
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- New `detect_unit_type()` helper function for pattern-based unit classification
|
||||||
|
- UserPreferences model with single-row table pattern (id=1) for global settings
|
||||||
|
- Series 4 units identified by UM prefix followed by digits (e.g., UM11719)
|
||||||
|
- JavaScript Intl API used for client-side timezone conversion
|
||||||
|
- Pydantic schema for partial preference updates (PreferencesUpdate model)
|
||||||
|
- Environment context injection via custom FastAPI template response wrapper
|
||||||
|
|
||||||
|
## [0.2.1] - 2025-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `/settings` roster manager page with CSV export/import, live stats, and danger-zone reset controls.
|
||||||
|
- `/api/settings` router that exposes `export-csv`, `stats`, `roster-units`, `import-csv-replace`, and the clear-* endpoints backing the UI.
|
||||||
|
- Dedicated HTMX partials/tabs for deployed, benched, retired, and ignored units plus new ignored-table UI to unignore or delete entries.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Roster and unit detail templates now display device-type specific metadata (calibration windows, modem pairings, IP/phone fields) alongside inline actions.
|
||||||
|
- Base navigation highlights the new settings workflow and routes retired/ignored buckets through dedicated endpoints + partials.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Snapshot summary counts only consider deployed units, preventing dashboard alerts from including benched hardware.
|
||||||
|
- Snapshot payloads now include address/coordinate metadata so map widgets and CSV exports stay accurate.
|
||||||
|
|
||||||
|
## [0.2.0] - 2025-12-03
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Device-type aware roster schema (seismographs vs modems) with new metadata columns plus `backend/migrate_add_device_types.py` for upgrading existing SQLite files.
|
||||||
|
- `create_test_db.py` helper that generates a ready-to-use demo database with sample seismographs, modems, and emitter rows.
|
||||||
|
- Ignore list persistence/API so noisy legacy emitters can be quarantined via `/api/roster/ignore` and surfaced in the UI.
|
||||||
|
- Roster page enhancements: Add Unit modal, CSV import modal, and HTMX-powered table fed by `/partials/roster-table`.
|
||||||
|
- Unit detail view rewritten to fetch data via API, expose deployment status, and allow edits to all metadata.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Snapshot service now merges roster + emitter data into active/benched/retired/unknown buckets and includes device-specific metadata in each record.
|
||||||
|
- Roster edit endpoints parse date fields, manage modem/seismograph specific attributes, and guarantee records exist when toggling deployed/retired states.
|
||||||
|
- Dashboard partial endpoints are grouped under `/dashboard/*` so HTMX tabs stay in sync with the consolidated snapshot payload.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Toggling deployed/retired flags no longer fails when a unit does not exist because the router now auto-creates placeholder roster rows.
|
||||||
|
- CSV import applies address/coordinate updates instead of silently dropping unknown columns.
|
||||||
|
|
||||||
|
## [0.1.1] - 2025-12-02
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Roster Editing API**: Full CRUD operations for roster management
|
||||||
|
- `POST /api/roster/add` - Add new units to roster
|
||||||
|
- `POST /api/roster/set-deployed/{unit_id}` - Toggle deployment status
|
||||||
|
- `POST /api/roster/set-retired/{unit_id}` - Toggle retired status
|
||||||
|
- `POST /api/roster/set-note/{unit_id}` - Update unit notes
|
||||||
|
- **CSV Import**: Bulk roster import functionality
|
||||||
|
- `POST /api/roster/import-csv` - Import units from CSV file
|
||||||
|
- Support for all roster fields: unit_id, unit_type, deployed, retired, note, project_id, location
|
||||||
|
- Optional update_existing parameter to control duplicate handling
|
||||||
|
- Detailed import summary with added/updated/skipped/error counts
|
||||||
|
- **Enhanced Database Models**:
|
||||||
|
- Added `project_id` field to RosterUnit model
|
||||||
|
- Added `location` field to RosterUnit model
|
||||||
|
- Added `last_updated` timestamp tracking
|
||||||
|
- **Dashboard Enhancements**:
|
||||||
|
- Separate views for Active, Benched, and Retired units
|
||||||
|
- New endpoints: `/dashboard/active` and `/dashboard/benched`
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Database session management bug in `emit_status_snapshot()`
|
||||||
|
- Added `get_db_session()` helper function for direct session access
|
||||||
|
- Implemented proper session cleanup with try/finally blocks
|
||||||
|
- Database schema synchronization issues
|
||||||
|
- Database now properly recreates when model changes are detected
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Updated RosterUnit model to include additional metadata fields
|
||||||
|
- Improved error handling in CSV import with row-level error reporting
|
||||||
|
- Enhanced snapshot service to properly manage database connections
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- All roster editing endpoints use Form data for better HTML form compatibility
|
||||||
|
- CSV import uses multipart/form-data for file uploads
|
||||||
|
- Boolean fields in CSV accept: 'true', '1', 'yes' (case-insensitive)
|
||||||
|
- Database sessions now properly closed to prevent connection leaks
|
||||||
|
|
||||||
|
## [0.1.0] - 2024-11-20
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Initial release of Seismo Fleet Manager
|
||||||
|
- FastAPI-based REST API for fleet management
|
||||||
|
- SQLite database with SQLAlchemy ORM
|
||||||
|
- Emitter reporting endpoints
|
||||||
|
- Basic fleet status monitoring
|
||||||
|
- Docker and Docker Compose support
|
||||||
|
- Web-based dashboard with HTMX
|
||||||
|
- Dark/light mode toggle
|
||||||
|
- Interactive maps with Leaflet
|
||||||
|
- Photo management per unit
|
||||||
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.4.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.1...v0.4.2
|
||||||
|
[0.4.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.4.0...v0.4.1
|
||||||
|
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||||
|
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
|
||||||
|
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
|
||||||
|
[0.3.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.0...v0.3.1
|
||||||
|
[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
|
||||||
|
[0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1
|
||||||
|
[0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0
|
||||||
|
[0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1
|
||||||
|
[0.1.0]: https://github.com/serversdwn/seismo-fleet-manager/releases/tag/v0.1.0
|
||||||
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies (ping for network diagnostics)
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends iputils-ping curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Copy requirements first for better caching
|
||||||
|
COPY requirements.txt .
|
||||||
|
|
||||||
|
# Install dependencies
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy application code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Expose port
|
||||||
|
EXPOSE 8001
|
||||||
|
|
||||||
|
# Run the application using the new backend structure
|
||||||
|
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8001"]
|
||||||
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"]
|
||||||
303
FRONTEND_README.md
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
# Seismo Fleet Manager - Frontend Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is the MVP frontend scaffold for **Seismo Fleet Manager**, built with:
|
||||||
|
- **FastAPI** (backend framework)
|
||||||
|
- **HTMX** (dynamic updates without JavaScript frameworks)
|
||||||
|
- **TailwindCSS** (utility-first styling)
|
||||||
|
- **Jinja2** (server-side templating)
|
||||||
|
- **Leaflet** (interactive maps)
|
||||||
|
|
||||||
|
No React, Vue, or other frontend frameworks are used.
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
seismo-fleet-manager/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # FastAPI app entry point
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── roster.py # Fleet roster endpoints
|
||||||
|
│ │ ├── units.py # Individual unit endpoints
|
||||||
|
│ │ └── photos.py # Photo management endpoints
|
||||||
|
│ ├── services/
|
||||||
|
│ │ └── snapshot.py # Mock status snapshot (replace with real logic)
|
||||||
|
│ ├── static/
|
||||||
|
│ │ └── style.css # Custom CSS
|
||||||
|
│ ├── database.py # SQLAlchemy database setup
|
||||||
|
│ ├── models.py # Database models
|
||||||
|
│ └── routes.py # Legacy API routes
|
||||||
|
├── templates/
|
||||||
|
│ ├── base.html # Base layout with sidebar & dark mode
|
||||||
|
│ ├── dashboard.html # Main dashboard page
|
||||||
|
│ ├── roster.html # Fleet roster page
|
||||||
|
│ ├── unit_detail.html # Unit detail page
|
||||||
|
│ └── partials/
|
||||||
|
│ └── roster_table.html # HTMX partial for roster table
|
||||||
|
├── data/
|
||||||
|
│ └── photos/ # Photo storage (organized by unit_id)
|
||||||
|
└── requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running the Application
|
||||||
|
|
||||||
|
### Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run the Server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at:
|
||||||
|
- **Web UI**: http://localhost:8001/
|
||||||
|
- **API Docs**: http://localhost:8001/docs
|
||||||
|
- **Health Check**: http://localhost:8001/health
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
### 1. Dashboard (`/`)
|
||||||
|
|
||||||
|
The main dashboard provides an at-a-glance view of the fleet:
|
||||||
|
|
||||||
|
- **Fleet Summary Card**: Total units, deployed units, status breakdown
|
||||||
|
- **Recent Alerts Card**: Shows units with Missing or Pending status
|
||||||
|
- **Recent Photos Card**: Placeholder for photo gallery
|
||||||
|
- **Fleet Status Preview**: Quick view of first 5 units
|
||||||
|
|
||||||
|
**Auto-refresh**: Dashboard updates every 10 seconds via HTMX
|
||||||
|
|
||||||
|
### 2. Fleet Roster (`/roster`)
|
||||||
|
|
||||||
|
A comprehensive table view of all seismograph units:
|
||||||
|
|
||||||
|
**Columns**:
|
||||||
|
- Status indicator (colored dot: green=OK, yellow=Pending, red=Missing)
|
||||||
|
- Deployment indicator (blue dot if deployed)
|
||||||
|
- Unit ID
|
||||||
|
- Last seen timestamp
|
||||||
|
- Age since last contact
|
||||||
|
- Notes
|
||||||
|
- Actions (View detail button)
|
||||||
|
|
||||||
|
**Features**:
|
||||||
|
- Auto-refresh every 10 seconds
|
||||||
|
- Sorted by priority (Missing > Pending > OK)
|
||||||
|
- Click any row to view unit details
|
||||||
|
|
||||||
|
### 3. Unit Detail Page (`/unit/{unit_id}`)
|
||||||
|
|
||||||
|
Split-screen layout with detailed information:
|
||||||
|
|
||||||
|
**Left Column**:
|
||||||
|
- Status card with real-time updates
|
||||||
|
- Deployment status
|
||||||
|
- Last contact time and file
|
||||||
|
- Notes section
|
||||||
|
- Editable metadata (mock form)
|
||||||
|
|
||||||
|
**Right Column - Tabbed Interface**:
|
||||||
|
- **Photos Tab**: Primary photo with thumbnail gallery
|
||||||
|
- **Map Tab**: Interactive Leaflet map showing unit location
|
||||||
|
- **History Tab**: Placeholder for event history
|
||||||
|
|
||||||
|
**Auto-refresh**: Unit data updates every 10 seconds
|
||||||
|
|
||||||
|
### 4. Dark/Light Mode
|
||||||
|
|
||||||
|
Toggle button in sidebar switches between themes:
|
||||||
|
- Uses Tailwind's `dark:` classes
|
||||||
|
- Preference saved to localStorage
|
||||||
|
- Smooth transitions on theme change
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Status & Fleet Data
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/status-snapshot
|
||||||
|
```
|
||||||
|
Returns complete fleet status snapshot with statistics.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/roster
|
||||||
|
```
|
||||||
|
Returns sorted list of all units for roster table.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/unit/{unit_id}
|
||||||
|
```
|
||||||
|
Returns detailed information for a single unit including coordinates.
|
||||||
|
|
||||||
|
### Photo Management
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/unit/{unit_id}/photos
|
||||||
|
```
|
||||||
|
Returns list of photos for a unit, sorted by recency.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/unit/{unit_id}/photo/{filename}
|
||||||
|
```
|
||||||
|
Serves a specific photo file.
|
||||||
|
|
||||||
|
### Legacy Endpoints
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /emitters/report
|
||||||
|
```
|
||||||
|
Endpoint for emitters to report status (from original backend).
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /fleet/status
|
||||||
|
```
|
||||||
|
Returns database-backed fleet status (from original backend).
|
||||||
|
|
||||||
|
## Mock Data
|
||||||
|
|
||||||
|
### Location: `backend/services/snapshot.py`
|
||||||
|
|
||||||
|
The `emit_status_snapshot()` function currently returns mock data with 8 units:
|
||||||
|
|
||||||
|
- **BE1234**: OK, deployed (San Francisco)
|
||||||
|
- **BE5678**: Pending, deployed (Los Angeles)
|
||||||
|
- **BE9012**: Missing, deployed (New York)
|
||||||
|
- **BE3456**: OK, benched (Chicago)
|
||||||
|
- **BE7890**: OK, deployed (Houston)
|
||||||
|
- **BE2468**: Pending, deployed
|
||||||
|
- **BE1357**: OK, benched
|
||||||
|
- **BE8642**: Missing, deployed
|
||||||
|
|
||||||
|
**To replace with real data**: Update the `emit_status_snapshot()` function to call your Series3 emitter logic.
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
### Color Palette
|
||||||
|
|
||||||
|
The application uses your brand colors:
|
||||||
|
|
||||||
|
```css
|
||||||
|
orange: #f48b1c
|
||||||
|
navy: #142a66
|
||||||
|
burgundy: #7d234d
|
||||||
|
```
|
||||||
|
|
||||||
|
These are configured in the Tailwind config as `seismo-orange`, `seismo-navy`, `seismo-burgundy`.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
All cards use the consistent styling:
|
||||||
|
```html
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Indicators
|
||||||
|
|
||||||
|
- Green dot: OK status
|
||||||
|
- Yellow dot: Pending status
|
||||||
|
- Red dot: Missing status
|
||||||
|
- Blue dot: Deployed
|
||||||
|
- Gray dot: Benched
|
||||||
|
|
||||||
|
## HTMX Usage
|
||||||
|
|
||||||
|
HTMX enables dynamic updates without writing JavaScript:
|
||||||
|
|
||||||
|
### Auto-refresh Example (Dashboard)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/api/status-snapshot"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
```
|
||||||
|
|
||||||
|
This fetches the snapshot on page load and every 10 seconds, then calls a JavaScript function to update the DOM.
|
||||||
|
|
||||||
|
### Partial Template Loading (Roster)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div hx-get="/partials/roster-table"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
```
|
||||||
|
|
||||||
|
This replaces the entire inner HTML with the server-rendered roster table every 10 seconds.
|
||||||
|
|
||||||
|
## Adding Photos
|
||||||
|
|
||||||
|
To add photos for a unit:
|
||||||
|
|
||||||
|
1. Create a directory: `data/photos/{unit_id}/`
|
||||||
|
2. Add image files (jpg, jpeg, png, gif, webp)
|
||||||
|
3. Photos will automatically appear on the unit detail page
|
||||||
|
4. Most recent file becomes the primary photo
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```bash
|
||||||
|
mkdir -p data/photos/BE1234
|
||||||
|
cp my-photo.jpg data/photos/BE1234/deployment-site.jpg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Customization
|
||||||
|
|
||||||
|
### Adding New Pages
|
||||||
|
|
||||||
|
1. Create a template in `templates/`
|
||||||
|
2. Add a route in `backend/main.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@app.get("/my-page", response_class=HTMLResponse)
|
||||||
|
async def my_page(request: Request):
|
||||||
|
return templates.TemplateResponse("my_page.html", {"request": request})
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Add a navigation link in `templates/base.html`
|
||||||
|
|
||||||
|
### Adding New API Endpoints
|
||||||
|
|
||||||
|
1. Create a router file in `backend/routers/`
|
||||||
|
2. Include the router in `backend/main.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from backend.routers import my_router
|
||||||
|
app.include_router(my_router.router)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
|
||||||
|
The project includes Docker configuration:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start the application on port 8001 (configured to avoid conflicts with port 8000).
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. **Replace Mock Data**: Update `backend/services/snapshot.py` with real Series3 emitter logic
|
||||||
|
2. **Database Integration**: The existing SQLAlchemy models can store historical data
|
||||||
|
3. **Photo Upload**: Add a form to upload photos from the UI
|
||||||
|
4. **Projects Management**: Implement the "Projects" page
|
||||||
|
5. **Settings**: Add user preferences and configuration
|
||||||
|
6. **Event History**: Populate the History tab with real event data
|
||||||
|
7. **Authentication**: Add user login/authentication if needed
|
||||||
|
8. **Notifications**: Add real-time alerts for critical status changes
|
||||||
|
|
||||||
|
## Development Tips
|
||||||
|
|
||||||
|
- The `--reload` flag auto-reloads the server when code changes
|
||||||
|
- Use browser DevTools to debug HTMX requests (look for `HX-Request` headers)
|
||||||
|
- Check `/docs` for interactive API documentation (Swagger UI)
|
||||||
|
- Dark mode state persists in browser localStorage
|
||||||
|
- All timestamps are currently mock data - replace with real values
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
See main README.md for license information.
|
||||||
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.
|
||||||
579
README.md
@@ -1,2 +1,577 @@
|
|||||||
# seismo-fleet-manager
|
# Terra-View v0.5.0
|
||||||
Web app and backend for tracking deployed units.
|
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
|
||||||
|
|
||||||
|
- **Progressive Web App (PWA)**: Mobile-first responsive design optimized for field deployment operations
|
||||||
|
- **Install as App**: Add to home screen on iOS/Android for native app experience
|
||||||
|
- **Offline Capable**: Service worker caching with IndexedDB storage for offline operation
|
||||||
|
- **Touch Optimized**: 44x44px minimum touch targets, hamburger menu, bottom navigation bar
|
||||||
|
- **Mobile Card View**: Compact unit cards with status dots, tap-to-navigate locations, and detail modals
|
||||||
|
- **Background Sync**: Queue edits while offline and automatically sync when connection returns
|
||||||
|
- **Web Dashboard**: Modern, responsive UI with dark/light mode, live HTMX updates, and integrated fleet map
|
||||||
|
- **Fleet Monitoring**: Track deployed, benched, retired, and ignored units in separate buckets with unknown-emitter triage
|
||||||
|
- **Roster Management**: Full CRUD + CSV import/export, device-type aware metadata, and inline actions from the roster tables
|
||||||
|
- **Settings & Safeguards**: `/settings` page exposes roster stats, exports, replace-all imports, and danger-zone reset tools
|
||||||
|
- **Device & Modem Metadata**: Capture calibration windows, modem pairings, phone/IP details, and addresses per unit
|
||||||
|
- **Status Management**: Automatically mark deployed units as OK, Pending (>12h), or Missing (>24h) based on recent telemetry
|
||||||
|
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
||||||
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
||||||
|
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||||
|
- **Database Management**: Comprehensive backup and restore system
|
||||||
|
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||||
|
- **Restore from Snapshot**: Restore database with automatic safety backups
|
||||||
|
- **Upload/Download**: Transfer database snapshots for off-site storage
|
||||||
|
- **Remote Cloning**: Copy production database to remote dev servers over WAN
|
||||||
|
- **Automatic Backups**: Scheduled background backups with configurable retention
|
||||||
|
|
||||||
|
## Roster Manager & Settings
|
||||||
|
|
||||||
|
Visit [`/settings`](http://localhost:8001/settings) to perform bulk roster operations with guardrails:
|
||||||
|
|
||||||
|
- **CSV export/import**: Download the entire roster, merge updates, or replace all units in one transaction.
|
||||||
|
- **Live roster table**: Fetch every unit via HTMX, edit metadata, toggle deployed/retired states, move emitters to the ignore list, or delete records in-place.
|
||||||
|
- **Database backups**: Create snapshots, restore from backups, upload/download database files, view database statistics.
|
||||||
|
- **Remote cloning**: Clone production database to remote development servers over the network (see `scripts/clone_db_to_dev.py`).
|
||||||
|
- **Stats at a glance**: View counts for the roster, emitters, and ignored units to confirm import/cleanup operations worked.
|
||||||
|
- **Danger zone controls**: Clear specific tables or wipe all fleet data when resetting a lab/demo environment.
|
||||||
|
|
||||||
|
All UI actions call `GET/POST /api/settings/*` endpoints so you can automate the same workflows from scripts. See [docs/DATABASE_MANAGEMENT.md](docs/DATABASE_MANAGEMENT.md) for comprehensive database backup and restore documentation.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **FastAPI**: Modern, fast web framework
|
||||||
|
- **SQLAlchemy**: SQL toolkit and ORM
|
||||||
|
- **SQLite**: Lightweight database
|
||||||
|
- **HTMX**: Dynamic updates without heavy JavaScript frameworks
|
||||||
|
- **TailwindCSS**: Utility-first CSS framework
|
||||||
|
- **Leaflet**: Interactive maps
|
||||||
|
- **Jinja2**: Server-side templating
|
||||||
|
- **uvicorn**: ASGI server
|
||||||
|
- **Docker**: Containerization for easy deployment
|
||||||
|
|
||||||
|
## Quick Start with Docker Compose (Recommended)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Docker and Docker Compose installed
|
||||||
|
|
||||||
|
### Running the Application
|
||||||
|
|
||||||
|
1. **Start the service:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Check logs:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Stop the service:**
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at:
|
||||||
|
- **Web Interface**: http://localhost:8001
|
||||||
|
- **API Documentation**: http://localhost:8001/docs
|
||||||
|
- **Health Check**: http://localhost:8001/health
|
||||||
|
|
||||||
|
### Data Persistence
|
||||||
|
|
||||||
|
The SQLite database and photos are stored in the `./data` directory, which is mounted as a volume. Your data will persist even if you restart or rebuild the container.
|
||||||
|
|
||||||
|
## Local Development (Without Docker)
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
- Python 3.11+
|
||||||
|
- pip
|
||||||
|
|
||||||
|
### Setup
|
||||||
|
|
||||||
|
1. **Install dependencies:**
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Run the server:**
|
||||||
|
```bash
|
||||||
|
uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at http://localhost:8001
|
||||||
|
|
||||||
|
### Optional: Generate Sample Data
|
||||||
|
|
||||||
|
Need realistic data quickly? Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python create_test_db.py
|
||||||
|
cp /tmp/sfm_test.db data/seismo_fleet.db
|
||||||
|
```
|
||||||
|
|
||||||
|
The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately.
|
||||||
|
|
||||||
|
## Upgrading from Previous Versions
|
||||||
|
|
||||||
|
### From v0.2.x to v0.3.0
|
||||||
|
|
||||||
|
Version 0.3.0 introduces user preferences storage. Run the migration once per database file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_user_preferences.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `user_preferences` table for persistent settings storage (timezone, theme, auto-refresh interval, calibration defaults, status thresholds).
|
||||||
|
|
||||||
|
### From v0.1.x to v0.2.x or later
|
||||||
|
|
||||||
|
Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_device_types.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Both migration scripts are idempotent—if the columns/tables already exist, they simply exit.
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Web Pages
|
||||||
|
- **GET** `/` - Dashboard home page
|
||||||
|
- **GET** `/roster` - Fleet roster page
|
||||||
|
- **GET** `/unit/{unit_id}` - Unit detail page
|
||||||
|
- **GET** `/settings` - Roster manager, CSV import/export, and danger-zone utilities
|
||||||
|
|
||||||
|
### Fleet Status & Monitoring
|
||||||
|
- **GET** `/api/status-snapshot` - Complete fleet status snapshot
|
||||||
|
- **GET** `/api/roster` - List of all units with metadata
|
||||||
|
- **GET** `/api/unit/{unit_id}` - Detailed unit information
|
||||||
|
- **GET** `/health` - Health check endpoint
|
||||||
|
|
||||||
|
### Roster Management
|
||||||
|
- **POST** `/api/roster/add` - Add new unit to roster
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8001/api/roster/add \
|
||||||
|
-F "id=BE1234" \
|
||||||
|
-F "device_type=seismograph" \
|
||||||
|
-F "unit_type=series3" \
|
||||||
|
-F "project_id=PROJ-001" \
|
||||||
|
-F "deployed=true" \
|
||||||
|
-F "note=Main site sensor"
|
||||||
|
```
|
||||||
|
- **GET** `/api/roster/{unit_id}` - Fetch a single roster entry for editing
|
||||||
|
- **POST** `/api/roster/edit/{unit_id}` - Update all metadata (device type, calibration dates, modem fields, etc.)
|
||||||
|
- **POST** `/api/roster/set-deployed/{unit_id}` - Toggle deployment status
|
||||||
|
- **POST** `/api/roster/set-retired/{unit_id}` - Toggle retired status
|
||||||
|
- **POST** `/api/roster/set-note/{unit_id}` - Update unit notes
|
||||||
|
- **DELETE** `/api/roster/{unit_id}` - Remove a roster/emitter pair entirely
|
||||||
|
- **POST** `/api/roster/import-csv` - Bulk import from CSV (merge/update mode)
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8001/api/roster/import-csv \
|
||||||
|
-F "file=@roster.csv" \
|
||||||
|
-F "update_existing=true"
|
||||||
|
```
|
||||||
|
- **POST** `/api/roster/ignore/{unit_id}` - Move an unknown emitter to the ignore list
|
||||||
|
- **DELETE** `/api/roster/ignore/{unit_id}` - Remove a unit from the ignore list
|
||||||
|
- **GET** `/api/roster/ignored` - List all ignored units with reasons
|
||||||
|
|
||||||
|
### Settings & Data Management
|
||||||
|
- **GET** `/api/settings/export-csv` - Download the entire roster as CSV
|
||||||
|
- **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables
|
||||||
|
- **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid
|
||||||
|
- **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction
|
||||||
|
- **GET** `/api/settings/preferences` - Get user preferences (timezone, theme, calibration defaults, etc.)
|
||||||
|
- **PUT** `/api/settings/preferences` - Update user preferences (supports partial updates)
|
||||||
|
- **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables
|
||||||
|
- **POST** `/api/settings/clear-roster` - Delete only roster entries
|
||||||
|
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
|
||||||
|
- **POST** `/api/settings/clear-ignored` - Reset ignore list
|
||||||
|
|
||||||
|
### Database Management
|
||||||
|
- **GET** `/api/settings/database/stats` - Database size, row counts, and last modified time
|
||||||
|
- **POST** `/api/settings/database/snapshot` - Create manual database snapshot with optional description
|
||||||
|
- **GET** `/api/settings/database/snapshots` - List all available snapshots with metadata
|
||||||
|
- **GET** `/api/settings/database/snapshot/{filename}` - Download a specific snapshot file
|
||||||
|
- **DELETE** `/api/settings/database/snapshot/{filename}` - Delete a snapshot
|
||||||
|
- **POST** `/api/settings/database/restore` - Restore database from snapshot (creates safety backup)
|
||||||
|
- **POST** `/api/settings/database/upload-snapshot` - Upload snapshot file to server
|
||||||
|
|
||||||
|
See [docs/DATABASE_MANAGEMENT.md](docs/DATABASE_MANAGEMENT.md) for detailed documentation and examples.
|
||||||
|
|
||||||
|
### CSV Import Format
|
||||||
|
Create a CSV file with the following columns (only `unit_id` is required, everything else is optional):
|
||||||
|
|
||||||
|
```
|
||||||
|
unit_id,unit_type,device_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model
|
||||||
|
```
|
||||||
|
|
||||||
|
Boolean columns accept `true/false`, `1/0`, or `yes/no` (case-insensitive). Date columns expect `YYYY-MM-DD`. Use the same schema whether you merge via `/api/roster/import-csv` or replace everything with `/api/settings/import-csv-replace`.
|
||||||
|
|
||||||
|
#### Example
|
||||||
|
|
||||||
|
```csv
|
||||||
|
unit_id,unit_type,device_type,deployed,retired,note,project_id,location,address,coordinates,last_calibrated,next_calibration_due,deployed_with_modem_id,ip_address,phone_number,hardware_model
|
||||||
|
BE1234,series3,seismograph,true,false,Primary sensor,PROJ-001,"Station A","123 Market St, San Francisco, CA","37.7937,-122.3965",2025-01-15,2026-01-15,MDM001,,,
|
||||||
|
MDM001,modem,modem,true,false,Field modem,PROJ-001,"Station A","123 Market St, San Francisco, CA","37.7937,-122.3965",,,,"192.0.2.10","+1-555-0100","Raven XTV"
|
||||||
|
```
|
||||||
|
|
||||||
|
See [sample_roster.csv](sample_roster.csv) for a minimal working example.
|
||||||
|
|
||||||
|
### Emitter Reporting
|
||||||
|
- **POST** `/emitters/report` - Submit status report from a seismograph unit
|
||||||
|
- **POST** `/api/series3/heartbeat` - Series 3 multi-unit telemetry payload
|
||||||
|
- **POST** `/api/series4/heartbeat` - Series 4 (Micromate) multi-unit telemetry payload
|
||||||
|
- **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy)
|
||||||
|
|
||||||
|
### Photo Management
|
||||||
|
- **GET** `/api/unit/{unit_id}/photos` - List photos for a unit
|
||||||
|
- **GET** `/api/unit/{unit_id}/photo/{filename}` - Serve specific photo file
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
Once running, interactive API documentation is available at:
|
||||||
|
- **Swagger UI**: http://localhost:8001/docs
|
||||||
|
- **ReDoc**: http://localhost:8001/redoc
|
||||||
|
|
||||||
|
## Testing the API
|
||||||
|
|
||||||
|
### Using curl
|
||||||
|
|
||||||
|
**Submit a report:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8001/emitters/report \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{
|
||||||
|
"unit": "SEISMO-001",
|
||||||
|
"unit_type": "series3",
|
||||||
|
"timestamp": "2025-11-20T10:30:00",
|
||||||
|
"file": "event_20251120_103000.dat",
|
||||||
|
"status": "OK"
|
||||||
|
}'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get fleet status:**
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8001/api/roster
|
||||||
|
```
|
||||||
|
|
||||||
|
**Import roster from CSV:**
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:8001/api/roster/import-csv \
|
||||||
|
-F "file=@sample_roster.csv" \
|
||||||
|
-F "update_existing=true"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Using Python
|
||||||
|
|
||||||
|
```python
|
||||||
|
import requests
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# Submit report
|
||||||
|
response = requests.post(
|
||||||
|
"http://localhost:8001/emitters/report",
|
||||||
|
json={
|
||||||
|
"unit": "SEISMO-001",
|
||||||
|
"unit_type": "series3",
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"file": "event_20251120_103000.dat",
|
||||||
|
"status": "OK"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
print(response.json())
|
||||||
|
|
||||||
|
# Get fleet status
|
||||||
|
response = requests.get("http://localhost:8001/api/roster")
|
||||||
|
print(response.json())
|
||||||
|
|
||||||
|
# Import CSV
|
||||||
|
with open('roster.csv', 'rb') as f:
|
||||||
|
files = {'file': f}
|
||||||
|
data = {'update_existing': 'true'}
|
||||||
|
response = requests.post(
|
||||||
|
"http://localhost:8001/api/roster/import-csv",
|
||||||
|
files=files,
|
||||||
|
data=data
|
||||||
|
)
|
||||||
|
print(response.json())
|
||||||
|
```
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### RosterUnit Table (Fleet Roster)
|
||||||
|
|
||||||
|
**Common fields**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | string | Unit identifier (primary key) |
|
||||||
|
| unit_type | string | Hardware model name (default: `series3`) |
|
||||||
|
| device_type | string | `seismograph` or `modem` discriminator |
|
||||||
|
| deployed | boolean | Whether the unit is in the field |
|
||||||
|
| retired | boolean | Removes the unit from deployments but preserves history |
|
||||||
|
| note | string | Notes about the unit |
|
||||||
|
| project_id | string | Associated project identifier |
|
||||||
|
| location | string | Legacy location label |
|
||||||
|
| address | string | Human-readable address |
|
||||||
|
| coordinates | string | `lat,lon` pair used by the map |
|
||||||
|
| last_updated | datetime | Last modification timestamp |
|
||||||
|
|
||||||
|
**Seismograph-only fields**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| last_calibrated | date | Last calibration date |
|
||||||
|
| next_calibration_due | date | Next calibration date |
|
||||||
|
| deployed_with_modem_id | string | Which modem is paired during deployment |
|
||||||
|
|
||||||
|
**Modem-only fields**
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| ip_address | string | Assigned IP (static or DHCP) |
|
||||||
|
| phone_number | string | Cellular number for the modem |
|
||||||
|
| hardware_model | string | Modem hardware reference |
|
||||||
|
|
||||||
|
### Emitter Table (Device Check-ins)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | string | Unit identifier (primary key) |
|
||||||
|
| unit_type | string | Reported device type/model |
|
||||||
|
| last_seen | datetime | Last report timestamp |
|
||||||
|
| last_file | string | Last file processed |
|
||||||
|
| status | string | Current status: OK, Pending, Missing |
|
||||||
|
| notes | string | Optional notes (nullable) |
|
||||||
|
|
||||||
|
### IgnoredUnit Table (Noise Management)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | string | Unit identifier (primary key) |
|
||||||
|
| reason | string | Optional context for ignoring |
|
||||||
|
| ignored_at | datetime | When the ignore action occurred |
|
||||||
|
|
||||||
|
### UserPreferences Table (Settings Storage)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Always 1 (single-row table) |
|
||||||
|
| timezone | string | Display timezone (default: America/New_York) |
|
||||||
|
| theme | string | UI theme: auto, light, or dark |
|
||||||
|
| auto_refresh_interval | integer | Dashboard refresh interval in seconds |
|
||||||
|
| date_format | string | Date format preference |
|
||||||
|
| table_rows_per_page | integer | Default pagination size |
|
||||||
|
| calibration_interval_days | integer | Default days between calibrations |
|
||||||
|
| calibration_warning_days | integer | Warning threshold before calibration due |
|
||||||
|
| status_ok_threshold_hours | integer | Hours for OK status threshold |
|
||||||
|
| status_pending_threshold_hours | integer | Hours for Pending status threshold |
|
||||||
|
| updated_at | datetime | Last preference update timestamp |
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
seismo-fleet-manager/
|
||||||
|
├── backend/
|
||||||
|
│ ├── main.py # FastAPI app entry point
|
||||||
|
│ ├── database.py # SQLAlchemy database configuration
|
||||||
|
│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit, UserPreferences)
|
||||||
|
│ ├── routes.py # Legacy API endpoints + Series 3/4 heartbeat endpoints
|
||||||
|
│ ├── routers/ # Modular API routers
|
||||||
|
│ │ ├── roster.py # Fleet status endpoints
|
||||||
|
│ │ ├── roster_edit.py # Roster management & CSV import
|
||||||
|
│ │ ├── units.py # Unit detail endpoints
|
||||||
|
│ │ ├── photos.py # Photo management
|
||||||
|
│ │ ├── dashboard.py # Dashboard partials
|
||||||
|
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
||||||
|
│ │ └── settings.py # Settings, preferences, and data management
|
||||||
|
│ ├── services/
|
||||||
|
│ │ ├── snapshot.py # Fleet status snapshot logic
|
||||||
|
│ │ ├── database_backup.py # Database backup and restore service
|
||||||
|
│ │ └── backup_scheduler.py # Automatic backup scheduler
|
||||||
|
│ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema
|
||||||
|
│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema
|
||||||
|
│ └── static/ # Static assets (CSS, etc.)
|
||||||
|
├── create_test_db.py # Generate a sample SQLite DB with mixed devices
|
||||||
|
├── templates/ # Jinja2 HTML templates
|
||||||
|
│ ├── base.html # Base layout with sidebar
|
||||||
|
│ ├── dashboard.html # Main dashboard
|
||||||
|
│ ├── roster.html # Fleet roster table
|
||||||
|
│ ├── unit_detail.html # Unit detail page
|
||||||
|
│ ├── settings.html # Roster manager UI
|
||||||
|
│ └── partials/ # HTMX partial templates
|
||||||
|
│ ├── roster_table.html
|
||||||
|
│ ├── retired_table.html
|
||||||
|
│ ├── ignored_table.html
|
||||||
|
│ └── unknown_emitters.html
|
||||||
|
├── data/ # SQLite database & photos (persisted)
|
||||||
|
│ └── backups/ # Database snapshots directory
|
||||||
|
├── scripts/
|
||||||
|
│ └── clone_db_to_dev.py # Remote database cloning utility
|
||||||
|
├── docs/
|
||||||
|
│ └── DATABASE_MANAGEMENT.md # Database backup/restore guide
|
||||||
|
├── requirements.txt # Python dependencies
|
||||||
|
├── Dockerfile # Docker container definition
|
||||||
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
|
├── CHANGELOG.md # Version history
|
||||||
|
├── FRONTEND_README.md # Frontend documentation
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Docker Commands
|
||||||
|
|
||||||
|
**Build the image:**
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start in foreground:**
|
||||||
|
```bash
|
||||||
|
docker compose up
|
||||||
|
```
|
||||||
|
|
||||||
|
**Start in background:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**View logs:**
|
||||||
|
```bash
|
||||||
|
docker compose logs -f seismo-backend
|
||||||
|
```
|
||||||
|
|
||||||
|
**Restart service:**
|
||||||
|
```bash
|
||||||
|
docker compose restart
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rebuild and restart:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
**Stop and remove containers:**
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
```
|
||||||
|
|
||||||
|
**Remove containers and volumes:**
|
||||||
|
```bash
|
||||||
|
docker compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.4.0 — 2025-12-16
|
||||||
|
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
||||||
|
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
||||||
|
- **Automatic Backup Scheduler**: Background service for scheduled backups with configurable retention management
|
||||||
|
- **Database Tab**: New dedicated tab in Settings for all database operations with real-time statistics
|
||||||
|
- **Settings Reorganization**: Improved tab structure - renamed "Data Management" to "Roster Management", moved CSV Replace Mode, created Database tab
|
||||||
|
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` with complete guide to backup/restore workflows, API reference, and best practices
|
||||||
|
|
||||||
|
### v0.3.3 — 2025-12-12
|
||||||
|
- **Improved Mobile Navigation**: Hamburger menu moved to bottom nav bar (no more floating button covering content)
|
||||||
|
- **Better Status Visibility**: Larger status dots (16px) in dashboard fleet overview for easier at-a-glance status checks
|
||||||
|
- **Cleaner Roster Cards**: Location navigation links moved to detail modal only, reducing clutter in card view
|
||||||
|
|
||||||
|
### v0.3.2 — 2025-12-12
|
||||||
|
- **Progressive Web App (PWA)**: Complete mobile optimization with offline support, installable as standalone app
|
||||||
|
- **Mobile-First UI**: Hamburger menu, bottom navigation bar, card-based roster view optimized for touch
|
||||||
|
- **Tap-to-Navigate**: Location links open in user's preferred navigation app (Google Maps, Apple Maps, Waze)
|
||||||
|
- **Offline Editing**: Service worker + IndexedDB for offline operation with automatic sync when online
|
||||||
|
- **Unit Detail Modals**: Bottom sheet modals for quick unit info access with full edit capabilities
|
||||||
|
- **Hard Reload Utility**: "Clear Cache & Reload" button to force fresh assets (helpful for development)
|
||||||
|
|
||||||
|
### v0.3.1 — 2025-12-12
|
||||||
|
- **Dashboard Alerts**: Only Missing units show in notifications (Pending units no longer alert)
|
||||||
|
- **Status Fixes**: Fixed "Unknown" status issues in mobile card views and detail modals
|
||||||
|
- **Backend Improvements**: Safer data access with `.get()` defaults to prevent errors
|
||||||
|
|
||||||
|
### v0.3.0 — 2025-12-09
|
||||||
|
- **Series 4 Support**: New `/api/series4/heartbeat` endpoint with auto-detection for Micromate units (UM##### pattern)
|
||||||
|
- **Settings Redesign**: Completely redesigned Settings page with 4-tab interface (General, Data Management, Advanced, Danger Zone)
|
||||||
|
- **User Preferences**: Backend storage for timezone, theme, auto-refresh interval, calibration defaults, and status thresholds
|
||||||
|
- **Development Labels**: Visual indicators to distinguish dev from production environments
|
||||||
|
- **Timezone Support**: Comprehensive timezone handling with human-readable timestamps site-wide
|
||||||
|
- **Quality of Life**: Relative timestamps, status icons for accessibility, breadcrumb navigation, copy-to-clipboard, search functionality
|
||||||
|
|
||||||
|
### v0.2.1 — 2025-12-03
|
||||||
|
- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions
|
||||||
|
- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions
|
||||||
|
- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields)
|
||||||
|
- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data
|
||||||
|
|
||||||
|
### v0.2.0 — 2025-12-03
|
||||||
|
- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper
|
||||||
|
- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster
|
||||||
|
- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts
|
||||||
|
- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata
|
||||||
|
|
||||||
|
### v0.1.1 — 2025-12-02
|
||||||
|
- **Roster Editing API**: Full CRUD operations for managing your fleet roster
|
||||||
|
- **CSV Import**: Bulk upload roster data from CSV files
|
||||||
|
- **Enhanced Data Model**: Added project_id and location fields to roster
|
||||||
|
- **Bug Fixes**: Improved database session management and error handling
|
||||||
|
|
||||||
|
See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- Email/SMS alerts for missing units
|
||||||
|
- Historical data tracking and reporting
|
||||||
|
- Multi-user authentication
|
||||||
|
- PostgreSQL support for larger deployments
|
||||||
|
- Advanced filtering and search
|
||||||
|
- Export roster to various formats
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Version
|
||||||
|
|
||||||
|
**Current: 0.4.0** — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||||
|
|
||||||
|
Previous: 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.1 — Dashboard alerts and status fixes (2025-12-12)
|
||||||
|
|
||||||
|
0.3.0 — Series 4 support, settings redesign, user preferences (2025-12-09)
|
||||||
|
|
||||||
|
0.2.1 — Settings & roster manager refresh (2025-12-03)
|
||||||
|
|
||||||
|
0.2.0 — Device-type aware roster + ignore list (2025-12-03)
|
||||||
|
|
||||||
|
0.1.1 — Roster Management & CSV Import (2025-12-02)
|
||||||
|
|
||||||
|
0.1.0 — Initial Release (2024-11-20)
|
||||||
|
|||||||
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"
|
||||||
31
app/core/database.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
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
146
app/seismo/routers/activity.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy import desc
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import UnitHistory, Emitter, RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["activity"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-activity")
|
||||||
|
def get_recent_activity(limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get recent activity feed combining unit history changes and photo uploads.
|
||||||
|
Returns a unified timeline of events sorted by timestamp (newest first).
|
||||||
|
"""
|
||||||
|
activities = []
|
||||||
|
|
||||||
|
# Get recent history entries
|
||||||
|
history_entries = db.query(UnitHistory)\
|
||||||
|
.order_by(desc(UnitHistory.changed_at))\
|
||||||
|
.limit(limit * 2)\
|
||||||
|
.all() # Get more than needed to mix with photos
|
||||||
|
|
||||||
|
for entry in history_entries:
|
||||||
|
activity = {
|
||||||
|
"type": "history",
|
||||||
|
"timestamp": entry.changed_at.isoformat(),
|
||||||
|
"timestamp_unix": entry.changed_at.timestamp(),
|
||||||
|
"unit_id": entry.unit_id,
|
||||||
|
"change_type": entry.change_type,
|
||||||
|
"field_name": entry.field_name,
|
||||||
|
"old_value": entry.old_value,
|
||||||
|
"new_value": entry.new_value,
|
||||||
|
"source": entry.source,
|
||||||
|
"notes": entry.notes
|
||||||
|
}
|
||||||
|
activities.append(activity)
|
||||||
|
|
||||||
|
# Get recent photos
|
||||||
|
if PHOTOS_BASE_DIR.exists():
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
photo_activities = []
|
||||||
|
|
||||||
|
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit_id = unit_dir.name
|
||||||
|
|
||||||
|
for file_path in unit_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
modified_time = file_path.stat().st_mtime
|
||||||
|
photo_activities.append({
|
||||||
|
"type": "photo",
|
||||||
|
"timestamp": datetime.fromtimestamp(modified_time).isoformat(),
|
||||||
|
"timestamp_unix": modified_time,
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"photo_url": f"/api/unit/{unit_id}/photo/{file_path.name}"
|
||||||
|
})
|
||||||
|
|
||||||
|
activities.extend(photo_activities)
|
||||||
|
|
||||||
|
# Sort all activities by timestamp (newest first)
|
||||||
|
activities.sort(key=lambda x: x["timestamp_unix"], reverse=True)
|
||||||
|
|
||||||
|
# Limit to requested number
|
||||||
|
activities = activities[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"activities": activities,
|
||||||
|
"total": len(activities)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-callins")
|
||||||
|
def get_recent_callins(hours: int = 6, limit: int = None, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get recent unit call-ins (units that have reported recently).
|
||||||
|
Returns units sorted by most recent last_seen timestamp.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
hours: Look back this many hours (default: 6)
|
||||||
|
limit: Maximum number of results (default: None = all)
|
||||||
|
"""
|
||||||
|
# Calculate the time threshold
|
||||||
|
time_threshold = datetime.now(timezone.utc) - timedelta(hours=hours)
|
||||||
|
|
||||||
|
# Query emitters with recent activity, joined with roster info
|
||||||
|
recent_emitters = db.query(Emitter)\
|
||||||
|
.filter(Emitter.last_seen >= time_threshold)\
|
||||||
|
.order_by(desc(Emitter.last_seen))\
|
||||||
|
.all()
|
||||||
|
|
||||||
|
# Get roster info for all units
|
||||||
|
roster_dict = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
|
|
||||||
|
call_ins = []
|
||||||
|
for emitter in recent_emitters:
|
||||||
|
roster_unit = roster_dict.get(emitter.id)
|
||||||
|
|
||||||
|
# Calculate time since last seen
|
||||||
|
last_seen_utc = emitter.last_seen.replace(tzinfo=timezone.utc) if emitter.last_seen.tzinfo is None else emitter.last_seen
|
||||||
|
time_diff = datetime.now(timezone.utc) - last_seen_utc
|
||||||
|
|
||||||
|
# Format time ago
|
||||||
|
if time_diff.total_seconds() < 60:
|
||||||
|
time_ago = "just now"
|
||||||
|
elif time_diff.total_seconds() < 3600:
|
||||||
|
minutes = int(time_diff.total_seconds() / 60)
|
||||||
|
time_ago = f"{minutes}m ago"
|
||||||
|
else:
|
||||||
|
hours_ago = time_diff.total_seconds() / 3600
|
||||||
|
if hours_ago < 24:
|
||||||
|
time_ago = f"{int(hours_ago)}h {int((hours_ago % 1) * 60)}m ago"
|
||||||
|
else:
|
||||||
|
days = int(hours_ago / 24)
|
||||||
|
time_ago = f"{days}d ago"
|
||||||
|
|
||||||
|
call_in = {
|
||||||
|
"unit_id": emitter.id,
|
||||||
|
"last_seen": emitter.last_seen.isoformat(),
|
||||||
|
"time_ago": time_ago,
|
||||||
|
"status": emitter.status,
|
||||||
|
"device_type": roster_unit.device_type if roster_unit else "seismograph",
|
||||||
|
"deployed": roster_unit.deployed if roster_unit else False,
|
||||||
|
"note": roster_unit.note if roster_unit and roster_unit.note else "",
|
||||||
|
"location": roster_unit.address if roster_unit and roster_unit.address else (roster_unit.location if roster_unit else "")
|
||||||
|
}
|
||||||
|
call_ins.append(call_in)
|
||||||
|
|
||||||
|
# Apply limit if specified
|
||||||
|
if limit:
|
||||||
|
call_ins = call_ins[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"call_ins": call_ins,
|
||||||
|
"total": len(call_ins),
|
||||||
|
"hours": hours,
|
||||||
|
"time_threshold": time_threshold.isoformat()
|
||||||
|
}
|
||||||
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"]}
|
||||||
|
)
|
||||||
34
app/seismo/routers/dashboard_tabs.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# backend/routers/dashboard_tabs.py
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
|
||||||
|
|
||||||
|
@router.get("/active")
|
||||||
|
def get_active_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return only ACTIVE (deployed) units for dashboard table swap.
|
||||||
|
"""
|
||||||
|
snap = emit_status_snapshot()
|
||||||
|
units = {
|
||||||
|
uid: u
|
||||||
|
for uid, u in snap["units"].items()
|
||||||
|
if u["deployed"] is True
|
||||||
|
}
|
||||||
|
return {"units": units}
|
||||||
|
|
||||||
|
@router.get("/benched")
|
||||||
|
def get_benched_units(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Return only BENCHED (not deployed) units for dashboard table swap.
|
||||||
|
"""
|
||||||
|
snap = emit_status_snapshot()
|
||||||
|
units = {
|
||||||
|
uid: u
|
||||||
|
for uid, u in snap["units"].items()
|
||||||
|
if u["deployed"] is False
|
||||||
|
}
|
||||||
|
return {"units": units}
|
||||||
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
|
||||||
|
}
|
||||||
|
)
|
||||||
242
app/seismo/routers/photos.py
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
from PIL import Image
|
||||||
|
from PIL.ExifTags import TAGS, GPSTAGS
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
|
PHOTOS_BASE_DIR = Path("data/photos")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_exif_data(image_path: Path) -> dict:
|
||||||
|
"""
|
||||||
|
Extract EXIF metadata from an image file.
|
||||||
|
Returns dict with timestamp, GPS coordinates, and other metadata.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
image = Image.open(image_path)
|
||||||
|
exif_data = image._getexif()
|
||||||
|
|
||||||
|
if not exif_data:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
metadata = {}
|
||||||
|
|
||||||
|
# Extract standard EXIF tags
|
||||||
|
for tag_id, value in exif_data.items():
|
||||||
|
tag = TAGS.get(tag_id, tag_id)
|
||||||
|
|
||||||
|
# Extract datetime
|
||||||
|
if tag == "DateTime" or tag == "DateTimeOriginal":
|
||||||
|
try:
|
||||||
|
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Extract GPS data
|
||||||
|
if tag == "GPSInfo":
|
||||||
|
gps_data = {}
|
||||||
|
for gps_tag_id in value:
|
||||||
|
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
|
||||||
|
gps_data[gps_tag] = value[gps_tag_id]
|
||||||
|
|
||||||
|
# Convert GPS data to decimal degrees
|
||||||
|
lat = gps_data.get("GPSLatitude")
|
||||||
|
lat_ref = gps_data.get("GPSLatitudeRef")
|
||||||
|
lon = gps_data.get("GPSLongitude")
|
||||||
|
lon_ref = gps_data.get("GPSLongitudeRef")
|
||||||
|
|
||||||
|
if lat and lon and lat_ref and lon_ref:
|
||||||
|
# Convert to decimal degrees
|
||||||
|
lat_decimal = convert_to_degrees(lat)
|
||||||
|
if lat_ref == "S":
|
||||||
|
lat_decimal = -lat_decimal
|
||||||
|
|
||||||
|
lon_decimal = convert_to_degrees(lon)
|
||||||
|
if lon_ref == "W":
|
||||||
|
lon_decimal = -lon_decimal
|
||||||
|
|
||||||
|
metadata["latitude"] = lat_decimal
|
||||||
|
metadata["longitude"] = lon_decimal
|
||||||
|
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error extracting EXIF data: {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def convert_to_degrees(value):
|
||||||
|
"""
|
||||||
|
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
|
||||||
|
"""
|
||||||
|
d, m, s = value
|
||||||
|
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unit/{unit_id}/upload-photo")
|
||||||
|
async def upload_photo(
|
||||||
|
unit_id: str,
|
||||||
|
photo: UploadFile = File(...),
|
||||||
|
auto_populate_coords: bool = True,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Upload a photo for a unit and extract EXIF metadata.
|
||||||
|
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
|
||||||
|
"""
|
||||||
|
# Validate file type
|
||||||
|
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
file_ext = Path(photo.filename).suffix.lower()
|
||||||
|
|
||||||
|
if file_ext not in allowed_extensions:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create photos directory for this unit
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Generate filename with timestamp to avoid collisions
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
filename = f"{timestamp}_{photo.filename}"
|
||||||
|
file_path = unit_photo_dir / filename
|
||||||
|
|
||||||
|
# Save the file
|
||||||
|
try:
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(photo.file, buffer)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
|
||||||
|
|
||||||
|
# Extract EXIF metadata
|
||||||
|
metadata = extract_exif_data(file_path)
|
||||||
|
|
||||||
|
# Update unit coordinates if GPS data exists and auto_populate_coords is True
|
||||||
|
coordinates_updated = False
|
||||||
|
if auto_populate_coords and "coordinates" in metadata:
|
||||||
|
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
|
|
||||||
|
if roster_unit:
|
||||||
|
roster_unit.coordinates = metadata["coordinates"]
|
||||||
|
roster_unit.last_updated = datetime.utcnow()
|
||||||
|
db.commit()
|
||||||
|
coordinates_updated = True
|
||||||
|
|
||||||
|
return JSONResponse(content={
|
||||||
|
"success": True,
|
||||||
|
"filename": filename,
|
||||||
|
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
|
||||||
|
"metadata": {
|
||||||
|
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
|
||||||
|
"latitude": metadata.get("latitude"),
|
||||||
|
"longitude": metadata.get("longitude"),
|
||||||
|
"coordinates": metadata.get("coordinates")
|
||||||
|
},
|
||||||
|
"coordinates_updated": coordinates_updated
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/photos")
|
||||||
|
def get_unit_photos(unit_id: str):
|
||||||
|
"""
|
||||||
|
Reads /data/photos/<unit_id>/ and returns list of image filenames.
|
||||||
|
Primary photo = most recent file.
|
||||||
|
"""
|
||||||
|
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||||
|
|
||||||
|
if not unit_photo_dir.exists():
|
||||||
|
# Return empty list if no photos directory exists
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"photos": [],
|
||||||
|
"primary_photo": None
|
||||||
|
}
|
||||||
|
|
||||||
|
# Get all image files
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
photos = []
|
||||||
|
|
||||||
|
for file_path in unit_photo_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
photos.append({
|
||||||
|
"filename": file_path.name,
|
||||||
|
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||||
|
"modified": file_path.stat().st_mtime
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (most recent first)
|
||||||
|
photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
|
|
||||||
|
# Primary photo is the most recent
|
||||||
|
primary_photo = photos[0]["filename"] if photos else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"photos": [p["filename"] for p in photos],
|
||||||
|
"primary_photo": primary_photo,
|
||||||
|
"photo_urls": [p["path"] for p in photos]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/recent-photos")
|
||||||
|
def get_recent_photos(limit: int = 12):
|
||||||
|
"""
|
||||||
|
Get the most recently uploaded photos across all units.
|
||||||
|
Returns photos sorted by modification time (newest first).
|
||||||
|
"""
|
||||||
|
if not PHOTOS_BASE_DIR.exists():
|
||||||
|
return {"photos": []}
|
||||||
|
|
||||||
|
all_photos = []
|
||||||
|
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||||
|
|
||||||
|
# Scan all unit directories
|
||||||
|
for unit_dir in PHOTOS_BASE_DIR.iterdir():
|
||||||
|
if not unit_dir.is_dir():
|
||||||
|
continue
|
||||||
|
|
||||||
|
unit_id = unit_dir.name
|
||||||
|
|
||||||
|
# Get all photos in this unit's directory
|
||||||
|
for file_path in unit_dir.iterdir():
|
||||||
|
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||||
|
all_photos.append({
|
||||||
|
"unit_id": unit_id,
|
||||||
|
"filename": file_path.name,
|
||||||
|
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||||
|
"modified": file_path.stat().st_mtime,
|
||||||
|
"modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by modification time (most recent first) and limit
|
||||||
|
all_photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||||
|
recent_photos = all_photos[:limit]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"photos": recent_photos,
|
||||||
|
"total": len(all_photos)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||||
|
def get_photo(unit_id: str, filename: str):
|
||||||
|
"""
|
||||||
|
Serves a specific photo file.
|
||||||
|
"""
|
||||||
|
file_path = PHOTOS_BASE_DIR / unit_id / filename
|
||||||
|
|
||||||
|
if not file_path.exists() or not file_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail="Photo not found")
|
||||||
|
|
||||||
|
return FileResponse(file_path)
|
||||||
46
app/seismo/routers/roster.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Dict, Any
|
||||||
|
import random
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["roster"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status-snapshot")
|
||||||
|
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Calls emit_status_snapshot() to get current fleet status.
|
||||||
|
This will be replaced with real Series3 emitter logic later.
|
||||||
|
"""
|
||||||
|
return emit_status_snapshot()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster")
|
||||||
|
def get_roster(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns list of units with their metadata and status.
|
||||||
|
Uses mock data for now.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
units_list = []
|
||||||
|
|
||||||
|
for unit_id, unit_data in snapshot["units"].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", ""),
|
||||||
|
"last_file": unit_data.get("fname", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
# 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 {"units": units_list}
|
||||||
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}
|
||||||
81
app/seismo/routers/seismo_dashboard.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""
|
||||||
|
Seismograph Dashboard API Router
|
||||||
|
Provides endpoints for the seismograph-specific dashboard
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Request, Depends, Query
|
||||||
|
from fastapi.responses import HTMLResponse
|
||||||
|
from fastapi.templating import Jinja2Templates
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||||
|
templates = Jinja2Templates(directory="app/ui/templates")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with seismograph statistics summary
|
||||||
|
"""
|
||||||
|
# Get all seismograph units
|
||||||
|
seismos = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
).all()
|
||||||
|
|
||||||
|
total = len(seismos)
|
||||||
|
deployed = sum(1 for s in seismos if s.deployed)
|
||||||
|
benched = sum(1 for s in seismos if not s.deployed)
|
||||||
|
|
||||||
|
# Count modems assigned to deployed seismographs
|
||||||
|
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||||
|
without_modem = deployed - with_modem
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_stats.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"total": total,
|
||||||
|
"deployed": deployed,
|
||||||
|
"benched": benched,
|
||||||
|
"with_modem": with_modem,
|
||||||
|
"without_modem": without_modem
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/units", response_class=HTMLResponse)
|
||||||
|
async def get_seismo_units(
|
||||||
|
request: Request,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
search: str = Query(None)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Returns HTML partial with filterable seismograph unit list
|
||||||
|
"""
|
||||||
|
query = db.query(RosterUnit).filter_by(
|
||||||
|
device_type="seismograph",
|
||||||
|
retired=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply search filter
|
||||||
|
if search:
|
||||||
|
search_lower = search.lower()
|
||||||
|
query = query.filter(
|
||||||
|
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||||
|
(RosterUnit.address.ilike(f"%{search}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
seismos = query.order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return templates.TemplateResponse(
|
||||||
|
"partials/seismo_unit_list.html",
|
||||||
|
{
|
||||||
|
"request": request,
|
||||||
|
"units": seismos,
|
||||||
|
"search": search or ""
|
||||||
|
}
|
||||||
|
)
|
||||||
479
app/seismo/routers/settings.py
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
|
from app.seismo.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export-csv")
|
||||||
|
def export_roster_csv(db: Session = Depends(get_db)):
|
||||||
|
"""Export all roster units to CSV"""
|
||||||
|
units = db.query(RosterUnit).all()
|
||||||
|
|
||||||
|
# Create CSV in memory
|
||||||
|
output = io.StringIO()
|
||||||
|
fieldnames = [
|
||||||
|
'unit_id', 'unit_type', 'device_type', 'deployed', 'retired',
|
||||||
|
'note', 'project_id', 'location', 'address', 'coordinates',
|
||||||
|
'last_calibrated', 'next_calibration_due', 'deployed_with_modem_id',
|
||||||
|
'ip_address', 'phone_number', 'hardware_model'
|
||||||
|
]
|
||||||
|
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||||
|
writer.writeheader()
|
||||||
|
|
||||||
|
for unit in units:
|
||||||
|
writer.writerow({
|
||||||
|
'unit_id': unit.id,
|
||||||
|
'unit_type': unit.unit_type or '',
|
||||||
|
'device_type': unit.device_type or 'seismograph',
|
||||||
|
'deployed': 'true' if unit.deployed else 'false',
|
||||||
|
'retired': 'true' if unit.retired else 'false',
|
||||||
|
'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.strftime('%Y-%m-%d') if unit.last_calibrated else '',
|
||||||
|
'next_calibration_due': unit.next_calibration_due.strftime('%Y-%m-%d') 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 ''
|
||||||
|
})
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
filename = f"roster_export_{date.today().isoformat()}.csv"
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
io.BytesIO(output.getvalue().encode('utf-8')),
|
||||||
|
media_type="text/csv",
|
||||||
|
headers={"Content-Disposition": f"attachment; filename={filename}"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stats")
|
||||||
|
def get_table_stats(db: Session = Depends(get_db)):
|
||||||
|
"""Get counts for all tables"""
|
||||||
|
roster_count = db.query(RosterUnit).count()
|
||||||
|
emitters_count = db.query(Emitter).count()
|
||||||
|
ignored_count = db.query(IgnoredUnit).count()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"roster": roster_count,
|
||||||
|
"emitters": emitters_count,
|
||||||
|
"ignored": ignored_count,
|
||||||
|
"total": roster_count + emitters_count + ignored_count
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/roster-units")
|
||||||
|
def get_all_roster_units(db: Session = Depends(get_db)):
|
||||||
|
"""Get all roster units for management table"""
|
||||||
|
units = db.query(RosterUnit).order_by(RosterUnit.id).all()
|
||||||
|
|
||||||
|
return [{
|
||||||
|
"id": unit.id,
|
||||||
|
"device_type": unit.device_type or "seismograph",
|
||||||
|
"unit_type": unit.unit_type or "series3",
|
||||||
|
"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 None,
|
||||||
|
"next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else None,
|
||||||
|
"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,
|
||||||
|
"slm_model": unit.slm_model or "",
|
||||||
|
"slm_serial_number": unit.slm_serial_number or "",
|
||||||
|
"slm_frequency_weighting": unit.slm_frequency_weighting or "",
|
||||||
|
"slm_time_weighting": unit.slm_time_weighting or "",
|
||||||
|
"slm_measurement_range": unit.slm_measurement_range or "",
|
||||||
|
"slm_last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||||
|
"last_updated": unit.last_updated.isoformat() if unit.last_updated else None
|
||||||
|
} for unit in units]
|
||||||
|
|
||||||
|
|
||||||
|
def parse_date(date_str):
|
||||||
|
"""Helper function to parse date strings"""
|
||||||
|
if not date_str or not date_str.strip():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str.strip(), "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import-csv-replace")
|
||||||
|
async def import_csv_replace(
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Replace all roster data with CSV import (atomic transaction).
|
||||||
|
Clears roster table first, then imports all rows from CSV.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not file.filename.endswith('.csv'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||||
|
|
||||||
|
# Read and parse CSV
|
||||||
|
contents = await file.read()
|
||||||
|
csv_text = contents.decode('utf-8')
|
||||||
|
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||||
|
|
||||||
|
# Parse all rows FIRST (fail fast before deletion)
|
||||||
|
parsed_units = []
|
||||||
|
for row_num, row in enumerate(csv_reader, start=2):
|
||||||
|
unit_id = row.get('unit_id', '').strip()
|
||||||
|
if not unit_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"Row {row_num}: Missing required field unit_id"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Parse and validate dates
|
||||||
|
last_cal_date = parse_date(row.get('last_calibrated'))
|
||||||
|
next_cal_date = parse_date(row.get('next_calibration_due'))
|
||||||
|
|
||||||
|
parsed_units.append({
|
||||||
|
'id': unit_id,
|
||||||
|
'unit_type': row.get('unit_type', 'series3'),
|
||||||
|
'device_type': row.get('device_type', 'seismograph'),
|
||||||
|
'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') or None,
|
||||||
|
'location': row.get('location') or None,
|
||||||
|
'address': row.get('address') or None,
|
||||||
|
'coordinates': row.get('coordinates') or None,
|
||||||
|
'last_calibrated': last_cal_date,
|
||||||
|
'next_calibration_due': next_cal_date,
|
||||||
|
'deployed_with_modem_id': row.get('deployed_with_modem_id') or None,
|
||||||
|
'ip_address': row.get('ip_address') or None,
|
||||||
|
'phone_number': row.get('phone_number') or None,
|
||||||
|
'hardware_model': row.get('hardware_model') or None,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Atomic transaction: delete all, then insert all
|
||||||
|
try:
|
||||||
|
deleted_count = db.query(RosterUnit).delete()
|
||||||
|
|
||||||
|
for unit_data in parsed_units:
|
||||||
|
new_unit = RosterUnit(**unit_data, last_updated=datetime.utcnow())
|
||||||
|
db.add(new_unit)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Roster replaced successfully",
|
||||||
|
"deleted": deleted_count,
|
||||||
|
"added": len(parsed_units)
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Import failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-all")
|
||||||
|
def clear_all_data(db: Session = Depends(get_db)):
|
||||||
|
"""Clear all tables (roster, emitters, ignored)"""
|
||||||
|
try:
|
||||||
|
roster_count = db.query(RosterUnit).delete()
|
||||||
|
emitters_count = db.query(Emitter).delete()
|
||||||
|
ignored_count = db.query(IgnoredUnit).delete()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "All data cleared",
|
||||||
|
"deleted": {
|
||||||
|
"roster": roster_count,
|
||||||
|
"emitters": emitters_count,
|
||||||
|
"ignored": ignored_count,
|
||||||
|
"total": roster_count + emitters_count + ignored_count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-roster")
|
||||||
|
def clear_roster(db: Session = Depends(get_db)):
|
||||||
|
"""Clear roster table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(RosterUnit).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Roster cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-emitters")
|
||||||
|
def clear_emitters(db: Session = Depends(get_db)):
|
||||||
|
"""Clear emitters table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(Emitter).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Emitters cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/clear-ignored")
|
||||||
|
def clear_ignored(db: Session = Depends(get_db)):
|
||||||
|
"""Clear ignored units table only"""
|
||||||
|
try:
|
||||||
|
count = db.query(IgnoredUnit).delete()
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Ignored units cleared", "deleted": count}
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# User Preferences Endpoints
|
||||||
|
|
||||||
|
class PreferencesUpdate(BaseModel):
|
||||||
|
"""Schema for updating user preferences (all fields optional)"""
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
theme: Optional[str] = None
|
||||||
|
auto_refresh_interval: Optional[int] = None
|
||||||
|
date_format: Optional[str] = None
|
||||||
|
table_rows_per_page: Optional[int] = None
|
||||||
|
calibration_interval_days: Optional[int] = None
|
||||||
|
calibration_warning_days: Optional[int] = None
|
||||||
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences")
|
||||||
|
def get_preferences(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get user preferences. Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
def update_preferences(
|
||||||
|
updates: PreferencesUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update user preferences. Accepts partial updates.
|
||||||
|
Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = updates.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(prefs, field, value)
|
||||||
|
|
||||||
|
prefs.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Preferences updated successfully",
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Database Management Endpoints
|
||||||
|
|
||||||
|
backup_service = DatabaseBackupService()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/stats")
|
||||||
|
def get_database_stats():
|
||||||
|
"""Get current database statistics"""
|
||||||
|
try:
|
||||||
|
stats = backup_service.get_database_stats()
|
||||||
|
return stats
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/snapshot")
|
||||||
|
def create_database_snapshot(description: Optional[str] = None):
|
||||||
|
"""Create a full database snapshot"""
|
||||||
|
try:
|
||||||
|
snapshot = backup_service.create_snapshot(description=description)
|
||||||
|
return {
|
||||||
|
"message": "Snapshot created successfully",
|
||||||
|
"snapshot": snapshot
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/snapshots")
|
||||||
|
def list_database_snapshots():
|
||||||
|
"""List all available database snapshots"""
|
||||||
|
try:
|
||||||
|
snapshots = backup_service.list_snapshots()
|
||||||
|
return {
|
||||||
|
"snapshots": snapshots,
|
||||||
|
"count": len(snapshots)
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/database/snapshot/{filename}")
|
||||||
|
def download_snapshot(filename: str):
|
||||||
|
"""Download a specific snapshot file"""
|
||||||
|
try:
|
||||||
|
snapshot_path = backup_service.download_snapshot(filename)
|
||||||
|
return FileResponse(
|
||||||
|
path=str(snapshot_path),
|
||||||
|
filename=filename,
|
||||||
|
media_type="application/x-sqlite3"
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/database/snapshot/{filename}")
|
||||||
|
def delete_database_snapshot(filename: str):
|
||||||
|
"""Delete a specific snapshot"""
|
||||||
|
try:
|
||||||
|
backup_service.delete_snapshot(filename)
|
||||||
|
return {
|
||||||
|
"message": f"Snapshot {filename} deleted successfully",
|
||||||
|
"filename": filename
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
"""Schema for restore request"""
|
||||||
|
filename: str
|
||||||
|
create_backup: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/restore")
|
||||||
|
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
|
||||||
|
"""Restore database from a snapshot"""
|
||||||
|
try:
|
||||||
|
# Close the database connection before restoring
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
result = backup_service.restore_snapshot(
|
||||||
|
filename=request.filename,
|
||||||
|
create_backup_before_restore=request.create_backup
|
||||||
|
)
|
||||||
|
|
||||||
|
return result
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/database/upload-snapshot")
|
||||||
|
async def upload_snapshot(file: UploadFile = File(...)):
|
||||||
|
"""Upload a snapshot file to the backups directory"""
|
||||||
|
if not file.filename.endswith('.db'):
|
||||||
|
raise HTTPException(status_code=400, detail="File must be a .db file")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Save uploaded file to backups directory
|
||||||
|
backups_dir = Path("./data/backups")
|
||||||
|
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
|
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
|
||||||
|
file_path = backups_dir / uploaded_filename
|
||||||
|
|
||||||
|
# Save file
|
||||||
|
with open(file_path, "wb") as buffer:
|
||||||
|
shutil.copyfileobj(file.file, buffer)
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"filename": uploaded_filename,
|
||||||
|
"created_at": timestamp,
|
||||||
|
"created_at_iso": datetime.utcnow().isoformat(),
|
||||||
|
"description": f"Uploaded: {file.filename}",
|
||||||
|
"size_bytes": file_path.stat().st_size,
|
||||||
|
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
|
||||||
|
"type": "uploaded"
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
|
||||||
|
import json
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Snapshot uploaded successfully",
|
||||||
|
"snapshot": metadata
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||||
44
app/seismo/routers/units.py
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Dict, Any
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.services.snapshot import emit_status_snapshot
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api", tags=["units"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/unit/{unit_id}")
|
||||||
|
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns detailed data for a single unit.
|
||||||
|
"""
|
||||||
|
snapshot = emit_status_snapshot()
|
||||||
|
|
||||||
|
if unit_id not in snapshot["units"]:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||||
|
|
||||||
|
unit_data = snapshot["units"][unit_id]
|
||||||
|
|
||||||
|
# Mock coordinates for now (will be replaced with real data)
|
||||||
|
mock_coords = {
|
||||||
|
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||||
|
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||||
|
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||||
|
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||||
|
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||||
|
}
|
||||||
|
|
||||||
|
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": unit_data["status"],
|
||||||
|
"age": unit_data["age"],
|
||||||
|
"last_seen": unit_data["last"],
|
||||||
|
"last_file": unit_data.get("fname", ""),
|
||||||
|
"deployed": unit_data["deployed"],
|
||||||
|
"note": unit_data.get("note", ""),
|
||||||
|
"coordinates": coords
|
||||||
|
}
|
||||||
286
app/seismo/routes.py
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from app.seismo.database import get_db
|
||||||
|
from app.seismo.models import Emitter
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
# Helper function to detect unit type from unit ID
|
||||||
|
def detect_unit_type(unit_id: str) -> str:
|
||||||
|
"""
|
||||||
|
Automatically detect if a unit is Series 3 or Series 4 based on ID pattern.
|
||||||
|
|
||||||
|
Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719)
|
||||||
|
Series 3 units typically have other patterns
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"series4" if the unit ID matches Micromate pattern (UM#####)
|
||||||
|
"series3" otherwise
|
||||||
|
"""
|
||||||
|
if not unit_id:
|
||||||
|
return "unknown"
|
||||||
|
|
||||||
|
# Series 4 (Micromate) pattern: UM followed by digits
|
||||||
|
if unit_id.upper().startswith("UM") and len(unit_id) > 2:
|
||||||
|
# Check if remaining characters after "UM" are digits
|
||||||
|
rest = unit_id[2:]
|
||||||
|
if rest.isdigit():
|
||||||
|
return "series4"
|
||||||
|
|
||||||
|
# Default to series3 for other patterns
|
||||||
|
return "series3"
|
||||||
|
|
||||||
|
|
||||||
|
# Pydantic schemas for request/response validation
|
||||||
|
class EmitterReport(BaseModel):
|
||||||
|
unit: str
|
||||||
|
unit_type: str
|
||||||
|
timestamp: str
|
||||||
|
file: str
|
||||||
|
status: str
|
||||||
|
|
||||||
|
|
||||||
|
class EmitterResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
unit_type: str
|
||||||
|
last_seen: datetime
|
||||||
|
last_file: str
|
||||||
|
status: str
|
||||||
|
notes: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
from_attributes = True
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/emitters/report", status_code=200)
|
||||||
|
def report_emitter(report: EmitterReport, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Endpoint for emitters to report their status.
|
||||||
|
Creates a new emitter if it doesn't exist, or updates an existing one.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Parse the timestamp
|
||||||
|
timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid timestamp format")
|
||||||
|
|
||||||
|
# Check if emitter already exists
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == report.unit).first()
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing emitter
|
||||||
|
emitter.unit_type = report.unit_type
|
||||||
|
emitter.last_seen = timestamp
|
||||||
|
emitter.last_file = report.file
|
||||||
|
emitter.status = report.status
|
||||||
|
else:
|
||||||
|
# Create new emitter
|
||||||
|
emitter = Emitter(
|
||||||
|
id=report.unit,
|
||||||
|
unit_type=report.unit_type,
|
||||||
|
last_seen=timestamp,
|
||||||
|
last_file=report.file,
|
||||||
|
status=report.status
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(emitter)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Emitter report received",
|
||||||
|
"unit": emitter.id,
|
||||||
|
"status": emitter.status
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fleet/status", response_model=List[EmitterResponse])
|
||||||
|
def get_fleet_status(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Returns a list of all emitters and their current status.
|
||||||
|
"""
|
||||||
|
emitters = db.query(Emitter).all()
|
||||||
|
return emitters
|
||||||
|
|
||||||
|
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
@router.post("/api/series3/heartbeat", status_code=200)
|
||||||
|
async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Accepts a full telemetry payload from the Series3 emitter.
|
||||||
|
Updates or inserts each unit into the database.
|
||||||
|
"""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
source = payload.get("source_id")
|
||||||
|
units = payload.get("units", [])
|
||||||
|
|
||||||
|
print("\n=== Series 3 Heartbeat ===")
|
||||||
|
print("Source:", source)
|
||||||
|
print("Units received:", len(units))
|
||||||
|
print("==========================\n")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for u in units:
|
||||||
|
uid = u.get("unit_id")
|
||||||
|
last_event_time = u.get("last_event_time")
|
||||||
|
event_meta = u.get("event_metadata", {})
|
||||||
|
age_minutes = u.get("age_minutes")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if last_event_time:
|
||||||
|
ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
ts = None
|
||||||
|
except:
|
||||||
|
ts = None
|
||||||
|
|
||||||
|
# Pull from DB
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||||
|
|
||||||
|
# File name (from event_metadata)
|
||||||
|
last_file = event_meta.get("file_name")
|
||||||
|
status = "Unknown"
|
||||||
|
|
||||||
|
# Determine status based on age
|
||||||
|
if age_minutes is None:
|
||||||
|
status = "Missing"
|
||||||
|
elif age_minutes > 24 * 60:
|
||||||
|
status = "Missing"
|
||||||
|
elif age_minutes > 12 * 60:
|
||||||
|
status = "Pending"
|
||||||
|
else:
|
||||||
|
status = "OK"
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing
|
||||||
|
emitter.last_seen = ts
|
||||||
|
emitter.last_file = last_file
|
||||||
|
emitter.status = status
|
||||||
|
# Update unit_type if it was incorrectly classified
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
if emitter.unit_type != detected_type:
|
||||||
|
emitter.unit_type = detected_type
|
||||||
|
else:
|
||||||
|
# Insert new - auto-detect unit type from ID
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
emitter = Emitter(
|
||||||
|
id=uid,
|
||||||
|
unit_type=detected_type,
|
||||||
|
last_seen=ts,
|
||||||
|
last_file=last_file,
|
||||||
|
status=status
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Heartbeat processed",
|
||||||
|
"source": source,
|
||||||
|
"units_processed": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# series4 (Micromate) Standardized Heartbeat Schema
|
||||||
|
@router.post("/api/series4/heartbeat", status_code=200)
|
||||||
|
async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Accepts a full telemetry payload from the Series4 (Micromate) emitter.
|
||||||
|
Updates or inserts each unit into the database.
|
||||||
|
|
||||||
|
Expected payload:
|
||||||
|
{
|
||||||
|
"source": "series4_emitter",
|
||||||
|
"generated_at": "2025-12-04T20:01:00",
|
||||||
|
"units": [
|
||||||
|
{
|
||||||
|
"unit_id": "UM11719",
|
||||||
|
"type": "micromate",
|
||||||
|
"project_hint": "Clearwater - ECMS 57940",
|
||||||
|
"last_call": "2025-12-04T19:30:42",
|
||||||
|
"status": "OK",
|
||||||
|
"age_days": 0.04,
|
||||||
|
"age_hours": 0.9,
|
||||||
|
"mlg_path": "C:\\THORDATA\\..."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
payload = await request.json()
|
||||||
|
|
||||||
|
source = payload.get("source", "series4_emitter")
|
||||||
|
units = payload.get("units", [])
|
||||||
|
|
||||||
|
print("\n=== Series 4 Heartbeat ===")
|
||||||
|
print("Source:", source)
|
||||||
|
print("Units received:", len(units))
|
||||||
|
print("==========================\n")
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
for u in units:
|
||||||
|
uid = u.get("unit_id")
|
||||||
|
last_call_str = u.get("last_call")
|
||||||
|
status = u.get("status", "Unknown")
|
||||||
|
mlg_path = u.get("mlg_path")
|
||||||
|
project_hint = u.get("project_hint")
|
||||||
|
|
||||||
|
# Parse last_call timestamp
|
||||||
|
try:
|
||||||
|
if last_call_str:
|
||||||
|
ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00"))
|
||||||
|
else:
|
||||||
|
ts = None
|
||||||
|
except:
|
||||||
|
ts = None
|
||||||
|
|
||||||
|
# Pull from DB
|
||||||
|
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||||
|
|
||||||
|
if emitter:
|
||||||
|
# Update existing
|
||||||
|
emitter.last_seen = ts
|
||||||
|
emitter.last_file = mlg_path
|
||||||
|
emitter.status = status
|
||||||
|
# Update unit_type if it was incorrectly classified
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
if emitter.unit_type != detected_type:
|
||||||
|
emitter.unit_type = detected_type
|
||||||
|
# Optionally update notes with project hint if it exists
|
||||||
|
if project_hint and not emitter.notes:
|
||||||
|
emitter.notes = f"Project: {project_hint}"
|
||||||
|
else:
|
||||||
|
# Insert new - auto-detect unit type from ID
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
|
notes = f"Project: {project_hint}" if project_hint else None
|
||||||
|
emitter = Emitter(
|
||||||
|
id=uid,
|
||||||
|
unit_type=detected_type,
|
||||||
|
last_seen=ts,
|
||||||
|
last_file=mlg_path,
|
||||||
|
status=status,
|
||||||
|
notes=notes
|
||||||
|
)
|
||||||
|
db.add(emitter)
|
||||||
|
|
||||||
|
results.append({"unit": uid, "status": status})
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Heartbeat processed",
|
||||||
|
"source": source,
|
||||||
|
"units_processed": len(results),
|
||||||
|
"results": results
|
||||||
|
}
|
||||||
0
app/seismo/services/__init__.py
Normal file
145
app/seismo/services/backup_scheduler.py
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
"""
|
||||||
|
Automatic Database Backup Scheduler
|
||||||
|
Handles scheduled automatic backups of the database
|
||||||
|
"""
|
||||||
|
|
||||||
|
import schedule
|
||||||
|
import time
|
||||||
|
import threading
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.seismo.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BackupScheduler:
|
||||||
|
"""Manages automatic database backups on a schedule"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||||
|
self.backup_service = DatabaseBackupService(db_path=db_path, backups_dir=backups_dir)
|
||||||
|
self.scheduler_thread: Optional[threading.Thread] = None
|
||||||
|
self.is_running = False
|
||||||
|
|
||||||
|
# Default settings
|
||||||
|
self.backup_interval_hours = 24 # Daily backups
|
||||||
|
self.keep_count = 10 # Keep last 10 backups
|
||||||
|
self.enabled = False
|
||||||
|
|
||||||
|
def configure(self, interval_hours: int = 24, keep_count: int = 10, enabled: bool = True):
|
||||||
|
"""
|
||||||
|
Configure backup scheduler settings
|
||||||
|
|
||||||
|
Args:
|
||||||
|
interval_hours: Hours between automatic backups
|
||||||
|
keep_count: Number of backups to retain
|
||||||
|
enabled: Whether automatic backups are enabled
|
||||||
|
"""
|
||||||
|
self.backup_interval_hours = interval_hours
|
||||||
|
self.keep_count = keep_count
|
||||||
|
self.enabled = enabled
|
||||||
|
|
||||||
|
logger.info(f"Backup scheduler configured: interval={interval_hours}h, keep={keep_count}, enabled={enabled}")
|
||||||
|
|
||||||
|
def create_automatic_backup(self):
|
||||||
|
"""Create an automatic backup and cleanup old ones"""
|
||||||
|
if not self.enabled:
|
||||||
|
logger.info("Automatic backups are disabled, skipping")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y-%m-%d %H:%M UTC")
|
||||||
|
description = f"Automatic backup - {timestamp}"
|
||||||
|
|
||||||
|
logger.info("Creating automatic backup...")
|
||||||
|
snapshot = self.backup_service.create_snapshot(description=description)
|
||||||
|
|
||||||
|
logger.info(f"Automatic backup created: {snapshot['filename']} ({snapshot['size_mb']} MB)")
|
||||||
|
|
||||||
|
# Cleanup old backups
|
||||||
|
cleanup_result = self.backup_service.cleanup_old_snapshots(keep_count=self.keep_count)
|
||||||
|
if cleanup_result['deleted'] > 0:
|
||||||
|
logger.info(f"Cleaned up {cleanup_result['deleted']} old snapshots")
|
||||||
|
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Automatic backup failed: {str(e)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the backup scheduler in a background thread"""
|
||||||
|
if self.is_running:
|
||||||
|
logger.warning("Backup scheduler is already running")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not self.enabled:
|
||||||
|
logger.info("Backup scheduler is disabled, not starting")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info(f"Starting backup scheduler (every {self.backup_interval_hours} hours)")
|
||||||
|
|
||||||
|
# Clear any existing scheduled jobs
|
||||||
|
schedule.clear()
|
||||||
|
|
||||||
|
# Schedule the backup job
|
||||||
|
schedule.every(self.backup_interval_hours).hours.do(self.create_automatic_backup)
|
||||||
|
|
||||||
|
# Also run immediately on startup
|
||||||
|
self.create_automatic_backup()
|
||||||
|
|
||||||
|
# Start the scheduler thread
|
||||||
|
self.is_running = True
|
||||||
|
self.scheduler_thread = threading.Thread(target=self._run_scheduler, daemon=True)
|
||||||
|
self.scheduler_thread.start()
|
||||||
|
|
||||||
|
logger.info("Backup scheduler started successfully")
|
||||||
|
|
||||||
|
def _run_scheduler(self):
|
||||||
|
"""Internal method to run the scheduler loop"""
|
||||||
|
while self.is_running:
|
||||||
|
schedule.run_pending()
|
||||||
|
time.sleep(60) # Check every minute
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the backup scheduler"""
|
||||||
|
if not self.is_running:
|
||||||
|
logger.warning("Backup scheduler is not running")
|
||||||
|
return
|
||||||
|
|
||||||
|
logger.info("Stopping backup scheduler...")
|
||||||
|
self.is_running = False
|
||||||
|
schedule.clear()
|
||||||
|
|
||||||
|
if self.scheduler_thread:
|
||||||
|
self.scheduler_thread.join(timeout=5)
|
||||||
|
|
||||||
|
logger.info("Backup scheduler stopped")
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get current scheduler status"""
|
||||||
|
next_run = None
|
||||||
|
if self.is_running and schedule.jobs:
|
||||||
|
next_run = schedule.jobs[0].next_run.isoformat() if schedule.jobs[0].next_run else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"enabled": self.enabled,
|
||||||
|
"running": self.is_running,
|
||||||
|
"interval_hours": self.backup_interval_hours,
|
||||||
|
"keep_count": self.keep_count,
|
||||||
|
"next_run": next_run
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global scheduler instance
|
||||||
|
_scheduler_instance: Optional[BackupScheduler] = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_backup_scheduler() -> BackupScheduler:
|
||||||
|
"""Get or create the global backup scheduler instance"""
|
||||||
|
global _scheduler_instance
|
||||||
|
if _scheduler_instance is None:
|
||||||
|
_scheduler_instance = BackupScheduler()
|
||||||
|
return _scheduler_instance
|
||||||
192
app/seismo/services/database_backup.py
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
"""
|
||||||
|
Database Backup and Restore Service
|
||||||
|
Handles full database snapshots, restoration, and remote synchronization
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Dict, Optional
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
class DatabaseBackupService:
|
||||||
|
"""Manages database backup operations"""
|
||||||
|
|
||||||
|
def __init__(self, db_path: str = "./data/seismo_fleet.db", backups_dir: str = "./data/backups"):
|
||||||
|
self.db_path = Path(db_path)
|
||||||
|
self.backups_dir = Path(backups_dir)
|
||||||
|
self.backups_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def create_snapshot(self, description: Optional[str] = None) -> Dict:
|
||||||
|
"""
|
||||||
|
Create a full database snapshot using SQLite backup API
|
||||||
|
Returns snapshot metadata
|
||||||
|
"""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||||
|
|
||||||
|
# Generate snapshot filename with timestamp
|
||||||
|
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||||
|
snapshot_name = f"snapshot_{timestamp}.db"
|
||||||
|
snapshot_path = self.backups_dir / snapshot_name
|
||||||
|
|
||||||
|
# Get database size before backup
|
||||||
|
db_size = self.db_path.stat().st_size
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Use SQLite backup API for safe backup (handles concurrent access)
|
||||||
|
source_conn = sqlite3.connect(str(self.db_path))
|
||||||
|
dest_conn = sqlite3.connect(str(snapshot_path))
|
||||||
|
|
||||||
|
# Perform the backup
|
||||||
|
with dest_conn:
|
||||||
|
source_conn.backup(dest_conn)
|
||||||
|
|
||||||
|
source_conn.close()
|
||||||
|
dest_conn.close()
|
||||||
|
|
||||||
|
# Create metadata
|
||||||
|
metadata = {
|
||||||
|
"filename": snapshot_name,
|
||||||
|
"created_at": timestamp,
|
||||||
|
"created_at_iso": datetime.utcnow().isoformat(),
|
||||||
|
"description": description or "Manual snapshot",
|
||||||
|
"size_bytes": snapshot_path.stat().st_size,
|
||||||
|
"size_mb": round(snapshot_path.stat().st_size / (1024 * 1024), 2),
|
||||||
|
"original_db_size_bytes": db_size,
|
||||||
|
"type": "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save metadata as JSON sidecar file
|
||||||
|
metadata_path = self.backups_dir / f"{snapshot_name}.meta.json"
|
||||||
|
with open(metadata_path, 'w') as f:
|
||||||
|
json.dump(metadata, f, indent=2)
|
||||||
|
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
# Clean up partial snapshot if it exists
|
||||||
|
if snapshot_path.exists():
|
||||||
|
snapshot_path.unlink()
|
||||||
|
raise Exception(f"Snapshot creation failed: {str(e)}")
|
||||||
|
|
||||||
|
def list_snapshots(self) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
List all available snapshots with metadata
|
||||||
|
Returns list sorted by creation date (newest first)
|
||||||
|
"""
|
||||||
|
snapshots = []
|
||||||
|
|
||||||
|
for db_file in sorted(self.backups_dir.glob("snapshot_*.db"), reverse=True):
|
||||||
|
metadata_file = self.backups_dir / f"{db_file.name}.meta.json"
|
||||||
|
|
||||||
|
if metadata_file.exists():
|
||||||
|
with open(metadata_file, 'r') as f:
|
||||||
|
metadata = json.load(f)
|
||||||
|
else:
|
||||||
|
# Fallback for legacy snapshots without metadata
|
||||||
|
stat_info = db_file.stat()
|
||||||
|
metadata = {
|
||||||
|
"filename": db_file.name,
|
||||||
|
"created_at": datetime.fromtimestamp(stat_info.st_mtime).strftime("%Y%m%d_%H%M%S"),
|
||||||
|
"created_at_iso": datetime.fromtimestamp(stat_info.st_mtime).isoformat(),
|
||||||
|
"description": "Legacy snapshot",
|
||||||
|
"size_bytes": stat_info.st_size,
|
||||||
|
"size_mb": round(stat_info.st_size / (1024 * 1024), 2),
|
||||||
|
"type": "manual"
|
||||||
|
}
|
||||||
|
|
||||||
|
snapshots.append(metadata)
|
||||||
|
|
||||||
|
return snapshots
|
||||||
|
|
||||||
|
def delete_snapshot(self, filename: str) -> bool:
|
||||||
|
"""Delete a snapshot and its metadata"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
metadata_path = self.backups_dir / f"{filename}.meta.json"
|
||||||
|
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
|
||||||
|
snapshot_path.unlink()
|
||||||
|
if metadata_path.exists():
|
||||||
|
metadata_path.unlink()
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
def restore_snapshot(self, filename: str, create_backup_before_restore: bool = True) -> Dict:
|
||||||
|
"""
|
||||||
|
Restore database from a snapshot
|
||||||
|
Creates a safety backup before restoring if requested
|
||||||
|
"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
|
||||||
|
if not self.db_path.exists():
|
||||||
|
raise FileNotFoundError(f"Database not found at {self.db_path}")
|
||||||
|
|
||||||
|
backup_info = None
|
||||||
|
|
||||||
|
# Create safety backup before restore
|
||||||
|
if create_backup_before_restore:
|
||||||
|
backup_info = self.create_snapshot(description="Auto-backup before restore")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Replace database file
|
||||||
|
shutil.copy2(str(snapshot_path), str(self.db_path))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Database restored successfully",
|
||||||
|
"restored_from": filename,
|
||||||
|
"restored_at": datetime.utcnow().isoformat(),
|
||||||
|
"backup_created": backup_info["filename"] if backup_info else None
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
raise Exception(f"Restore failed: {str(e)}")
|
||||||
|
|
||||||
|
def get_database_stats(self) -> Dict:
|
||||||
|
"""Get statistics about the current database"""
|
||||||
|
if not self.db_path.exists():
|
||||||
|
return {"error": "Database not found"}
|
||||||
|
|
||||||
|
conn = sqlite3.connect(str(self.db_path))
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
# Get table counts
|
||||||
|
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
|
||||||
|
tables = cursor.fetchall()
|
||||||
|
|
||||||
|
table_stats = {}
|
||||||
|
total_rows = 0
|
||||||
|
|
||||||
|
for (table_name,) in tables:
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM {table_name}")
|
||||||
|
count = cursor.fetchone()[0]
|
||||||
|
table_stats[table_name] = count
|
||||||
|
total_rows += count
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
db_size = self.db_path.stat().st_size
|
||||||
|
|
||||||
|
return {
|
||||||
|
"database_path": str(self.db_path),
|
||||||
|
"size_bytes": db_size,
|
||||||
|
"size_mb": round(db_size / (1024 * 1024), 2),
|
||||||
|
"total_rows": total_rows,
|
||||||
|
"tables": table_stats,
|
||||||
|
"last_modified": datetime.fromtimestamp(self.db_path.stat().st_mtime).isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
def download_snapshot(self, filename: str) -> Path:
|
||||||
|
"""Get the file path for downloading a snapshot"""
|
||||||
|
snapshot_path = self.backups_dir / filename
|
||||||
|
if not snapshot_path.exists():
|
||||||
|
raise FileNotFoundError(f"Snapshot {filename} not found")
|
||||||
|
return snapshot_path
|
||||||
191
app/seismo/services/snapshot.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.seismo.database import get_db_session
|
||||||
|
from app.seismo.models import Emitter, RosterUnit, IgnoredUnit
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_utc(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def format_age(last_seen):
|
||||||
|
if not last_seen:
|
||||||
|
return "N/A"
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
diff = now - last_seen
|
||||||
|
hours = diff.total_seconds() // 3600
|
||||||
|
mins = (diff.total_seconds() % 3600) // 60
|
||||||
|
return f"{int(hours)}h {int(mins)}m"
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_status(last_seen, status_ok_threshold=12, status_pending_threshold=24):
|
||||||
|
"""
|
||||||
|
Calculate status based on how long ago the unit was last seen.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
last_seen: datetime of last seen (UTC)
|
||||||
|
status_ok_threshold: hours before status becomes Pending (default 12)
|
||||||
|
status_pending_threshold: hours before status becomes Missing (default 24)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
"OK", "Pending", or "Missing"
|
||||||
|
"""
|
||||||
|
if not last_seen:
|
||||||
|
return "Missing"
|
||||||
|
|
||||||
|
last_seen = ensure_utc(last_seen)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
hours_ago = (now - last_seen).total_seconds() / 3600
|
||||||
|
|
||||||
|
if hours_ago > status_pending_threshold:
|
||||||
|
return "Missing"
|
||||||
|
elif hours_ago > status_ok_threshold:
|
||||||
|
return "Pending"
|
||||||
|
else:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
|
def emit_status_snapshot():
|
||||||
|
"""
|
||||||
|
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||||
|
Status is recalculated based on current time to ensure accuracy.
|
||||||
|
"""
|
||||||
|
|
||||||
|
db = get_db_session()
|
||||||
|
try:
|
||||||
|
# Get user preferences for status thresholds
|
||||||
|
from app.seismo.models import UserPreferences
|
||||||
|
prefs = db.query(UserPreferences).filter_by(id=1).first()
|
||||||
|
status_ok_threshold = prefs.status_ok_threshold_hours if prefs else 12
|
||||||
|
status_pending_threshold = prefs.status_pending_threshold_hours if prefs else 24
|
||||||
|
|
||||||
|
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||||
|
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||||
|
ignored = {i.id for i in db.query(IgnoredUnit).all()}
|
||||||
|
|
||||||
|
units = {}
|
||||||
|
|
||||||
|
# --- Merge roster entries first ---
|
||||||
|
for unit_id, r in roster.items():
|
||||||
|
e = emitters.get(unit_id)
|
||||||
|
if r.retired:
|
||||||
|
# Retired units get separated later
|
||||||
|
status = "Retired"
|
||||||
|
age = "N/A"
|
||||||
|
last_seen = None
|
||||||
|
fname = ""
|
||||||
|
else:
|
||||||
|
if e:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status based on current time, not stored value
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
age = format_age(last_seen)
|
||||||
|
fname = e.last_file
|
||||||
|
else:
|
||||||
|
# Rostered but no emitter data
|
||||||
|
status = "Missing"
|
||||||
|
last_seen = None
|
||||||
|
age = "N/A"
|
||||||
|
fname = ""
|
||||||
|
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": age,
|
||||||
|
"last": last_seen.isoformat() if last_seen else None,
|
||||||
|
"fname": fname,
|
||||||
|
"deployed": r.deployed,
|
||||||
|
"note": r.note or "",
|
||||||
|
"retired": r.retired,
|
||||||
|
# Device type and type-specific fields
|
||||||
|
"device_type": r.device_type or "seismograph",
|
||||||
|
"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,
|
||||||
|
"deployed_with_modem_id": r.deployed_with_modem_id,
|
||||||
|
"ip_address": r.ip_address,
|
||||||
|
"phone_number": r.phone_number,
|
||||||
|
"hardware_model": r.hardware_model,
|
||||||
|
# Location for mapping
|
||||||
|
"location": r.location or "",
|
||||||
|
"address": r.address or "",
|
||||||
|
"coordinates": r.coordinates or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- Add unexpected emitter-only units ---
|
||||||
|
for unit_id, e in emitters.items():
|
||||||
|
if unit_id not in roster:
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
# RECALCULATE status for unknown units too
|
||||||
|
status = calculate_status(last_seen, status_ok_threshold, status_pending_threshold)
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": status,
|
||||||
|
"age": format_age(last_seen),
|
||||||
|
"last": last_seen.isoformat(),
|
||||||
|
"fname": e.last_file,
|
||||||
|
"deployed": False, # default
|
||||||
|
"note": "",
|
||||||
|
"retired": False,
|
||||||
|
# Device type and type-specific fields (defaults for unknown units)
|
||||||
|
"device_type": "seismograph", # default
|
||||||
|
"last_calibrated": None,
|
||||||
|
"next_calibration_due": None,
|
||||||
|
"deployed_with_modem_id": None,
|
||||||
|
"ip_address": None,
|
||||||
|
"phone_number": None,
|
||||||
|
"hardware_model": None,
|
||||||
|
# Location fields
|
||||||
|
"location": "",
|
||||||
|
"address": "",
|
||||||
|
"coordinates": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Separate buckets for UI
|
||||||
|
active_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
benched_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if not u["retired"] and not u["deployed"] and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
retired_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if u["retired"]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Unknown units - emitters that aren't in the roster and aren't ignored
|
||||||
|
unknown_units = {
|
||||||
|
uid: u for uid, u in units.items()
|
||||||
|
if uid not in roster and uid not in ignored
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timestamp": datetime.utcnow().isoformat(),
|
||||||
|
"units": units,
|
||||||
|
"active": active_units,
|
||||||
|
"benched": benched_units,
|
||||||
|
"retired": retired_units,
|
||||||
|
"unknown": unknown_units,
|
||||||
|
"summary": {
|
||||||
|
"total": len(active_units) + len(benched_units),
|
||||||
|
"active": len(active_units),
|
||||||
|
"benched": len(benched_units),
|
||||||
|
"retired": len(retired_units),
|
||||||
|
"unknown": len(unknown_units),
|
||||||
|
# Status counts only for deployed units (active_units)
|
||||||
|
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),
|
||||||
|
"pending": sum(1 for u in active_units.values() if u["status"] == "Pending"),
|
||||||
|
"missing": sum(1 for u in active_units.values() if u["status"] == "Missing"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
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"}
|
||||||
|
)
|
||||||
78
app/ui/static/icons/ICON_GENERATION_INSTRUCTIONS.md
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# PWA Icon Generation Instructions
|
||||||
|
|
||||||
|
The PWA manifest requires 8 icon sizes for full compatibility across devices.
|
||||||
|
|
||||||
|
## Required Icon Sizes
|
||||||
|
|
||||||
|
- 72x72px
|
||||||
|
- 96x96px
|
||||||
|
- 128x128px
|
||||||
|
- 144x144px
|
||||||
|
- 152x152px
|
||||||
|
- 192x192px
|
||||||
|
- 384x384px
|
||||||
|
- 512x512px (maskable)
|
||||||
|
|
||||||
|
## Design Guidelines
|
||||||
|
|
||||||
|
**Background:** Navy blue (#142a66)
|
||||||
|
**Icon/Logo:** Orange (#f48b1c)
|
||||||
|
**Style:** Simple, recognizable design that works at small sizes
|
||||||
|
|
||||||
|
## Quick Generation Methods
|
||||||
|
|
||||||
|
### Option 1: Online PWA Icon Generator
|
||||||
|
|
||||||
|
1. Visit: https://www.pwabuilder.com/imageGenerator
|
||||||
|
2. Upload a 512x512px source image
|
||||||
|
3. Download the generated icon pack
|
||||||
|
4. Copy PNG files to this directory
|
||||||
|
|
||||||
|
### Option 2: ImageMagick (Command Line)
|
||||||
|
|
||||||
|
If you have a 512x512px source image called `source-icon.png`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# From the icons directory
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert source-icon.png -resize ${size}x${size} icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
### Option 3: Photoshop/GIMP
|
||||||
|
|
||||||
|
1. Create a 512x512px canvas
|
||||||
|
2. Add your design (navy background + orange icon)
|
||||||
|
3. Save/Export for each required size
|
||||||
|
4. Name files as: icon-72.png, icon-96.png, etc.
|
||||||
|
|
||||||
|
## Temporary Placeholder
|
||||||
|
|
||||||
|
For testing, you can use a simple colored square:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Generate simple colored placeholder icons
|
||||||
|
for size in 72 96 128 144 152 192 384 512; do
|
||||||
|
convert -size ${size}x${size} xc:#142a66 \
|
||||||
|
-gravity center \
|
||||||
|
-fill '#f48b1c' \
|
||||||
|
-pointsize $((size / 2)) \
|
||||||
|
-annotate +0+0 'SFM' \
|
||||||
|
icon-${size}.png
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After generating icons, verify:
|
||||||
|
- All 8 sizes exist in this directory
|
||||||
|
- Files are named exactly: icon-72.png, icon-96.png, etc.
|
||||||
|
- Images have transparent or navy background
|
||||||
|
- Logo/text is clearly visible at smallest size (72px)
|
||||||
|
|
||||||
|
## Testing PWA Installation
|
||||||
|
|
||||||
|
1. Open SFM in Chrome on Android or Safari on iOS
|
||||||
|
2. Look for "Install App" or "Add to Home Screen" prompt
|
||||||
|
3. Check that the correct icon appears in the install dialog
|
||||||
|
4. After installation, verify icon on home screen
|
||||||
BIN
app/ui/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
4
app/ui/static/icons/icon-128.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="128" height="128" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="128" height="128" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="128" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-144.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="144" height="144" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="144" height="144" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="14" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
app/ui/static/icons/icon-152.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="152" height="152" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="152" height="152" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="152" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
4
app/ui/static/icons/icon-192.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="192" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
app/ui/static/icons/icon-384.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="384" height="384" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="384" height="384" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="38" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 287 B |
BIN
app/ui/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
app/ui/static/icons/icon-512.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="512" height="512" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="512" height="512" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="512" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 288 B |
BIN
app/ui/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
4
app/ui/static/icons/icon-72.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="72" height="72" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="72" height="72" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="72" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
BIN
app/ui/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
4
app/ui/static/icons/icon-96.png.svg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="96" height="96" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="96" height="96" fill="#142a66"/>
|
||||||
|
<text x="50%" y="50%" font-family="Arial, sans-serif" font-size="96" font-weight="bold" fill="#f48b1c" text-anchor="middle" dominant-baseline="middle">SFM</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 283 B |
78
app/ui/static/manifest.json
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{
|
||||||
|
"name": "Seismo Fleet Manager",
|
||||||
|
"short_name": "SFM",
|
||||||
|
"description": "Real-time seismograph and modem fleet monitoring and management",
|
||||||
|
"start_url": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#142a66",
|
||||||
|
"theme_color": "#f48b1c",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-72.png",
|
||||||
|
"sizes": "72x72",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-96.png",
|
||||||
|
"sizes": "96x96",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-128.png",
|
||||||
|
"sizes": "128x128",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-144.png",
|
||||||
|
"sizes": "144x144",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-152.png",
|
||||||
|
"sizes": "152x152",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-384.png",
|
||||||
|
"sizes": "384x384",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/static/icons/icon-512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png",
|
||||||
|
"purpose": "any maskable"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/static/screenshots/dashboard.png",
|
||||||
|
"type": "image/png",
|
||||||
|
"sizes": "540x720",
|
||||||
|
"form_factor": "narrow"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"categories": ["utilities", "productivity"],
|
||||||
|
"shortcuts": [
|
||||||
|
{
|
||||||
|
"name": "Dashboard",
|
||||||
|
"short_name": "Dashboard",
|
||||||
|
"description": "View fleet status dashboard",
|
||||||
|
"url": "/",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Fleet Roster",
|
||||||
|
"short_name": "Roster",
|
||||||
|
"description": "View and manage fleet roster",
|
||||||
|
"url": "/roster",
|
||||||
|
"icons": [{ "src": "/static/icons/icon-192.png", "sizes": "192x192" }]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
612
app/ui/static/mobile.css
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
/* Mobile-specific styles for Seismo Fleet Manager */
|
||||||
|
/* Touch-optimized, portrait-first design */
|
||||||
|
|
||||||
|
/* ===== MOBILE TOUCH TARGETS ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Buttons - 44x44px minimum (iOS standard) */
|
||||||
|
.btn, button:not(.tab-button), .button, a.button {
|
||||||
|
min-width: 44px;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Icon-only buttons */
|
||||||
|
.icon-button, .btn-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form inputs - 48px height, 16px font prevents iOS zoom */
|
||||||
|
input:not([type="checkbox"]):not([type="radio"]),
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
min-height: 48px;
|
||||||
|
font-size: 16px !important;
|
||||||
|
padding: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Checkboxes and radio buttons - larger touch targets */
|
||||||
|
input[type="checkbox"],
|
||||||
|
input[type="radio"] {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom nav buttons - 56px industry standard */
|
||||||
|
.bottom-nav button {
|
||||||
|
min-height: 56px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Increase spacing between clickable elements */
|
||||||
|
.btn + .btn,
|
||||||
|
button + button {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== HAMBURGER MENU ===== */
|
||||||
|
.hamburger-btn {
|
||||||
|
position: fixed;
|
||||||
|
top: 1rem;
|
||||||
|
left: 1rem;
|
||||||
|
z-index: 50;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: white;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-btn {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger icon */
|
||||||
|
.hamburger-icon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hamburger-line {
|
||||||
|
width: 100%;
|
||||||
|
height: 2px;
|
||||||
|
background-color: #374151;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .hamburger-line {
|
||||||
|
background-color: #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hamburger animation when menu open */
|
||||||
|
.menu-open .hamburger-line:nth-child(1) {
|
||||||
|
transform: translateY(8px) rotate(45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(2) {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-open .hamburger-line:nth-child(3) {
|
||||||
|
transform: translateY(-8px) rotate(-45deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SIDEBAR (RESPONSIVE) ===== */
|
||||||
|
.sidebar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 16rem; /* 256px */
|
||||||
|
height: 100vh;
|
||||||
|
z-index: 40;
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
transform: translateX(0) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BACKDROP ===== */
|
||||||
|
.backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: 30;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.backdrop.show {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.backdrop {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== BOTTOM NAVIGATION ===== */
|
||||||
|
.bottom-nav {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 4rem;
|
||||||
|
background-color: white;
|
||||||
|
border-top: 1px solid #e5e7eb;
|
||||||
|
z-index: 20;
|
||||||
|
box-shadow: 0 -1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav {
|
||||||
|
background-color: #1e293b;
|
||||||
|
border-top-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: #6b7280;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bottom-nav-btn:active {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn.active {
|
||||||
|
color: #f48b1c; /* seismo-orange */
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn svg {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-nav-btn span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.bottom-nav {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAIN CONTENT ADJUSTMENTS ===== */
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0;
|
||||||
|
padding-bottom: 5rem; /* 80px for bottom nav */
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
margin-left: 16rem; /* 256px sidebar width */
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE ROSTER CARDS ===== */
|
||||||
|
.unit-card {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1);
|
||||||
|
padding: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s, box-shadow 0.2s;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-card {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card:hover {
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== UNIT DETAIL MODAL (BOTTOM SHEET) ===== */
|
||||||
|
.unit-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show {
|
||||||
|
pointer-events: auto;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
max-height: 85vh;
|
||||||
|
background-color: white;
|
||||||
|
border-top-left-radius: 1rem;
|
||||||
|
border-top-right-radius: 1rem;
|
||||||
|
box-shadow: 0 -4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
overflow-y: auto;
|
||||||
|
transform: translateY(100%);
|
||||||
|
transition: transform 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .unit-modal-content {
|
||||||
|
background-color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.unit-modal {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal-content {
|
||||||
|
max-width: 42rem; /* 672px */
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
transform: translateY(20px);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-modal.show .unit-modal-content {
|
||||||
|
transform: translateY(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Modal handle bar (mobile only) */
|
||||||
|
.modal-handle {
|
||||||
|
height: 4px;
|
||||||
|
width: 3rem;
|
||||||
|
background-color: #d1d5db;
|
||||||
|
border-radius: 9999px;
|
||||||
|
margin: 0.75rem auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.modal-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== OFFLINE INDICATOR ===== */
|
||||||
|
.offline-indicator {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background-color: #eab308; /* yellow-500 */
|
||||||
|
color: white;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
z-index: 50;
|
||||||
|
transform: translateY(-100%);
|
||||||
|
transition: transform 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.offline-indicator.show {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SYNC TOAST ===== */
|
||||||
|
.sync-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 6rem; /* Above bottom nav */
|
||||||
|
left: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background-color: #22c55e; /* green-500 */
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
transition: opacity 0.3s, transform 0.3s;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sync-toast.show {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.sync-toast {
|
||||||
|
bottom: 1rem;
|
||||||
|
left: auto;
|
||||||
|
right: 1rem;
|
||||||
|
max-width: 20rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE SEARCH BAR (STICKY) ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background-color: #f3f4f6;
|
||||||
|
margin: -1rem -1rem 1rem -1rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .mobile-search-sticky {
|
||||||
|
background-color: #111827;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.mobile-search-sticky {
|
||||||
|
position: static;
|
||||||
|
background-color: transparent;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== STATUS BADGES ===== */
|
||||||
|
.status-dot {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== DEVICE TYPE BADGES ===== */
|
||||||
|
.device-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE MAP HEIGHT ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
#fleet-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
|
||||||
|
#unit-map {
|
||||||
|
height: 16rem !important; /* 256px on mobile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MAP OVERLAP FIX ===== */
|
||||||
|
/* Prevent map and controls from overlapping UI elements on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
/* Constrain leaflet container to prevent overflow */
|
||||||
|
.leaflet-container {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override Leaflet's default high z-index values */
|
||||||
|
/* Bottom nav is z-20, sidebar is z-40, so map must be below */
|
||||||
|
.leaflet-pane,
|
||||||
|
.leaflet-tile-pane,
|
||||||
|
.leaflet-overlay-pane,
|
||||||
|
.leaflet-shadow-pane,
|
||||||
|
.leaflet-marker-pane,
|
||||||
|
.leaflet-tooltip-pane,
|
||||||
|
.leaflet-popup-pane {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Map controls should also be below navigation elements */
|
||||||
|
.leaflet-control-container,
|
||||||
|
.leaflet-top,
|
||||||
|
.leaflet-bottom,
|
||||||
|
.leaflet-left,
|
||||||
|
.leaflet-right {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-control {
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When sidebar is open, hide all Leaflet controls (zoom, attribution, etc) */
|
||||||
|
body.menu-open .leaflet-control-container {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure map tiles are non-interactive when sidebar is open */
|
||||||
|
body.menu-open #fleet-map,
|
||||||
|
body.menu-open #unit-map {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== PENDING SYNC BADGE ===== */
|
||||||
|
.pending-sync-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
background-color: #fef3c7; /* amber-100 */
|
||||||
|
color: #92400e; /* amber-800 */
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .pending-sync-badge {
|
||||||
|
background-color: #78350f;
|
||||||
|
color: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pending-sync-badge::before {
|
||||||
|
content: "⏳";
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== MOBILE-SPECIFIC UTILITY CLASSES ===== */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.mobile-text-lg {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-text-xl {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-p-4 {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-mb-4 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== ACCESSIBILITY ===== */
|
||||||
|
/* Improve focus visibility on mobile */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
button:focus-visible,
|
||||||
|
a:focus-visible,
|
||||||
|
input:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
textarea:focus-visible {
|
||||||
|
outline: 2px solid #f48b1c;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent text selection on buttons (better mobile UX) */
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.button {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SMOOTH SCROLLING ===== */
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Prevent overscroll bounce on iOS */
|
||||||
|
body {
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== LOADING STATES ===== */
|
||||||
|
.loading-pulse {
|
||||||
|
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
0%, 100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== SAFE AREA SUPPORT (iOS notch) ===== */
|
||||||
|
@supports (padding: env(safe-area-inset-bottom)) {
|
||||||
|
.bottom-nav {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
height: calc(4rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: calc(5rem + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.main-content {
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
597
app/ui/static/mobile.js
Normal file
@@ -0,0 +1,597 @@
|
|||||||
|
/* Mobile JavaScript for Seismo Fleet Manager */
|
||||||
|
/* Handles hamburger menu, modals, offline sync, and mobile interactions */
|
||||||
|
|
||||||
|
// ===== GLOBAL STATE =====
|
||||||
|
let currentUnitData = null;
|
||||||
|
let isOnline = navigator.onLine;
|
||||||
|
|
||||||
|
// ===== HAMBURGER MENU TOGGLE =====
|
||||||
|
function toggleMenu() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
const isOpen = sidebar.classList.contains('open');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
// Close menu
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
} else {
|
||||||
|
// Open menu
|
||||||
|
sidebar.classList.add('open');
|
||||||
|
backdrop.classList.add('show');
|
||||||
|
hamburgerBtn?.classList.add('menu-open');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.body.classList.add('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when clicking backdrop
|
||||||
|
function closeMenuFromBackdrop() {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close menu when window is resized to desktop
|
||||||
|
function handleResize() {
|
||||||
|
if (window.innerWidth >= 768) {
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
const backdrop = document.getElementById('backdrop');
|
||||||
|
const hamburgerBtn = document.getElementById('hamburgerBtn');
|
||||||
|
|
||||||
|
if (sidebar && backdrop) {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
backdrop.classList.remove('show');
|
||||||
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UNIT DETAIL MODAL =====
|
||||||
|
function openUnitModal(unitId, status = null, age = null) {
|
||||||
|
const modal = document.getElementById('unitModal');
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Store the status info passed from the card
|
||||||
|
// Accept status if it's a non-empty string, use age if provided or default to '--'
|
||||||
|
const cardStatusInfo = (status && status !== '') ? {
|
||||||
|
status: status,
|
||||||
|
age: age || '--'
|
||||||
|
} : null;
|
||||||
|
|
||||||
|
console.log('openUnitModal:', { unitId, status, age, cardStatusInfo });
|
||||||
|
|
||||||
|
// Fetch unit data and populate modal
|
||||||
|
fetchUnitDetails(unitId).then(unit => {
|
||||||
|
if (unit) {
|
||||||
|
currentUnitData = unit;
|
||||||
|
// Pass the card status info to the populate function
|
||||||
|
populateUnitModal(unit, cardStatusInfo);
|
||||||
|
modal.classList.add('show');
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeUnitModal(event) {
|
||||||
|
// Only close if clicking backdrop or close button
|
||||||
|
if (event && event.target.closest('.unit-modal-content') && !event.target.closest('[data-close-modal]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modal = document.getElementById('unitModal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('show');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
currentUnitData = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUnitDetails(unitId) {
|
||||||
|
try {
|
||||||
|
// Try to fetch from network first
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`);
|
||||||
|
if (response.ok) {
|
||||||
|
const unit = await response.json();
|
||||||
|
|
||||||
|
// Save to IndexedDB if offline support is available
|
||||||
|
if (window.offlineDB) {
|
||||||
|
await window.offlineDB.saveUnit(unit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return unit;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('Network fetch failed, trying offline storage:', error);
|
||||||
|
|
||||||
|
// Fall back to offline storage
|
||||||
|
if (window.offlineDB) {
|
||||||
|
return await window.offlineDB.getUnit(unitId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateUnitModal(unit, cardStatusInfo = null) {
|
||||||
|
// Set unit ID in header
|
||||||
|
const modalUnitId = document.getElementById('modalUnitId');
|
||||||
|
if (modalUnitId) {
|
||||||
|
modalUnitId.textContent = unit.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate modal content
|
||||||
|
const modalContent = document.getElementById('modalContent');
|
||||||
|
if (!modalContent) return;
|
||||||
|
|
||||||
|
// Use status from card if provided, otherwise get from snapshot or derive from unit
|
||||||
|
let statusInfo = cardStatusInfo || getUnitStatus(unit.id, unit);
|
||||||
|
console.log('populateUnitModal:', { unit, cardStatusInfo, statusInfo });
|
||||||
|
|
||||||
|
const statusColor = statusInfo.status === 'OK' ? 'green' :
|
||||||
|
statusInfo.status === 'Pending' ? 'yellow' :
|
||||||
|
statusInfo.status === 'Missing' ? 'red' : 'gray';
|
||||||
|
|
||||||
|
const statusTextColor = statusInfo.status === 'OK' ? 'text-green-600 dark:text-green-400' :
|
||||||
|
statusInfo.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' :
|
||||||
|
statusInfo.status === 'Missing' ? 'text-red-600 dark:text-red-400' :
|
||||||
|
'text-gray-600 dark:text-gray-400';
|
||||||
|
|
||||||
|
// Determine status label (show "Benched" instead of "Unknown" for non-deployed units)
|
||||||
|
let statusLabel = statusInfo.status;
|
||||||
|
if ((statusInfo.status === 'Unknown' || statusInfo.status === 'N/A') && !unit.deployed) {
|
||||||
|
statusLabel = 'Benched';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create navigation URL for location
|
||||||
|
const createNavUrl = (address, coordinates) => {
|
||||||
|
if (address) {
|
||||||
|
// Use address for navigation
|
||||||
|
const encodedAddress = encodeURIComponent(address);
|
||||||
|
// Universal link that works on iOS and Android
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodedAddress}`;
|
||||||
|
} else if (coordinates) {
|
||||||
|
// Use coordinates for navigation (format: lat,lon)
|
||||||
|
const encodedCoords = encodeURIComponent(coordinates);
|
||||||
|
return `https://www.google.com/maps/search/?api=1&query=${encodedCoords}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navUrl = createNavUrl(unit.address, unit.coordinates);
|
||||||
|
|
||||||
|
modalContent.innerHTML = `
|
||||||
|
<!-- Status Section -->
|
||||||
|
<div class="flex items-center justify-between pb-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-4 h-4 rounded-full bg-${statusColor}-500"></span>
|
||||||
|
<span class="font-semibold ${statusTextColor}">${statusLabel}</span>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-500">${statusInfo.age || '--'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.device_type || '--'}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${unit.unit_type ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.unit_type}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.project_id ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.project_id}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
|
${navUrl ? `
|
||||||
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="underline">${unit.address}</span>
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.address}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.coordinates && !unit.address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
||||||
|
${navUrl ? `
|
||||||
|
<a href="${navUrl}" target="_blank" class="mt-1 flex items-center gap-2 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
|
||||||
|
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="font-mono text-sm underline">${unit.coordinates}</span>
|
||||||
|
</a>
|
||||||
|
` : `
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white font-mono text-sm">${unit.coordinates}</p>
|
||||||
|
`}
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Seismograph-specific fields -->
|
||||||
|
${unit.device_type === 'seismograph' ? `
|
||||||
|
${unit.last_calibrated ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.last_calibrated}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.next_calibration_due ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.next_calibration_due}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.deployed_with_modem_id ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed_with_modem_id}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<!-- Modem-specific fields -->
|
||||||
|
${unit.device_type === 'modem' ? `
|
||||||
|
${unit.ip_address ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.ip_address}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.phone_number ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.phone_number}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.hardware_model ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.hardware_model}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${unit.note ? `
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">${unit.note}</p>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Deployed</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.deployed ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-xs font-medium text-gray-500 dark:text-gray-400">Retired</label>
|
||||||
|
<p class="mt-1 text-gray-900 dark:text-white">${unit.retired ? 'Yes' : 'No'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Update action buttons
|
||||||
|
const editBtn = document.getElementById('modalEditBtn');
|
||||||
|
const deployBtn = document.getElementById('modalDeployBtn');
|
||||||
|
const deleteBtn = document.getElementById('modalDeleteBtn');
|
||||||
|
|
||||||
|
if (editBtn) {
|
||||||
|
editBtn.onclick = () => {
|
||||||
|
window.location.href = `/unit/${unit.id}`;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deployBtn) {
|
||||||
|
deployBtn.textContent = unit.deployed ? 'Bench Unit' : 'Deploy Unit';
|
||||||
|
deployBtn.onclick = () => toggleDeployStatus(unit.id, !unit.deployed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteBtn) {
|
||||||
|
deleteBtn.onclick = () => deleteUnit(unit.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUnitStatus(unitId, unit = null) {
|
||||||
|
// Prefer roster table data if it was rendered with the current view
|
||||||
|
if (window.rosterStatusMap && window.rosterStatusMap[unitId]) {
|
||||||
|
return window.rosterStatusMap[unitId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to get status from dashboard snapshot if it exists
|
||||||
|
if (window.lastStatusSnapshot && window.lastStatusSnapshot.units && window.lastStatusSnapshot.units[unitId]) {
|
||||||
|
const unitStatus = window.lastStatusSnapshot.units[unitId];
|
||||||
|
return {
|
||||||
|
status: unitStatus.status,
|
||||||
|
age: unitStatus.age,
|
||||||
|
last: unitStatus.last
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if unit data is provided, derive status from deployment state
|
||||||
|
if (unit) {
|
||||||
|
if (unit.deployed) {
|
||||||
|
// For deployed units without status data, default to "Unknown"
|
||||||
|
return { status: 'Unknown', age: '--', last: '--' };
|
||||||
|
} else {
|
||||||
|
// For benched units, use "N/A" which will be displayed as "Benched"
|
||||||
|
return { status: 'N/A', age: '--', last: '--' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'Unknown', age: '--', last: '--' };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleDeployStatus(unitId, deployed) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deployed', deployed ? 'true' : 'false');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✓ Unit updated successfully');
|
||||||
|
closeUnitModal();
|
||||||
|
|
||||||
|
// Trigger HTMX refresh if on roster page
|
||||||
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||||
|
if (rosterTable) {
|
||||||
|
htmx.trigger(rosterTable, 'refresh');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('❌ Failed to update unit', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error toggling deploy status:', error);
|
||||||
|
showToast('❌ Failed to update unit', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteUnit(unitId) {
|
||||||
|
if (!confirm(`Are you sure you want to delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
showToast('✓ Unit deleted successfully');
|
||||||
|
closeUnitModal();
|
||||||
|
|
||||||
|
// Refresh roster page if present
|
||||||
|
const rosterTable = document.querySelector('[hx-get*="roster"]');
|
||||||
|
if (rosterTable) {
|
||||||
|
htmx.trigger(rosterTable, 'refresh');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('❌ Failed to delete unit', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting unit:', error);
|
||||||
|
showToast('❌ Failed to delete unit', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== ONLINE/OFFLINE STATUS =====
|
||||||
|
function updateOnlineStatus() {
|
||||||
|
isOnline = navigator.onLine;
|
||||||
|
const offlineIndicator = document.getElementById('offlineIndicator');
|
||||||
|
|
||||||
|
if (offlineIndicator) {
|
||||||
|
if (isOnline) {
|
||||||
|
offlineIndicator.classList.remove('show');
|
||||||
|
// Trigger sync when coming back online
|
||||||
|
if (window.offlineDB) {
|
||||||
|
syncPendingEdits();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
offlineIndicator.classList.add('show');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('online', updateOnlineStatus);
|
||||||
|
window.addEventListener('offline', updateOnlineStatus);
|
||||||
|
|
||||||
|
// ===== SYNC FUNCTIONALITY =====
|
||||||
|
async function syncPendingEdits() {
|
||||||
|
if (!window.offlineDB) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pendingEdits = await window.offlineDB.getPendingEdits();
|
||||||
|
|
||||||
|
if (pendingEdits.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`Syncing ${pendingEdits.length} pending edits...`);
|
||||||
|
|
||||||
|
for (const edit of pendingEdits) {
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
for (const [key, value] of Object.entries(edit.changes)) {
|
||||||
|
formData.append(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/edit/${edit.unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
await window.offlineDB.clearEdit(edit.id);
|
||||||
|
console.log(`Synced edit ${edit.id} for unit ${edit.unitId}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Failed to sync edit ${edit.id}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error syncing edit ${edit.id}:`, error);
|
||||||
|
// Keep in queue for next sync attempt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show success toast
|
||||||
|
showToast('✓ Synced successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error in syncPendingEdits:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manual sync button
|
||||||
|
function manualSync() {
|
||||||
|
if (!isOnline) {
|
||||||
|
showToast('⚠️ Cannot sync while offline', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
syncPendingEdits();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== TOAST NOTIFICATIONS =====
|
||||||
|
function showToast(message, type = 'success') {
|
||||||
|
const toast = document.getElementById('syncToast');
|
||||||
|
if (!toast) return;
|
||||||
|
|
||||||
|
// Update toast appearance based on type
|
||||||
|
toast.classList.remove('bg-green-500', 'bg-red-500', 'bg-yellow-500');
|
||||||
|
|
||||||
|
if (type === 'success') {
|
||||||
|
toast.classList.add('bg-green-500');
|
||||||
|
} else if (type === 'error') {
|
||||||
|
toast.classList.add('bg-red-500');
|
||||||
|
} else if (type === 'warning') {
|
||||||
|
toast.classList.add('bg-yellow-500');
|
||||||
|
}
|
||||||
|
|
||||||
|
toast.textContent = message;
|
||||||
|
toast.classList.add('show');
|
||||||
|
|
||||||
|
// Auto-hide after 3 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('show');
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== BOTTOM NAV ACTIVE STATE =====
|
||||||
|
function updateBottomNavActiveState() {
|
||||||
|
const currentPath = window.location.pathname;
|
||||||
|
const navButtons = document.querySelectorAll('.bottom-nav-btn');
|
||||||
|
|
||||||
|
navButtons.forEach(btn => {
|
||||||
|
const href = btn.getAttribute('data-href');
|
||||||
|
if (href && (currentPath === href || (href !== '/' && currentPath.startsWith(href)))) {
|
||||||
|
btn.classList.add('active');
|
||||||
|
} else {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== INITIALIZATION =====
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Initialize online/offline status
|
||||||
|
updateOnlineStatus();
|
||||||
|
|
||||||
|
// Update bottom nav active state
|
||||||
|
updateBottomNavActiveState();
|
||||||
|
|
||||||
|
// Add resize listener
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
// Close menu on navigation (for mobile)
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const link = e.target.closest('a');
|
||||||
|
if (link && link.closest('#sidebar')) {
|
||||||
|
// Delay to allow navigation to start
|
||||||
|
setTimeout(() => {
|
||||||
|
if (window.innerWidth < 768) {
|
||||||
|
closeMenuFromBackdrop();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prevent scroll when modals are open (iOS fix)
|
||||||
|
document.addEventListener('touchmove', (e) => {
|
||||||
|
const modal = document.querySelector('.unit-modal.show, #sidebar.open');
|
||||||
|
if (modal && !e.target.closest('.unit-modal-content, #sidebar')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
|
console.log('Mobile.js initialized');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== SERVICE WORKER REGISTRATION =====
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('Service Worker registered:', registration);
|
||||||
|
|
||||||
|
// Check for updates periodically
|
||||||
|
setInterval(() => {
|
||||||
|
registration.update();
|
||||||
|
}, 60 * 60 * 1000); // Check every hour
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Service Worker registration failed:', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Listen for service worker updates
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('Service Worker updated, reloading page...');
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export functions for global use
|
||||||
|
window.toggleMenu = toggleMenu;
|
||||||
|
window.closeMenuFromBackdrop = closeMenuFromBackdrop;
|
||||||
|
window.openUnitModal = openUnitModal;
|
||||||
|
window.closeUnitModal = closeUnitModal;
|
||||||
|
window.manualSync = manualSync;
|
||||||
|
window.showToast = showToast;
|
||||||
349
app/ui/static/offline-db.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
/* IndexedDB wrapper for offline data storage in SFM */
|
||||||
|
/* Handles unit data, status snapshots, and pending edit queue */
|
||||||
|
|
||||||
|
class OfflineDB {
|
||||||
|
constructor() {
|
||||||
|
this.dbName = 'sfm-offline-db';
|
||||||
|
this.version = 1;
|
||||||
|
this.db = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
async init() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(this.dbName, this.version);
|
||||||
|
|
||||||
|
request.onerror = () => {
|
||||||
|
console.error('IndexedDB error:', request.error);
|
||||||
|
reject(request.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
this.db = request.result;
|
||||||
|
console.log('IndexedDB initialized successfully');
|
||||||
|
resolve(this.db);
|
||||||
|
};
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
// Units store - full unit details
|
||||||
|
if (!db.objectStoreNames.contains('units')) {
|
||||||
|
const unitsStore = db.createObjectStore('units', { keyPath: 'id' });
|
||||||
|
unitsStore.createIndex('device_type', 'device_type', { unique: false });
|
||||||
|
unitsStore.createIndex('deployed', 'deployed', { unique: false });
|
||||||
|
console.log('Created units object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status snapshot store - latest status data
|
||||||
|
if (!db.objectStoreNames.contains('status-snapshot')) {
|
||||||
|
db.createObjectStore('status-snapshot', { keyPath: 'timestamp' });
|
||||||
|
console.log('Created status-snapshot object store');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pending edits store - offline edit queue
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
const editsStore = db.createObjectStore('pending-edits', {
|
||||||
|
keyPath: 'id',
|
||||||
|
autoIncrement: true
|
||||||
|
});
|
||||||
|
editsStore.createIndex('unitId', 'unitId', { unique: false });
|
||||||
|
editsStore.createIndex('timestamp', 'timestamp', { unique: false });
|
||||||
|
console.log('Created pending-edits object store');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UNITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Save or update a unit
|
||||||
|
async saveUnit(unit) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.put(unit);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a single unit by ID
|
||||||
|
async getUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.get(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all units
|
||||||
|
async getAllUnits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a unit
|
||||||
|
async deleteUnit(unitId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('units');
|
||||||
|
const request = store.delete(unitId);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve();
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== STATUS SNAPSHOT OPERATIONS =====
|
||||||
|
|
||||||
|
// Save status snapshot
|
||||||
|
async saveSnapshot(snapshot) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
|
||||||
|
// Add timestamp
|
||||||
|
const snapshotWithTimestamp = {
|
||||||
|
...snapshot,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.put(snapshotWithTimestamp);
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get latest status snapshot
|
||||||
|
async getLatestSnapshot() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
const snapshots = request.result;
|
||||||
|
if (snapshots.length > 0) {
|
||||||
|
// Return the most recent snapshot
|
||||||
|
const latest = snapshots.reduce((prev, current) =>
|
||||||
|
(prev.timestamp > current.timestamp) ? prev : current
|
||||||
|
);
|
||||||
|
resolve(latest);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear old snapshots (keep only latest)
|
||||||
|
async clearOldSnapshots() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('status-snapshot');
|
||||||
|
const getAllRequest = store.getAll();
|
||||||
|
|
||||||
|
getAllRequest.onsuccess = () => {
|
||||||
|
const snapshots = getAllRequest.result;
|
||||||
|
|
||||||
|
if (snapshots.length > 1) {
|
||||||
|
// Sort by timestamp, keep only the latest
|
||||||
|
snapshots.sort((a, b) => b.timestamp - a.timestamp);
|
||||||
|
|
||||||
|
// Delete all except the first (latest)
|
||||||
|
for (let i = 1; i < snapshots.length; i++) {
|
||||||
|
store.delete(snapshots[i].timestamp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
getAllRequest.onerror = () => reject(getAllRequest.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== PENDING EDITS OPERATIONS =====
|
||||||
|
|
||||||
|
// Queue an edit for offline sync
|
||||||
|
async queueEdit(unitId, changes) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
const edit = {
|
||||||
|
unitId,
|
||||||
|
changes,
|
||||||
|
timestamp: Date.now()
|
||||||
|
};
|
||||||
|
|
||||||
|
const request = store.add(edit);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Queued edit for unit ${unitId}`);
|
||||||
|
resolve(request.result);
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all pending edits
|
||||||
|
async getPendingEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get pending edits count
|
||||||
|
async getPendingEditsCount() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.count();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear a synced edit
|
||||||
|
async clearEdit(editId) {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.delete(editId);
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log(`Cleared edit ${editId} from queue`);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear all pending edits
|
||||||
|
async clearAllEdits() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.clear();
|
||||||
|
|
||||||
|
request.onsuccess = () => {
|
||||||
|
console.log('Cleared all pending edits');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== UTILITY OPERATIONS =====
|
||||||
|
|
||||||
|
// Clear all data (for debugging/reset)
|
||||||
|
async clearAllData() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const storeNames = ['units', 'status-snapshot', 'pending-edits'];
|
||||||
|
const transaction = this.db.transaction(storeNames, 'readwrite');
|
||||||
|
|
||||||
|
storeNames.forEach(storeName => {
|
||||||
|
transaction.objectStore(storeName).clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => {
|
||||||
|
console.log('Cleared all offline data');
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get database statistics
|
||||||
|
async getStats() {
|
||||||
|
if (!this.db) await this.init();
|
||||||
|
|
||||||
|
const unitsCount = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['units'], 'readonly');
|
||||||
|
const request = transaction.objectStore('units').count();
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
const pendingEditsCount = await this.getPendingEditsCount();
|
||||||
|
|
||||||
|
const hasSnapshot = await new Promise((resolve, reject) => {
|
||||||
|
const transaction = this.db.transaction(['status-snapshot'], 'readonly');
|
||||||
|
const request = transaction.objectStore('status-snapshot').count();
|
||||||
|
request.onsuccess = () => resolve(request.result > 0);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
unitsCount,
|
||||||
|
pendingEditsCount,
|
||||||
|
hasSnapshot
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create global instance
|
||||||
|
window.offlineDB = new OfflineDB();
|
||||||
|
|
||||||
|
// Initialize on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
try {
|
||||||
|
await window.offlineDB.init();
|
||||||
|
console.log('Offline database ready');
|
||||||
|
|
||||||
|
// Display pending edits count if any
|
||||||
|
const pendingCount = await window.offlineDB.getPendingEditsCount();
|
||||||
|
if (pendingCount > 0) {
|
||||||
|
console.log(`${pendingCount} pending edits in queue`);
|
||||||
|
// Could show a badge in the UI here
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize offline database:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
12
app/ui/static/style.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/* Custom styles for Seismo Fleet Manager */
|
||||||
|
|
||||||
|
/* Additional custom styles can go here */
|
||||||
|
|
||||||
|
.card-hover {
|
||||||
|
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
347
app/ui/static/sw.js
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
/* Service Worker for Seismo Fleet Manager PWA */
|
||||||
|
/* Network-first strategy with cache fallback for real-time data */
|
||||||
|
|
||||||
|
const CACHE_VERSION = 'v1';
|
||||||
|
const STATIC_CACHE = `sfm-static-${CACHE_VERSION}`;
|
||||||
|
const DYNAMIC_CACHE = `sfm-dynamic-${CACHE_VERSION}`;
|
||||||
|
const DATA_CACHE = `sfm-data-${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// Files to precache (critical app shell)
|
||||||
|
const STATIC_FILES = [
|
||||||
|
'/',
|
||||||
|
'/static/style.css',
|
||||||
|
'/static/mobile.css',
|
||||||
|
'/static/mobile.js',
|
||||||
|
'/static/offline-db.js',
|
||||||
|
'/static/manifest.json',
|
||||||
|
'https://cdn.tailwindcss.com',
|
||||||
|
'https://unpkg.com/htmx.org@1.9.10',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css',
|
||||||
|
'https://unpkg.com/leaflet@1.9.4/dist/leaflet.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - cache static files
|
||||||
|
self.addEventListener('install', (event) => {
|
||||||
|
console.log('[SW] Installing service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(STATIC_CACHE)
|
||||||
|
.then((cache) => {
|
||||||
|
console.log('[SW] Precaching static files');
|
||||||
|
return cache.addAll(STATIC_FILES);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Static files cached successfully');
|
||||||
|
return self.skipWaiting(); // Activate immediately
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error('[SW] Precaching failed:', error);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - clean up old caches
|
||||||
|
self.addEventListener('activate', (event) => {
|
||||||
|
console.log('[SW] Activating service worker...');
|
||||||
|
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys()
|
||||||
|
.then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => {
|
||||||
|
// Delete old caches that don't match current version
|
||||||
|
if (cacheName !== STATIC_CACHE &&
|
||||||
|
cacheName !== DYNAMIC_CACHE &&
|
||||||
|
cacheName !== DATA_CACHE) {
|
||||||
|
console.log('[SW] Deleting old cache:', cacheName);
|
||||||
|
return caches.delete(cacheName);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[SW] Service worker activated');
|
||||||
|
return self.clients.claim(); // Take control of all pages
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch event - network-first strategy
|
||||||
|
self.addEventListener('fetch', (event) => {
|
||||||
|
const { request } = event;
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Skip non-GET requests
|
||||||
|
if (request.method !== 'GET') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip chrome-extension and other non-http(s) requests
|
||||||
|
if (!url.protocol.startsWith('http')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API requests - network first, cache fallback
|
||||||
|
if (url.pathname.startsWith('/api/')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DATA_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Static assets - cache first
|
||||||
|
if (isStaticAsset(url.pathname)) {
|
||||||
|
event.respondWith(cacheFirstStrategy(request, STATIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTML pages - network first with cache fallback
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything else - network first
|
||||||
|
event.respondWith(networkFirstStrategy(request, DYNAMIC_CACHE));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Network-first strategy
|
||||||
|
async function networkFirstStrategy(request, cacheName) {
|
||||||
|
try {
|
||||||
|
// Try network first
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
// Network failed, try cache
|
||||||
|
console.log('[SW] Network failed, trying cache:', request.url);
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
console.log('[SW] Serving from cache:', request.url);
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No cache available, return offline page or error
|
||||||
|
console.error('[SW] No cache available for:', request.url);
|
||||||
|
|
||||||
|
// For HTML requests, return a basic offline page
|
||||||
|
if (request.headers.get('accept')?.includes('text/html')) {
|
||||||
|
return new Response(
|
||||||
|
`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Offline - SFM</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: #f3f4f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 { color: #f48b1c; margin-bottom: 1rem; }
|
||||||
|
p { margin-bottom: 1.5rem; color: #6b7280; }
|
||||||
|
button {
|
||||||
|
background: #f48b1c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
button:hover { background: #d97706; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>📡 You're Offline</h1>
|
||||||
|
<p>SFM requires an internet connection for this page.</p>
|
||||||
|
<p>Please check your connection and try again.</p>
|
||||||
|
<button onclick="location.reload()">Retry</button>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'text/html' }
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other requests, return error
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache-first strategy
|
||||||
|
async function cacheFirstStrategy(request, cacheName) {
|
||||||
|
const cachedResponse = await caches.match(request);
|
||||||
|
|
||||||
|
if (cachedResponse) {
|
||||||
|
return cachedResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not in cache, fetch from network
|
||||||
|
try {
|
||||||
|
const networkResponse = await fetch(request);
|
||||||
|
|
||||||
|
// Cache successful responses
|
||||||
|
if (networkResponse && networkResponse.status === 200) {
|
||||||
|
const cache = await caches.open(cacheName);
|
||||||
|
cache.put(request, networkResponse.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
return networkResponse;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Fetch failed:', request.url, error);
|
||||||
|
return new Response('Network error', {
|
||||||
|
status: 503,
|
||||||
|
statusText: 'Service Unavailable'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if URL is a static asset
|
||||||
|
function isStaticAsset(pathname) {
|
||||||
|
const staticExtensions = ['.css', '.js', '.png', '.jpg', '.jpeg', '.svg', '.ico', '.woff', '.woff2'];
|
||||||
|
return staticExtensions.some(ext => pathname.endsWith(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background Sync - for offline edits
|
||||||
|
self.addEventListener('sync', (event) => {
|
||||||
|
console.log('[SW] Background sync event:', event.tag);
|
||||||
|
|
||||||
|
if (event.tag === 'sync-edits') {
|
||||||
|
event.waitUntil(syncPendingEdits());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync pending edits to server
|
||||||
|
async function syncPendingEdits() {
|
||||||
|
console.log('[SW] Syncing pending edits...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get pending edits from IndexedDB
|
||||||
|
const db = await openDatabase();
|
||||||
|
const edits = await getPendingEdits(db);
|
||||||
|
|
||||||
|
if (edits.length === 0) {
|
||||||
|
console.log('[SW] No pending edits to sync');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[SW] Syncing ${edits.length} pending edits`);
|
||||||
|
|
||||||
|
// Send edits to server
|
||||||
|
const response = await fetch('/api/sync-edits', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ edits })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('[SW] Sync successful:', result);
|
||||||
|
|
||||||
|
// Clear synced edits from IndexedDB
|
||||||
|
await clearSyncedEdits(db, result.synced_ids || []);
|
||||||
|
|
||||||
|
// Notify all clients about successful sync
|
||||||
|
const clients = await self.clients.matchAll();
|
||||||
|
clients.forEach(client => {
|
||||||
|
client.postMessage({
|
||||||
|
type: 'SYNC_COMPLETE',
|
||||||
|
synced: result.synced
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.error('[SW] Sync failed:', response.status);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[SW] Sync error:', error);
|
||||||
|
throw error; // Will retry sync later
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexedDB helpers (simplified versions - full implementations in offline-db.js)
|
||||||
|
function openDatabase() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open('sfm-offline-db', 1);
|
||||||
|
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
|
||||||
|
request.onupgradeneeded = (event) => {
|
||||||
|
const db = event.target.result;
|
||||||
|
|
||||||
|
if (!db.objectStoreNames.contains('pending-edits')) {
|
||||||
|
db.createObjectStore('pending-edits', { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingEdits(db) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readonly');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
const request = store.getAll();
|
||||||
|
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSyncedEdits(db, editIds) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const transaction = db.transaction(['pending-edits'], 'readwrite');
|
||||||
|
const store = transaction.objectStore('pending-edits');
|
||||||
|
|
||||||
|
editIds.forEach(id => {
|
||||||
|
store.delete(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
transaction.oncomplete = () => resolve();
|
||||||
|
transaction.onerror = () => reject(transaction.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message event - handle messages from clients
|
||||||
|
self.addEventListener('message', (event) => {
|
||||||
|
console.log('[SW] Message received:', event.data);
|
||||||
|
|
||||||
|
if (event.data.type === 'SKIP_WAITING') {
|
||||||
|
self.skipWaiting();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.data.type === 'CLEAR_CACHE') {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((cacheNames) => {
|
||||||
|
return Promise.all(
|
||||||
|
cacheNames.map((cacheName) => caches.delete(cacheName))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[SW] Service Worker loaded');
|
||||||
384
app/ui/templates/base.html
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- HTMX -->
|
||||||
|
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
|
||||||
|
|
||||||
|
<!-- Leaflet for maps -->
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
|
||||||
|
<!-- Mobile CSS -->
|
||||||
|
<link rel="stylesheet" href="/static/mobile.css">
|
||||||
|
|
||||||
|
<!-- PWA Manifest -->
|
||||||
|
<link rel="manifest" href="/static/manifest.json">
|
||||||
|
<meta name="theme-color" content="#f48b1c">
|
||||||
|
<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-title" content="SFM">
|
||||||
|
|
||||||
|
<!-- Custom Tailwind Config -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
darkMode: 'class',
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
seismo: {
|
||||||
|
orange: '#f48b1c',
|
||||||
|
navy: '#142a66',
|
||||||
|
burgundy: '#7d234d',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Custom scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #888;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
* {
|
||||||
|
transition: background-color 0.2s ease, color 0.2s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% block extra_head %}{% endblock %}
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
|
|
||||||
|
<!-- Offline Indicator -->
|
||||||
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
|
📡 Offline - Changes will sync when connected
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sync Toast -->
|
||||||
|
<div id="syncToast" class="sync-toast">
|
||||||
|
✓ Synced successfully
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
<!-- Sidebar (Responsive) -->
|
||||||
|
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
|
||||||
|
Seismo<br>
|
||||||
|
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||||
|
</h1>
|
||||||
|
<div class="flex items-center justify-between mt-2">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||||
|
{% if environment == 'development' %}
|
||||||
|
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation -->
|
||||||
|
<nav class="flex-1 p-4 space-y-2">
|
||||||
|
<a href="/" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/' %}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="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/seismographs" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/seismographs' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
Seismographs
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/sound-level-meters" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/sound-level-meters' %}bg-gray-100 dark:bg-gray-700{% endif %}">
|
||||||
|
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
Sound Level Meters
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="#" 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="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>
|
||||||
|
Projects
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Dark mode toggle and utilities -->
|
||||||
|
<div class="p-4 border-t border-gray-200 dark:border-gray-700 space-y-2">
|
||||||
|
<button onclick="toggleDarkMode()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||||
|
<svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg id="theme-toggle-light-icon" class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3">Toggle theme</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="hardReload()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-red-100 dark:bg-red-900/30 hover:bg-red-200 dark:hover:bg-red-900/50 text-red-700 dark:text-red-400">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-3">Clear Cache & Reload</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Backdrop (Mobile Only) -->
|
||||||
|
<div id="backdrop" class="backdrop" onclick="closeMenuFromBackdrop()"></div>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="main-content flex-1 overflow-y-auto">
|
||||||
|
<div class="p-8">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
|
<nav class="bottom-nav">
|
||||||
|
<div class="grid grid-cols-4 h-16">
|
||||||
|
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Menu</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Dashboard</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/roster" onclick="window.location.href='/roster'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Devices</span>
|
||||||
|
</button>
|
||||||
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>Settings</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Dark mode toggle
|
||||||
|
function toggleDarkMode() {
|
||||||
|
const html = document.documentElement;
|
||||||
|
if (html.classList.contains('dark')) {
|
||||||
|
html.classList.remove('dark');
|
||||||
|
localStorage.setItem('theme', 'light');
|
||||||
|
} else {
|
||||||
|
html.classList.add('dark');
|
||||||
|
localStorage.setItem('theme', 'dark');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hard reload function - clears all caches and reloads
|
||||||
|
async function hardReload() {
|
||||||
|
try {
|
||||||
|
// Clear service worker caches
|
||||||
|
if ('caches' in window) {
|
||||||
|
const cacheNames = await caches.keys();
|
||||||
|
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||||
|
console.log('Cleared all service worker caches');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unregister service workers
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
const registrations = await navigator.serviceWorker.getRegistrations();
|
||||||
|
await Promise.all(registrations.map(reg => reg.unregister()));
|
||||||
|
console.log('Unregistered all service workers');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear IndexedDB
|
||||||
|
if ('indexedDB' in window) {
|
||||||
|
try {
|
||||||
|
indexedDB.deleteDatabase('sfm-offline-db');
|
||||||
|
console.log('Cleared IndexedDB');
|
||||||
|
} catch (e) {
|
||||||
|
console.log('Could not clear IndexedDB:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force reload with cache bypass
|
||||||
|
window.location.reload(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error during hard reload:', error);
|
||||||
|
// Fallback to regular reload
|
||||||
|
window.location.reload(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load saved theme preference
|
||||||
|
if (localStorage.getItem('theme') === 'light') {
|
||||||
|
document.documentElement.classList.remove('dark');
|
||||||
|
} else if (localStorage.getItem('theme') === 'dark') {
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Convert timestamp to relative time
|
||||||
|
function timeAgo(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
const remainingMins = minutes % 60;
|
||||||
|
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 4) {
|
||||||
|
const remainingDays = days % 7;
|
||||||
|
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Get user's selected timezone
|
||||||
|
function getTimezone() {
|
||||||
|
return localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp with tooltip (timezone-aware)
|
||||||
|
function formatTimestamp(dateString) {
|
||||||
|
if (!dateString) return '<span class="text-gray-400">Never</span>';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
const fullDate = date.toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp as full date/time (no relative time)
|
||||||
|
// Format: "9/10/2020 8:00 AM EST"
|
||||||
|
function formatFullTimestamp(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all timestamps on page load and periodically
|
||||||
|
function updateAllTimestamps() {
|
||||||
|
document.querySelectorAll('[data-timestamp]').forEach(el => {
|
||||||
|
const timestamp = el.getAttribute('data-timestamp');
|
||||||
|
el.innerHTML = formatTimestamp(timestamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on load and every minute
|
||||||
|
updateAllTimestamps();
|
||||||
|
setInterval(updateAllTimestamps, 60000);
|
||||||
|
|
||||||
|
// Copy to clipboard helper
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Visual feedback
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
button.classList.add('text-green-500');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('text-green-500');
|
||||||
|
}, 1500);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Offline Database -->
|
||||||
|
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||||
|
|
||||||
|
<!-- Mobile JavaScript -->
|
||||||
|
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||||
|
|
||||||
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
656
app/ui/templates/dashboard.html
Normal file
@@ -0,0 +1,656 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% if environment == 'development' %}
|
||||||
|
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
||||||
|
<p class="font-bold">Development Environment</p>
|
||||||
|
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mb-8 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
|
||||||
|
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
|
<div hx-get="/api/status-snapshot"
|
||||||
|
hx-trigger="load, every 10s"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="updateDashboard(event)">
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
|
<!-- 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')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</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="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-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="space-y-3 card-content" id="fleet-summary-content">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||||
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
|
||||||
|
<span id="deployed-units" class="text-3xl md:text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Benched</span>
|
||||||
|
<span id="benched-units" class="text-3xl md:text-2xl font-bold text-gray-600 dark:text-gray-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">By Device Type:</p>
|
||||||
|
<div class="flex justify-between items-center mb-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/seismographs" class="text-sm text-gray-600 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400">Seismographs</a>
|
||||||
|
</div>
|
||||||
|
<span id="seismo-count" class="font-semibold text-blue-600 dark:text-blue-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1.5 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/sound-level-meters" class="text-sm text-gray-600 dark:text-gray-400 hover:text-purple-600 dark:hover:text-purple-400">Sound Level Meters</a>
|
||||||
|
</div>
|
||||||
|
<span id="slm-count" class="font-semibold text-purple-600 dark:text-purple-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
||||||
|
</div>
|
||||||
|
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-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 id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recently Called In Units 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')">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-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="recent-callins-content">
|
||||||
|
<div id="recent-callins-list" class="space-y-2">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||||
|
</div>
|
||||||
|
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||||
|
Show all recent call-ins
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Map -->
|
||||||
|
<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')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-map-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="fleet-map-content">
|
||||||
|
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Photos Section -->
|
||||||
|
<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')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</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="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-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="recent-photos-content">
|
||||||
|
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Status Section with Tabs -->
|
||||||
|
<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')">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||||
|
Full Roster
|
||||||
|
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-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="fleet-status-content">
|
||||||
|
<!-- Tab Bar -->
|
||||||
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Active
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium tab-button"
|
||||||
|
hx-get="/dashboard/benched"
|
||||||
|
hx-target="#fleet-table"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Benched
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content Target -->
|
||||||
|
<div id="fleet-table" class="space-y-2"
|
||||||
|
hx-get="/dashboard/active"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<!-- TAB STYLE -->
|
||||||
|
<style>
|
||||||
|
.tab-button {
|
||||||
|
color: #6b7280; /* gray-500 */
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.tab-button:hover {
|
||||||
|
color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
.active-tab {
|
||||||
|
color: #b84a12 !important; /* seismo orange */
|
||||||
|
border-bottom: 2px solid #b84a12 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Collapsible cards (mobile only) */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.card-content.collapsed {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.chevron.collapsed {
|
||||||
|
transform: rotate(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Toggle card collapse/expand (mobile only)
|
||||||
|
function toggleCard(cardName) {
|
||||||
|
// Only work on mobile
|
||||||
|
if (window.innerWidth >= 768) return;
|
||||||
|
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Toggle collapsed state
|
||||||
|
const isCollapsed = content.classList.contains('collapsed');
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.remove('collapsed');
|
||||||
|
chevron.classList.remove('collapsed');
|
||||||
|
|
||||||
|
// If expanding the fleet map, invalidate size after animation
|
||||||
|
if (cardName === 'fleet-map' && window.fleetMap) {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.fleetMap.invalidateSize();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save state to localStorage
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
cardStates[cardName] = !isCollapsed;
|
||||||
|
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore card states from localStorage on page load
|
||||||
|
function restoreCardStates() {
|
||||||
|
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||||
|
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||||
|
|
||||||
|
cardNames.forEach(cardName => {
|
||||||
|
const content = document.getElementById(`${cardName}-content`);
|
||||||
|
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||||
|
|
||||||
|
if (!content || !chevron) return;
|
||||||
|
|
||||||
|
// Default to expanded (true) if no saved state
|
||||||
|
const isCollapsed = cardStates[cardName] === false;
|
||||||
|
|
||||||
|
if (isCollapsed) {
|
||||||
|
content.classList.add('collapsed');
|
||||||
|
chevron.classList.add('collapsed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore states when DOM is ready
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
||||||
|
} else {
|
||||||
|
restoreCardStates();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboard(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
|
// Update "Last updated" timestamp with timezone
|
||||||
|
const now = new Date();
|
||||||
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
// ===== Fleet summary numbers =====
|
||||||
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
|
document.getElementById('benched-units').textContent = data.summary?.benched ?? 0;
|
||||||
|
document.getElementById('status-ok').textContent = data.summary?.ok ?? 0;
|
||||||
|
document.getElementById('status-pending').textContent = data.summary?.pending ?? 0;
|
||||||
|
document.getElementById('status-missing').textContent = data.summary?.missing ?? 0;
|
||||||
|
|
||||||
|
// ===== Device type counts =====
|
||||||
|
let seismoCount = 0;
|
||||||
|
let slmCount = 0;
|
||||||
|
Object.values(data.units || {}).forEach(unit => {
|
||||||
|
if (unit.retired) return; // Don't count retired units
|
||||||
|
const deviceType = unit.device_type || 'seismograph';
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoCount++;
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
slmCount++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('seismo-count').textContent = seismoCount;
|
||||||
|
document.getElementById('slm-count').textContent = slmCount;
|
||||||
|
|
||||||
|
// ===== Alerts =====
|
||||||
|
const alertsList = document.getElementById('alerts-list');
|
||||||
|
// Only show alerts for deployed units that are MISSING (not pending)
|
||||||
|
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) {
|
||||||
|
console.error("Dashboard update error:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle tab switching
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabButtons = document.querySelectorAll('.tab-button');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
// Remove active-tab class from all buttons
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active-tab'));
|
||||||
|
// Add active-tab class to clicked button
|
||||||
|
this.classList.add('active-tab');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize fleet map
|
||||||
|
initFleetMap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let fleetMap = null;
|
||||||
|
let fleetMarkers = [];
|
||||||
|
let fleetMapInitialized = false;
|
||||||
|
|
||||||
|
// Make fleetMap accessible globally for toggleCard function
|
||||||
|
window.fleetMap = null;
|
||||||
|
|
||||||
|
function initFleetMap() {
|
||||||
|
// Initialize the map centered on the US (can adjust based on your deployment area)
|
||||||
|
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||||
|
window.fleetMap = fleetMap;
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
fleetMap.invalidateSize();
|
||||||
|
}, 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) {
|
||||||
|
if (!location) return null;
|
||||||
|
|
||||||
|
// Try to parse as "lat,lon" format
|
||||||
|
const parts = location.split(',').map(s => s.trim());
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const lat = parseFloat(parts[0]);
|
||||||
|
const lon = parseFloat(parts[1]);
|
||||||
|
if (!isNaN(lat) && !isNaN(lon)) {
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Add geocoding support for address strings
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display recent photos
|
||||||
|
async function loadRecentPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-photos?limit=12');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('recentPhotosGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photos.forEach(photo => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<a href="/unit/${photo.unit_id}" class="block">
|
||||||
|
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||||
|
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||||
|
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent photos:', error);
|
||||||
|
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load recent photos on page load and refresh every 30 seconds
|
||||||
|
loadRecentPhotos();
|
||||||
|
setInterval(loadRecentPhotos, 30000);
|
||||||
|
|
||||||
|
// Load and display recent call-ins
|
||||||
|
let showingAllCallins = false;
|
||||||
|
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||||
|
|
||||||
|
async function loadRecentCallins() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/recent-callins?hours=6');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load recent call-ins');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const callinsList = document.getElementById('recent-callins-list');
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
|
||||||
|
if (data.call_ins && data.call_ins.length > 0) {
|
||||||
|
// Determine how many to show
|
||||||
|
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||||
|
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||||
|
|
||||||
|
// Build HTML for call-ins list
|
||||||
|
let html = '';
|
||||||
|
callinsToDisplay.forEach(callin => {
|
||||||
|
// Status color
|
||||||
|
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||||
|
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||||
|
|
||||||
|
// Build location/note line
|
||||||
|
let subtitle = '';
|
||||||
|
if (callin.location) {
|
||||||
|
subtitle = callin.location;
|
||||||
|
} else if (callin.note) {
|
||||||
|
subtitle = callin.note;
|
||||||
|
}
|
||||||
|
|
||||||
|
html += `
|
||||||
|
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||||
|
<div>
|
||||||
|
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||||
|
${callin.unit_id}
|
||||||
|
</a>
|
||||||
|
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
callinsList.innerHTML = html;
|
||||||
|
|
||||||
|
// Show/hide the "Show all" button
|
||||||
|
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||||
|
showAllButton.classList.remove('hidden');
|
||||||
|
showAllButton.textContent = showingAllCallins
|
||||||
|
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||||
|
: `Show all (${data.call_ins.length})`;
|
||||||
|
} else {
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||||
|
showAllButton.classList.add('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading recent call-ins:', error);
|
||||||
|
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle show all/show fewer
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const showAllButton = document.getElementById('show-all-callins');
|
||||||
|
showAllButton.addEventListener('click', function() {
|
||||||
|
showingAllCallins = !showingAllCallins;
|
||||||
|
loadRecentCallins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load recent call-ins on page load and refresh every 30 seconds
|
||||||
|
loadRecentCallins();
|
||||||
|
setInterval(loadRecentCallins, 30000);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
56
app/ui/templates/partials/active_table.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for unit_id, unit in units.items() %}
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
|
<!-- Status Indicator -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit_id }}
|
||||||
|
</a>
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Age -->
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
<span class="text-sm {% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400{% else %}text-gray-500 dark:text-gray-400{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No active units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new unit to the fleet.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
56
app/ui/templates/partials/benched_table.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{% for unit_id, unit in units.items() %}
|
||||||
|
<div class="flex items-center justify-between p-3 rounded-lg bg-gray-50 dark:bg-gray-700/50 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
|
<!-- Status Indicator (grayed out for benched) -->
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="Benched"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Info -->
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a href="/unit/{{ unit_id }}" class="font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit_id }}
|
||||||
|
</a>
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-0.5 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ unit.note }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Last Activity (if any) -->
|
||||||
|
<div class="text-right flex-shrink-0">
|
||||||
|
{% if unit.age != 'N/A' %}
|
||||||
|
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
Last seen: {{ unit.age }} ago
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-sm text-gray-400 dark:text-gray-500">
|
||||||
|
No data
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No benched units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Units awaiting deployment will appear here.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
23
app/ui/templates/partials/dashboard_active.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% for id, unit in units.items() %}
|
||||||
|
<a href="/unit/{{ id }}"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if unit.status == 'OK' %} bg-green-500
|
||||||
|
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||||
|
{% else %} bg-red-500 {% endif %}">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
|
||||||
|
|
||||||
|
<span class="font-medium">{{ id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if units|length == 0 %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">No active units</p>
|
||||||
|
{% endif %}
|
||||||
23
app/ui/templates/partials/dashboard_benched.html
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{% for id, unit in units.items() %}
|
||||||
|
<a href="/unit/{{ id }}"
|
||||||
|
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
|
||||||
|
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<span class="w-3 h-3 rounded-full
|
||||||
|
{% if unit.status == 'OK' %} bg-green-500
|
||||||
|
{% elif unit.status == 'Pending' %} bg-yellow-500
|
||||||
|
{% else %} bg-red-500 {% endif %}">
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<!-- No deployed dot for benched units -->
|
||||||
|
|
||||||
|
<span class="font-medium">{{ id }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if units|length == 0 %}
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm">No benched units</p>
|
||||||
|
{% endif %}
|
||||||
449
app/ui/templates/partials/devices_table.html
Normal file
@@ -0,0 +1,449 @@
|
|||||||
|
<!-- Desktop Table View -->
|
||||||
|
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<span class="sort-indicator" data-column="status"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Unit ID
|
||||||
|
<span class="sort-indicator" data-column="id"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Type
|
||||||
|
<span class="sort-indicator" data-column="type"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Last Seen
|
||||||
|
<span class="sort-indicator" data-column="last_seen"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Age
|
||||||
|
<span class="sort-indicator" data-column="age"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Note
|
||||||
|
<span class="sort-indicator" data-column="note"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
|
data-health="{{ unit.status }}"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-type="{{ unit.device_type }}"
|
||||||
|
data-last-seen="{{ unit.last_seen }}"
|
||||||
|
data-age="{{ unit.age }}"
|
||||||
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
{% if unit.ip_address %}
|
||||||
|
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.phone_number %}
|
||||||
|
<div>{{ unit.phone_number }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.hardware_model %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if unit.next_calibration_due %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm
|
||||||
|
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||||
|
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-gray-500 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||||
|
{{ unit.note if unit.note else '-' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Card View -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="unit-card device-card"
|
||||||
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
|
data-device-type="{{ unit.device_type }}"
|
||||||
|
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
|
||||||
|
data-health="{{ unit.status }}"
|
||||||
|
data-unit-id="{{ unit.id }}"
|
||||||
|
data-age="{{ unit.age }}">
|
||||||
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% elif unit.status == 'Missing' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.address }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.coordinates }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
{% if unit.project_id %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
🏗️ {{ unit.project_id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||||
|
🕐 {{ unit.age }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed/Benched Indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
⚡ Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
📦 Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap Hint -->
|
||||||
|
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
Tap for details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Mobile Last Updated -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal -->
|
||||||
|
<div id="unitModal" class="unit-modal">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="unit-modal-content">
|
||||||
|
<!-- Handle Bar (Mobile Only) -->
|
||||||
|
<div class="modal-handle"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="modalContent" class="p-6">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Deploy/Bench
|
||||||
|
</button>
|
||||||
|
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sort-indicator::after {
|
||||||
|
content: '⇅';
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-indicator.asc::after {
|
||||||
|
content: '↑';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sort-indicator.desc::after {
|
||||||
|
content: '↓';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update timestamp
|
||||||
|
const timestampElement = document.getElementById('last-updated');
|
||||||
|
if (timestampElement) {
|
||||||
|
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||||
|
if (timestampMobileElement) {
|
||||||
|
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a lightweight status map around for the mobile modal
|
||||||
|
const rosterUnits = {{ units | tojson }};
|
||||||
|
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||||
|
acc[unit.id] = {
|
||||||
|
status: unit.status || 'Unknown',
|
||||||
|
age: unit.age || 'N/A',
|
||||||
|
last: unit.last_seen || 'Never'
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|
||||||
|
function sortTable(column) {
|
||||||
|
const tbody = document.getElementById('roster-tbody');
|
||||||
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
|
// Determine sort direction
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||||
|
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||||
|
|
||||||
|
// Special handling for different column types
|
||||||
|
if (column === 'age') {
|
||||||
|
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||||
|
aVal = parseAge(aVal);
|
||||||
|
bVal = parseAge(bVal);
|
||||||
|
} else if (column === 'status') {
|
||||||
|
// Sort by status priority: Missing > Pending > OK
|
||||||
|
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||||
|
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||||
|
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||||
|
} else if (column === 'last_seen') {
|
||||||
|
// Sort by date
|
||||||
|
aVal = new Date(aVal).getTime() || 0;
|
||||||
|
bVal = new Date(bVal).getTime() || 0;
|
||||||
|
} else {
|
||||||
|
// String comparison (case-insensitive)
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = bVal.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-append rows in sorted order
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort indicators
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAge(ageStr) {
|
||||||
|
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||||
|
if (!ageStr) return 0;
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
const weeks = ageStr.match(/(\d+)w/);
|
||||||
|
const days = ageStr.match(/(\d+)d/);
|
||||||
|
const hours = ageStr.match(/(\d+)h/);
|
||||||
|
const minutes = ageStr.match(/(\d+)m/);
|
||||||
|
|
||||||
|
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||||
|
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||||
|
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||||
|
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||||
|
|
||||||
|
return totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
// Clear all indicators
|
||||||
|
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||||
|
indicator.className = 'sort-indicator';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set current indicator
|
||||||
|
if (currentSort.column) {
|
||||||
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
|
if (indicator) {
|
||||||
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
69
app/ui/templates/partials/ignored_table.html
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Unit ID
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Reason
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Ignored At
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if ignored_units %}
|
||||||
|
{% for unit in ignored_units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-400" title="Ignored"></span>
|
||||||
|
<span class="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{{ unit.id }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.reason }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.ignored_at }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="unignoreUnit('{{ unit.id }}')"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Un-ignore Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteIgnoredUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No ignored units
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: {{ timestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
77
app/ui/templates/partials/retired_table.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Unit ID
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Type
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Note
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% if units %}
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-500" title="Retired"></span>
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">{{ unit.note }}</span>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="px-6 py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
No retired units
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: {{ timestamp }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
445
app/ui/templates/partials/roster_table.html
Normal file
@@ -0,0 +1,445 @@
|
|||||||
|
<!-- Desktop Table View -->
|
||||||
|
<div class="hidden md:block rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
|
||||||
|
<table id="roster-table" class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('status')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Status
|
||||||
|
<span class="sort-indicator" data-column="status"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('id')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Unit ID
|
||||||
|
<span class="sort-indicator" data-column="id"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('type')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Type
|
||||||
|
<span class="sort-indicator" data-column="type"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Details
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('last_seen')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Last Seen
|
||||||
|
<span class="sort-indicator" data-column="last_seen"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('age')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Age
|
||||||
|
<span class="sort-indicator" data-column="age"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider cursor-pointer hover:bg-gray-100 dark:hover:bg-gray-600 select-none" onclick="sortTable('note')">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
Note
|
||||||
|
<span class="sort-indicator" data-column="note"></span>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="roster-tbody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
data-status="{{ unit.status }}"
|
||||||
|
data-id="{{ unit.id }}"
|
||||||
|
data-type="{{ unit.device_type }}"
|
||||||
|
data-last-seen="{{ unit.last_seen }}"
|
||||||
|
data-age="{{ unit.age }}"
|
||||||
|
data-note="{{ unit.note if unit.note else '' }}">
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-sm font-medium text-seismo-orange hover:text-seismo-burgundy hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
{% if unit.ip_address %}
|
||||||
|
<div><span class="font-mono">{{ unit.ip_address }}</span></div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.phone_number %}
|
||||||
|
<div>{{ unit.phone_number }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.hardware_model %}
|
||||||
|
<div class="text-gray-500 dark:text-gray-500">{{ unit.hardware_model }}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{% if unit.next_calibration_due %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Cal Due:</span>
|
||||||
|
<span class="font-medium">{{ unit.next_calibration_due }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500 dark:text-gray-500">Modem:</span>
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-seismo-orange hover:underline font-medium">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap">
|
||||||
|
<div class="text-sm
|
||||||
|
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
|
||||||
|
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
|
||||||
|
{% else %}text-gray-500 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{{ unit.age }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4">
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
|
||||||
|
{{ unit.note if unit.note else '-' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<button onclick="editUnit('{{ unit.id }}')"
|
||||||
|
class="text-seismo-orange hover:text-seismo-burgundy p-1" title="Edit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', false)"
|
||||||
|
class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 p-1" title="Mark as Benched">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button onclick="toggleDeployed('{{ unit.id }}', true)"
|
||||||
|
class="text-green-600 hover:text-green-800 dark:text-green-400 dark:hover:text-green-300 p-1" title="Mark as Deployed">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
<button onclick="moveToIgnore('{{ unit.id }}')"
|
||||||
|
class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 p-1" title="Move to Ignore List">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit('{{ unit.id }}')"
|
||||||
|
class="text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 p-1" title="Delete Unit">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- Last updated indicator -->
|
||||||
|
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
|
||||||
|
Last updated: <span id="last-updated">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile Card View -->
|
||||||
|
<div class="md:hidden space-y-3">
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="unit-card"
|
||||||
|
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
|
||||||
|
data-unit-id="{{ unit.id }}"
|
||||||
|
data-status="{{ unit.status }}"
|
||||||
|
data-age="{{ unit.age }}">
|
||||||
|
<!-- Header: Status Dot + Unit ID + Status Badge -->
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if unit.status == 'OK' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
|
{% elif unit.status == 'Pending' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
|
{% elif unit.status == 'Missing' %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="No Data"></span>
|
||||||
|
{% endif %}
|
||||||
|
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="px-3 py-1 rounded-full text-xs font-medium
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
|
||||||
|
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
|
||||||
|
{% endif %}">
|
||||||
|
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Type Badge -->
|
||||||
|
<div class="mb-2">
|
||||||
|
{% if unit.device_type == 'modem' %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300 text-xs font-medium">
|
||||||
|
Modem
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-2 py-1 rounded-full bg-blue-100 dark:bg-blue-900/30 text-blue-800 dark:text-blue-300 text-xs font-medium">
|
||||||
|
Seismograph
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location -->
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.address }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
📍 {{ unit.coordinates }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
{% if unit.project_id %}
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
|
🏗️ {{ unit.project_id }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Last Seen -->
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-500 mt-2">
|
||||||
|
🕐 {{ unit.age }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed/Benched Indicator -->
|
||||||
|
<div class="mt-2">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="text-xs text-blue-600 dark:text-blue-400">
|
||||||
|
⚡ Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-500">
|
||||||
|
📦 Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tap Hint -->
|
||||||
|
<div class="text-xs text-gray-400 mt-2 text-center border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
Tap for details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<!-- Mobile Last Updated -->
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 text-center py-2">
|
||||||
|
Last updated: <span id="last-updated-mobile">{{ timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Detail Modal -->
|
||||||
|
<div id="unitModal" class="unit-modal">
|
||||||
|
<!-- Backdrop -->
|
||||||
|
<div class="unit-modal-backdrop" onclick="closeUnitModal(event)"></div>
|
||||||
|
|
||||||
|
<!-- Modal Content -->
|
||||||
|
<div class="unit-modal-content">
|
||||||
|
<!-- Handle Bar (Mobile Only) -->
|
||||||
|
<div class="modal-handle"></div>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 id="modalUnitId" class="text-xl font-bold text-gray-900 dark:text-white"></h3>
|
||||||
|
<button onclick="closeUnitModal(event)" data-close-modal class="w-10 h-10 flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div id="modalContent" class="p-6">
|
||||||
|
<!-- Content will be populated by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="p-6 border-t border-gray-200 dark:border-gray-700 space-y-3">
|
||||||
|
<button id="modalEditBtn" class="w-full h-12 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button id="modalDeployBtn" class="h-12 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
|
||||||
|
Deploy/Bench
|
||||||
|
</button>
|
||||||
|
<button id="modalDeleteBtn" class="h-12 border border-red-300 dark:border-red-600 text-red-600 dark:text-red-400 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.sort-indicator::after {
|
||||||
|
content: '⇅';
|
||||||
|
opacity: 0.3;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.sort-indicator.asc::after {
|
||||||
|
content: '↑';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.sort-indicator.desc::after {
|
||||||
|
content: '↓';
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update timestamp
|
||||||
|
const timestampElement = document.getElementById('last-updated');
|
||||||
|
if (timestampElement) {
|
||||||
|
timestampElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
const timestampMobileElement = document.getElementById('last-updated-mobile');
|
||||||
|
if (timestampMobileElement) {
|
||||||
|
timestampMobileElement.textContent = new Date().toLocaleTimeString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep a lightweight status map around for the mobile modal
|
||||||
|
const rosterUnits = {{ units | tojson }};
|
||||||
|
window.rosterStatusMap = rosterUnits.reduce((acc, unit) => {
|
||||||
|
acc[unit.id] = {
|
||||||
|
status: unit.status || 'Unknown',
|
||||||
|
age: unit.age || 'N/A',
|
||||||
|
last: unit.last_seen || 'Never'
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
// Sorting state
|
||||||
|
let currentSort = { column: null, direction: 'asc' };
|
||||||
|
|
||||||
|
function sortTable(column) {
|
||||||
|
const tbody = document.getElementById('roster-tbody');
|
||||||
|
const rows = Array.from(tbody.getElementsByTagName('tr'));
|
||||||
|
|
||||||
|
// Determine sort direction
|
||||||
|
if (currentSort.column === column) {
|
||||||
|
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||||||
|
} else {
|
||||||
|
currentSort.column = column;
|
||||||
|
currentSort.direction = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort rows
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
let aVal = a.getAttribute(`data-${column}`) || '';
|
||||||
|
let bVal = b.getAttribute(`data-${column}`) || '';
|
||||||
|
|
||||||
|
// Special handling for different column types
|
||||||
|
if (column === 'age') {
|
||||||
|
// Parse age strings like "2h 15m" or "45m" or "3d 5h"
|
||||||
|
aVal = parseAge(aVal);
|
||||||
|
bVal = parseAge(bVal);
|
||||||
|
} else if (column === 'status') {
|
||||||
|
// Sort by status priority: Missing > Pending > OK
|
||||||
|
const statusOrder = { 'Missing': 0, 'Pending': 1, 'OK': 2, '': 3 };
|
||||||
|
aVal = statusOrder[aVal] !== undefined ? statusOrder[aVal] : 999;
|
||||||
|
bVal = statusOrder[bVal] !== undefined ? statusOrder[bVal] : 999;
|
||||||
|
} else if (column === 'last_seen') {
|
||||||
|
// Sort by date
|
||||||
|
aVal = new Date(aVal).getTime() || 0;
|
||||||
|
bVal = new Date(bVal).getTime() || 0;
|
||||||
|
} else {
|
||||||
|
// String comparison (case-insensitive)
|
||||||
|
aVal = aVal.toLowerCase();
|
||||||
|
bVal = bVal.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aVal < bVal) return currentSort.direction === 'asc' ? -1 : 1;
|
||||||
|
if (aVal > bVal) return currentSort.direction === 'asc' ? 1 : -1;
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Re-append rows in sorted order
|
||||||
|
rows.forEach(row => tbody.appendChild(row));
|
||||||
|
|
||||||
|
// Update sort indicators
|
||||||
|
updateSortIndicators();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAge(ageStr) {
|
||||||
|
// Parse age strings like "2h 15m", "45m", "3d 5h", "2w 3d"
|
||||||
|
if (!ageStr) return 0;
|
||||||
|
|
||||||
|
let totalMinutes = 0;
|
||||||
|
const weeks = ageStr.match(/(\d+)w/);
|
||||||
|
const days = ageStr.match(/(\d+)d/);
|
||||||
|
const hours = ageStr.match(/(\d+)h/);
|
||||||
|
const minutes = ageStr.match(/(\d+)m/);
|
||||||
|
|
||||||
|
if (weeks) totalMinutes += parseInt(weeks[1]) * 7 * 24 * 60;
|
||||||
|
if (days) totalMinutes += parseInt(days[1]) * 24 * 60;
|
||||||
|
if (hours) totalMinutes += parseInt(hours[1]) * 60;
|
||||||
|
if (minutes) totalMinutes += parseInt(minutes[1]);
|
||||||
|
|
||||||
|
return totalMinutes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortIndicators() {
|
||||||
|
// Clear all indicators
|
||||||
|
document.querySelectorAll('.sort-indicator').forEach(indicator => {
|
||||||
|
indicator.className = 'sort-indicator';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set current indicator
|
||||||
|
if (currentSort.column) {
|
||||||
|
const indicator = document.querySelector(`.sort-indicator[data-column="${currentSort.column}"]`);
|
||||||
|
if (indicator) {
|
||||||
|
indicator.className = `sort-indicator ${currentSort.direction}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
56
app/ui/templates/partials/seismo_stats.html
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<!-- Total Seismographs -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Total Seismographs</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">{{ total }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Deployed</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-2">{{ deployed }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benched -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- With Modem -->
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">With Modem</p>
|
||||||
|
<p class="text-3xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ with_modem }}<span class="text-base text-gray-500">/ {{ deployed }}</span></p>
|
||||||
|
{% if without_modem > 0 %}
|
||||||
|
<p class="text-xs text-orange-600 dark:text-orange-400 mt-1">{{ without_modem }} without modem</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<svg class="w-12 h-12 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
97
app/ui/templates/partials/seismo_unit_list.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% if units %}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Unit ID</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Status</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Modem</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Location</th>
|
||||||
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Notes</th>
|
||||||
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
|
{% for unit in units %}
|
||||||
|
<tr class="hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors">
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="font-medium text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.id }}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap">
|
||||||
|
{% if unit.deployed %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Deployed
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
|
||||||
|
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 00-1 1v4a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1H8z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
Benched
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<a href="/unit/{{ unit.deployed_with_modem_id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">None</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-900 dark:text-gray-300">
|
||||||
|
{% if unit.address %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.address }}">{{ unit.address }}</span>
|
||||||
|
{% elif unit.coordinates %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ unit.coordinates }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-sm text-gray-700 dark:text-gray-400">
|
||||||
|
{% if unit.note %}
|
||||||
|
<span class="truncate max-w-xs inline-block" title="{{ unit.note }}">{{ unit.note }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="text-gray-400 dark:text-gray-600">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
||||||
|
<a href="/unit/{{ unit.id }}" class="text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
View Details →
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if search %}
|
||||||
|
<div class="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Found {{ units|length }} seismograph(s) matching "{{ search }}"
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">No seismographs found</h3>
|
||||||
|
{% if search %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">No seismographs match "{{ search }}"</p>
|
||||||
|
<button onclick="document.getElementById('seismo-search').value = ''; htmx.trigger('#seismo-search', 'keyup');"
|
||||||
|
class="mt-3 text-blue-600 dark:text-blue-400 hover:underline">
|
||||||
|
Clear search
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a seismograph unit from the roster page.</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
288
app/ui/templates/partials/slm_config_form.html
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
<form id="slm-config-form"
|
||||||
|
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleConfigSave(event)">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model & Serial -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||||
|
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select model...</option>
|
||||||
|
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||||
|
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="e.g., SN123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency & Time Weighting -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||||
|
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||||
|
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||||
|
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||||
|
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Range -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||||
|
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select range...</option>
|
||||||
|
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||||
|
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||||
|
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||||
|
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<select name="deployed_with_modem_id" id="config-modem-select" class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">No modem (direct connection)</option>
|
||||||
|
<!-- Options loaded via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<button type="button" id="test-modem-btn" onclick="testModemConnection()"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors {% if not unit.deployed_with_modem_id %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if not unit.deployed_with_modem_id %}disabled{% endif %}
|
||||||
|
title="Ping modem to test connectivity">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Port Configuration (always visible) -->
|
||||||
|
<div class="grid grid-cols-3 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '2255' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="2255">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Control port</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">FTP Port</label>
|
||||||
|
<input type="number" name="slm_ftp_port" value="{{ unit.slm_ftp_port or '21' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="21">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Data transfer</p>
|
||||||
|
</div>
|
||||||
|
<div id="direct-ip-field" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP</label>
|
||||||
|
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="192.168.1.100">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">If no modem</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="closeConfigModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="testSLMConnection('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||||
|
Test SLM Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load modems list for dropdown
|
||||||
|
async function loadModemsForConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
const modems = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('config-modem-select');
|
||||||
|
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||||
|
|
||||||
|
// Keep the "No modem" option
|
||||||
|
modems.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
const ipText = modem.ip_address ? ' (' + modem.ip_address + ')' : '';
|
||||||
|
option.textContent = modem.id + ipText;
|
||||||
|
if (modem.id === currentValue) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load modems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direct IP field and test modem button based on modem selection
|
||||||
|
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||||
|
const directIpField = document.getElementById('direct-ip-field');
|
||||||
|
const testModemBtn = document.getElementById('test-modem-btn');
|
||||||
|
|
||||||
|
if (this.value === '') {
|
||||||
|
directIpField.classList.remove('hidden');
|
||||||
|
testModemBtn.disabled = true;
|
||||||
|
testModemBtn.classList.add('opacity-50', 'cursor-not-allowed');
|
||||||
|
} else {
|
||||||
|
directIpField.classList.add('hidden');
|
||||||
|
testModemBtn.disabled = false;
|
||||||
|
testModemBtn.classList.remove('opacity-50', 'cursor-not-allowed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save response
|
||||||
|
function handleConfigSave(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
// Show success message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Configuration saved successfully!';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
closeConfigModal();
|
||||||
|
// Refresh the unit list
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Failed to save configuration';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to modem (health ping)
|
||||||
|
async function testModemConnection() {
|
||||||
|
const modemSelect = document.getElementById('config-modem-select');
|
||||||
|
const modemId = modemSelect.value;
|
||||||
|
|
||||||
|
if (!modemId) {
|
||||||
|
alert('Please select a modem first');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Pinging modem...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/slm-dashboard/test-modem/' + modemId);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'success') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
const ipAddr = data.ip_address || modemId;
|
||||||
|
const respTime = data.response_time || 'N/A';
|
||||||
|
resultToast.innerHTML = '✓ Modem responding!<br><span class="text-xs">' + ipAddr + ' - ' + respTime + 'ms</span>';
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '⚠ Modem not responding: ' + (data.detail || 'Unknown error');
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 4000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ Failed to ping modem: ' + error.message;
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to SLM
|
||||||
|
async function testSLMConnection(unitId) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Testing SLM connection...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/slmm/' + unitId + '/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'online') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '✓ SLM connection successful! ' + (data.model || 'SLM') + ' responding';
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = '⚠ SLM connection failed or device offline';
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ SLM connection test failed: ' + error.message;
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modems on page load
|
||||||
|
loadModemsForConfig();
|
||||||
|
</script>
|
||||||
215
app/ui/templates/partials/slm_config_form_old.html
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
<form id="slm-config-form"
|
||||||
|
hx-post="/api/slm-dashboard/config/{{ unit.id }}"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="handleConfigSave(event)">
|
||||||
|
|
||||||
|
<div class="mb-6">
|
||||||
|
<h4 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">Unit: {{ unit.id }}</h4>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">Configure measurement parameters for this sound level meter</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Model & Serial -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Model</label>
|
||||||
|
<select name="slm_model" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select model...</option>
|
||||||
|
<option value="NL-43" {% if unit.slm_model == 'NL-43' %}selected{% endif %}>NL-43</option>
|
||||||
|
<option value="NL-53" {% if unit.slm_model == 'NL-53' %}selected{% endif %}>NL-53</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" value="{{ unit.slm_serial_number or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
|
||||||
|
placeholder="e.g., SN123456">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Frequency & Time Weighting -->
|
||||||
|
<div class="grid grid-cols-2 gap-4 mb-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="A" {% if unit.slm_frequency_weighting == 'A' %}selected{% endif %}>A-weighting</option>
|
||||||
|
<option value="C" {% if unit.slm_frequency_weighting == 'C' %}selected{% endif %}>C-weighting</option>
|
||||||
|
<option value="Z" {% if unit.slm_frequency_weighting == 'Z' %}selected{% endif %}>Z-weighting (Linear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select...</option>
|
||||||
|
<option value="Fast" {% if unit.slm_time_weighting == 'Fast' %}selected{% endif %}>Fast (125ms)</option>
|
||||||
|
<option value="Slow" {% if unit.slm_time_weighting == 'Slow' %}selected{% endif %}>Slow (1s)</option>
|
||||||
|
<option value="Impulse" {% if unit.slm_time_weighting == 'Impulse' %}selected{% endif %}>Impulse</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Range -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Measurement Range</label>
|
||||||
|
<select name="slm_measurement_range" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">Select range...</option>
|
||||||
|
<option value="30-130" {% if unit.slm_measurement_range == '30-130' %}selected{% endif %}>30-130 dB</option>
|
||||||
|
<option value="40-140" {% if unit.slm_measurement_range == '40-140' %}selected{% endif %}>40-140 dB</option>
|
||||||
|
<option value="50-140" {% if unit.slm_measurement_range == '50-140' %}selected{% endif %}>50-140 dB</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Network Configuration -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 mt-6 mb-4">
|
||||||
|
<h5 class="text-md font-semibold text-gray-900 dark:text-white mb-3">Network Configuration</h5>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned Modem</label>
|
||||||
|
<select name="deployed_with_modem_id" id="config-modem-select" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
|
||||||
|
<option value="">No modem (direct connection)</option>
|
||||||
|
<!-- Options loaded via JavaScript -->
|
||||||
|
</select>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Select a modem for network access, or leave blank for direct IP connection</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legacy direct connection (shown only if no modem selected) -->
|
||||||
|
<div id="direct-connection-fields" class="{% if unit.deployed_with_modem_id %}hidden{% endif %}">
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Direct IP Address</label>
|
||||||
|
<input type="text" name="slm_host" value="{{ unit.slm_host or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="192.168.1.100">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" value="{{ unit.slm_tcp_port or '' }}"
|
||||||
|
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white font-mono text-sm"
|
||||||
|
placeholder="502">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Action Buttons -->
|
||||||
|
<div class="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onclick="closeConfigModal()"
|
||||||
|
class="px-4 py-2 text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="testConnection('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 text-blue-700 dark:text-blue-300 bg-blue-100 dark:bg-blue-900 hover:bg-blue-200 dark:hover:bg-blue-800 rounded-lg transition-colors">
|
||||||
|
Test Connection
|
||||||
|
</button>
|
||||||
|
<button type="submit"
|
||||||
|
class="px-4 py-2 text-white bg-seismo-orange hover:bg-seismo-burgundy rounded-lg transition-colors">
|
||||||
|
Save Configuration
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Load modems list for dropdown
|
||||||
|
async function loadModemsForConfig() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/modems');
|
||||||
|
const modems = await response.json();
|
||||||
|
|
||||||
|
const select = document.getElementById('config-modem-select');
|
||||||
|
const currentValue = '{{ unit.deployed_with_modem_id or "" }}';
|
||||||
|
|
||||||
|
// Keep the "No modem" option
|
||||||
|
modems.forEach(modem => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = modem.id;
|
||||||
|
option.textContent = `${modem.id}${modem.ip_address ? ' (' + modem.ip_address + ')' : ''}`;
|
||||||
|
if (modem.id === currentValue) {
|
||||||
|
option.selected = true;
|
||||||
|
}
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load modems:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle direct connection fields based on modem selection
|
||||||
|
document.getElementById('config-modem-select')?.addEventListener('change', function() {
|
||||||
|
const directFields = document.getElementById('direct-connection-fields');
|
||||||
|
if (this.value === '') {
|
||||||
|
directFields.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
directFields.classList.add('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle save response
|
||||||
|
function handleConfigSave(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
// Show success message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Configuration saved successfully!';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
closeConfigModal();
|
||||||
|
// Refresh the unit list
|
||||||
|
htmx.trigger('#slm-list', 'load');
|
||||||
|
}, 2000);
|
||||||
|
} else {
|
||||||
|
// Show error message
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Failed to save configuration';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection to SLM
|
||||||
|
async function testConnection(unitId) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = 'fixed bottom-4 right-4 bg-blue-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
toast.textContent = 'Testing connection...';
|
||||||
|
document.body.appendChild(toast);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
toast.remove();
|
||||||
|
|
||||||
|
const resultToast = document.createElement('div');
|
||||||
|
if (response.ok && data.status === 'online') {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = `✓ Connection successful! ${data.model || 'SLM'} responding`;
|
||||||
|
} else {
|
||||||
|
resultToast.className = 'fixed bottom-4 right-4 bg-yellow-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
resultToast.textContent = `Connection failed or device offline`;
|
||||||
|
}
|
||||||
|
document.body.appendChild(resultToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
} catch (error) {
|
||||||
|
toast.remove();
|
||||||
|
const errorToast = document.createElement('div');
|
||||||
|
errorToast.className = 'fixed bottom-4 right-4 bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg z-50';
|
||||||
|
errorToast.textContent = '✗ Connection test failed';
|
||||||
|
document.body.appendChild(errorToast);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
errorToast.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load modems on page load
|
||||||
|
loadModemsForConfig();
|
||||||
|
</script>
|
||||||
105
app/ui/templates/partials/slm_controls.html
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<div class="mb-6 p-4 rounded-lg {% if is_measuring %}bg-green-50 dark:bg-green-900/20{% else %}bg-gray-50 dark:bg-gray-900{% endif %}">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Measurement Status</div>
|
||||||
|
<div class="text-2xl font-bold {% if is_measuring %}text-green-600 dark:text-green-400{% else %}text-gray-600 dark:text-gray-400{% endif %}">
|
||||||
|
{% if measurement_state %}
|
||||||
|
{{ measurement_state }}
|
||||||
|
{% if is_measuring %}
|
||||||
|
<span class="inline-block w-3 h-3 bg-green-500 rounded-full ml-2 animate-pulse"></span>
|
||||||
|
{% endif %}
|
||||||
|
{% else %}
|
||||||
|
Unknown
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Battery</div>
|
||||||
|
<div class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
|
{{ battery_level or '--' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/start"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/stop"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors flex items-center justify-center gap-2
|
||||||
|
{% if not is_measuring %}opacity-50 cursor-not-allowed{% endif %}"
|
||||||
|
{% if not is_measuring %}disabled{% endif %}>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/pause"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-yellow-600 text-white rounded-lg hover:bg-yellow-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/reset"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-confirm="Are you sure you want to reset the measurement data?"
|
||||||
|
hx-on::after-request="htmx.trigger('#slm-controls', 'refresh')"
|
||||||
|
class="px-4 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<button hx-get="/api/slmm/{{ unit_id }}/live"
|
||||||
|
hx-swap="none"
|
||||||
|
hx-indicator="#live-spinner"
|
||||||
|
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg id="live-spinner" class="htmx-indicator w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Get Live Data
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button hx-post="/api/slmm/{{ unit_id }}/store"
|
||||||
|
hx-swap="none"
|
||||||
|
class="px-4 py-2 bg-purple-600 text-white text-sm rounded-lg hover:bg-purple-700 transition-colors flex items-center justify-center gap-2">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4"></path>
|
||||||
|
</svg>
|
||||||
|
Store Data
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="slm-controls" hx-get="/slm/partials/{{ unit_id }}/controls" hx-trigger="refresh" hx-swap="outerHTML"></div>
|
||||||
1466
app/ui/templates/partials/slm_live_view.html
Normal file
438
app/ui/templates/partials/slm_live_view.html.backup
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
<!-- Live View Panel for {{ unit.id }} -->
|
||||||
|
<div class="h-full flex flex-col">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">{{ unit.id }}</h2>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{% if unit.slm_model %}{{ unit.slm_model }}{% endif %}
|
||||||
|
{% if unit.slm_serial_number %} • S/N: {{ unit.slm_serial_number }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if modem %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
via Modem: {{ modem.id }}{% if modem_ip %} ({{ modem_ip }}){% endif %}
|
||||||
|
</p>
|
||||||
|
{% elif modem_ip %}
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
Direct: {{ modem_ip }}
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-xs text-red-500 dark:text-red-400 mt-1">
|
||||||
|
⚠️ No modem assigned or IP configured
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Measurement Status Badge -->
|
||||||
|
<div>
|
||||||
|
{% if is_measuring %}
|
||||||
|
<span class="px-4 py-2 bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-400 rounded-lg font-medium flex items-center">
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Measuring
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg font-medium">
|
||||||
|
Stopped
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Buttons -->
|
||||||
|
<div class="flex gap-2 mb-6">
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'start')"
|
||||||
|
class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Start
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'pause')"
|
||||||
|
class="px-4 py-2 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 9v6m4-6v6m7-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
Pause
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'stop')"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button onclick="controlUnit('{{ unit.id }}', 'reset')"
|
||||||
|
class="px-4 py-2 bg-gray-600 hover:bg-gray-700 text-white rounded-lg flex items-center">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="start-stream-btn" onclick="initLiveDataStream('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center ml-auto">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
Start Live Stream
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button id="stop-stream-btn" onclick="stopLiveDataStream()" style="display: none;"
|
||||||
|
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg flex items-center ml-auto">
|
||||||
|
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 10a1 1 0 011-1h4a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 01-1-1v-4z"></path>
|
||||||
|
</svg>
|
||||||
|
Stop Live Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Metrics -->
|
||||||
|
<div class="grid grid-cols-5 gap-4 mb-6">
|
||||||
|
<div class="bg-blue-50 dark:bg-blue-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</p>
|
||||||
|
<p id="live-lp" class="text-2xl font-bold text-blue-600 dark:text-blue-400">
|
||||||
|
{% if current_status and current_status.lp %}{{ current_status.lp }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-green-50 dark:bg-green-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</p>
|
||||||
|
<p id="live-leq" class="text-2xl font-bold text-green-600 dark:text-green-400">
|
||||||
|
{% if current_status and current_status.leq %}{{ current_status.leq }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-red-50 dark:bg-red-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmax (Max)</p>
|
||||||
|
<p id="live-lmax" class="text-2xl font-bold text-red-600 dark:text-red-400">
|
||||||
|
{% if current_status and current_status.lmax %}{{ current_status.lmax }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-purple-50 dark:bg-purple-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lmin (Min)</p>
|
||||||
|
<p id="live-lmin" class="text-2xl font-bold text-purple-600 dark:text-purple-400">
|
||||||
|
{% if current_status and current_status.lmin %}{{ current_status.lmin }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-orange-50 dark:bg-orange-900/20 rounded-lg p-4">
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">Lpeak (Peak)</p>
|
||||||
|
<p id="live-lpeak" class="text-2xl font-bold text-orange-600 dark:text-orange-400">
|
||||||
|
{% if current_status and current_status.lpeak %}{{ current_status.lpeak }}{% else %}--{% endif %}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400">dB</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live Chart -->
|
||||||
|
<div class="flex-1 bg-gray-50 dark:bg-gray-900/50 rounded-lg p-4" style="min-height: 400px;">
|
||||||
|
<canvas id="liveChart"></canvas>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Info -->
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Battery:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Power:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">Weighting:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if unit.slm_frequency_weighting %}{{ unit.slm_frequency_weighting }}{% else %}--{% endif %} /
|
||||||
|
{% if unit.slm_time_weighting %}{{ unit.slm_time_weighting }}{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">SD Remaining:</span>
|
||||||
|
<span class="font-medium text-gray-900 dark:text-white ml-2">
|
||||||
|
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||||
|
<script>
|
||||||
|
// Initialize Chart.js for live data visualization
|
||||||
|
function initializeChart() {
|
||||||
|
// Wait for Chart.js to load
|
||||||
|
if (typeof Chart === 'undefined') {
|
||||||
|
console.log('Waiting for Chart.js to load...');
|
||||||
|
setTimeout(initializeChart, 100);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Chart.js loaded, version:', Chart.version);
|
||||||
|
|
||||||
|
const canvas = document.getElementById('liveChart');
|
||||||
|
if (!canvas) {
|
||||||
|
console.error('Chart canvas not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Canvas found:', canvas);
|
||||||
|
|
||||||
|
// Destroy existing chart if it exists
|
||||||
|
if (window.liveChart && typeof window.liveChart.destroy === 'function') {
|
||||||
|
console.log('Destroying existing chart');
|
||||||
|
window.liveChart.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
console.log('Creating new chart...');
|
||||||
|
|
||||||
|
// Dark mode detection
|
||||||
|
const isDarkMode = document.documentElement.classList.contains('dark');
|
||||||
|
const gridColor = isDarkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)';
|
||||||
|
const textColor = isDarkMode ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)';
|
||||||
|
|
||||||
|
window.liveChart = new Chart(ctx, {
|
||||||
|
type: 'line',
|
||||||
|
data: {
|
||||||
|
labels: [],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Lp (Instantaneous)',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(59, 130, 246)',
|
||||||
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Leq (Equivalent)',
|
||||||
|
data: [],
|
||||||
|
borderColor: 'rgb(34, 197, 94)',
|
||||||
|
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||||
|
tension: 0.3,
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: false,
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index'
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
display: true,
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor,
|
||||||
|
maxTicksLimit: 10
|
||||||
|
}
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
display: true,
|
||||||
|
title: {
|
||||||
|
display: true,
|
||||||
|
text: 'Sound Level (dB)',
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
color: gridColor
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: textColor
|
||||||
|
},
|
||||||
|
min: 30,
|
||||||
|
max: 130
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
labels: {
|
||||||
|
color: textColor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Chart created successfully:', window.liveChart);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chart when DOM is ready
|
||||||
|
console.log('Executing initializeChart...');
|
||||||
|
initializeChart();
|
||||||
|
|
||||||
|
// WebSocket management (use global scope to avoid redeclaration)
|
||||||
|
if (typeof window.currentWebSocket === 'undefined') {
|
||||||
|
window.currentWebSocket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLiveDataStream(unitId) {
|
||||||
|
// Close existing connection if any
|
||||||
|
if (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset chart data
|
||||||
|
if (window.chartData) {
|
||||||
|
window.chartData.timestamps = [];
|
||||||
|
window.chartData.lp = [];
|
||||||
|
window.chartData.leq = [];
|
||||||
|
}
|
||||||
|
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
|
||||||
|
window.liveChart.data.labels = [];
|
||||||
|
window.liveChart.data.datasets[0].data = [];
|
||||||
|
window.liveChart.data.datasets[1].data = [];
|
||||||
|
window.liveChart.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket URL for SLMM backend via proxy
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
|
||||||
|
|
||||||
|
window.currentWebSocket = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
window.currentWebSocket.onopen = function() {
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'none';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'flex';
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentWebSocket.onmessage = function(event) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('WebSocket data received:', data);
|
||||||
|
updateLiveMetrics(data);
|
||||||
|
updateLiveChart(data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error parsing WebSocket message:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentWebSocket.onerror = function(error) {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.currentWebSocket.onclose = function() {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
// Toggle button visibility
|
||||||
|
const startBtn = document.getElementById('start-stream-btn');
|
||||||
|
const stopBtn = document.getElementById('stop-stream-btn');
|
||||||
|
if (startBtn) startBtn.style.display = 'flex';
|
||||||
|
if (stopBtn) stopBtn.style.display = 'none';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLiveDataStream() {
|
||||||
|
if (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
window.currentWebSocket = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metrics display
|
||||||
|
function updateLiveMetrics(data) {
|
||||||
|
if (document.getElementById('live-lp')) {
|
||||||
|
document.getElementById('live-lp').textContent = data.lp || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-leq')) {
|
||||||
|
document.getElementById('live-leq').textContent = data.leq || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmax')) {
|
||||||
|
document.getElementById('live-lmax').textContent = data.lmax || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lmin')) {
|
||||||
|
document.getElementById('live-lmin').textContent = data.lmin || '--';
|
||||||
|
}
|
||||||
|
if (document.getElementById('live-lpeak')) {
|
||||||
|
document.getElementById('live-lpeak').textContent = data.lpeak || '--';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chart data storage (use global scope to avoid redeclaration)
|
||||||
|
if (typeof window.chartData === 'undefined') {
|
||||||
|
window.chartData = {
|
||||||
|
timestamps: [],
|
||||||
|
lp: [],
|
||||||
|
leq: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update live chart
|
||||||
|
function updateLiveChart(data) {
|
||||||
|
const now = new Date();
|
||||||
|
window.chartData.timestamps.push(now.toLocaleTimeString());
|
||||||
|
window.chartData.lp.push(parseFloat(data.lp || 0));
|
||||||
|
window.chartData.leq.push(parseFloat(data.leq || 0));
|
||||||
|
|
||||||
|
// Keep only last 60 data points
|
||||||
|
if (window.chartData.timestamps.length > 60) {
|
||||||
|
window.chartData.timestamps.shift();
|
||||||
|
window.chartData.lp.shift();
|
||||||
|
window.chartData.leq.shift();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update chart if available
|
||||||
|
if (window.liveChart) {
|
||||||
|
window.liveChart.data.labels = window.chartData.timestamps;
|
||||||
|
window.liveChart.data.datasets[0].data = window.chartData.lp;
|
||||||
|
window.liveChart.data.datasets[1].data = window.chartData.leq;
|
||||||
|
window.liveChart.update('none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Control function
|
||||||
|
async function controlUnit(unitId, action) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/slm-dashboard/control/${unitId}/${action}`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.status === 'ok') {
|
||||||
|
// Reload the live view to update status
|
||||||
|
setTimeout(() => {
|
||||||
|
htmx.ajax('GET', `/api/slm-dashboard/live-view/${unitId}`, {
|
||||||
|
target: '#live-view-panel',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
} else {
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Failed to control unit: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup on page unload
|
||||||
|
window.addEventListener('beforeunload', function() {
|
||||||
|
if (window.currentWebSocket) {
|
||||||
|
window.currentWebSocket.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
8
app/ui/templates/partials/slm_live_view_error.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<!-- Error State for Live View -->
|
||||||
|
<div class="flex flex-col items-center justify-center h-[600px] text-red-500 dark:text-red-400">
|
||||||
|
<svg class="w-24 h-24 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="text-lg font-medium">Error Loading Unit</p>
|
||||||
|
<p class="text-sm mt-2 text-gray-600 dark:text-gray-400">{{ error }}</p>
|
||||||
|
</div>
|
||||||
61
app/ui/templates/partials/slm_stats.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<!-- Summary stat cards -->
|
||||||
|
<!-- Total Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Total Units</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-1">{{ total_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-blue-100 dark:bg-blue-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-blue-600 dark:text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Deployed Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Deployed</p>
|
||||||
|
<p class="text-3xl font-bold text-green-600 dark:text-green-400 mt-1">{{ deployed_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-100 dark:bg-green-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-green-600 dark:text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Now Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Active Now</p>
|
||||||
|
<p class="text-3xl font-bold text-seismo-orange mt-1">{{ active_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-orange-100 dark:bg-orange-900/30 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">Checked in last hour</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Benched Units Card -->
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 font-medium">Benched</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-500 dark:text-gray-400 mt-1">{{ benched_count }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-200 dark:bg-gray-700 p-3 rounded-lg">
|
||||||
|
<svg class="w-8 h-8 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
73
app/ui/templates/partials/slm_unit_list.html
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<!-- SLM Unit List -->
|
||||||
|
{% if units %}
|
||||||
|
{% for unit in units %}
|
||||||
|
<div class="slm-unit-item bg-gray-100 dark:bg-gray-700 rounded-lg p-4 transition-colors relative group">
|
||||||
|
<!-- Configure button (appears on hover) -->
|
||||||
|
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||||
|
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded-full p-1.5 hover:bg-gray-200 dark:hover:bg-gray-600 z-10"
|
||||||
|
title="Configure {{ unit.id }}">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="cursor-pointer" onclick="selectUnit('{{ unit.id }}')">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<span class="font-semibold">{{ unit.id }}</span>
|
||||||
|
<!-- Status indicator: green=active, yellow=recent, red=old, gray=never -->
|
||||||
|
{% if unit.slm_last_check %}
|
||||||
|
<span class="w-2 h-2 bg-green-500 rounded-full" title="Active"></span>
|
||||||
|
{% else %}
|
||||||
|
<span class="w-2 h-2 bg-gray-400 rounded-full" title="No check-in"></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-sm space-y-1">
|
||||||
|
{% if unit.slm_model %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.slm_model }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.address %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="truncate">{{ unit.address }}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if unit.deployed_with_modem_id %}
|
||||||
|
<div class="flex items-center font-mono text-xs">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.deployed_with_modem_id }}
|
||||||
|
</div>
|
||||||
|
{% elif unit.slm_host %}
|
||||||
|
<div class="flex items-center font-mono text-xs">
|
||||||
|
<svg class="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path>
|
||||||
|
</svg>
|
||||||
|
{{ unit.slm_host }}{% if unit.slm_tcp_port %}:{{ unit.slm_tcp_port }}{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<p>No sound level meters found</p>
|
||||||
|
<p class="text-sm mt-1">Add units from the Fleet Roster</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
61
app/ui/templates/partials/unknown_emitters.html
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
{% if unknown_units %}
|
||||||
|
<div class="mb-6 rounded-xl shadow-lg bg-yellow-50 dark:bg-yellow-900/20 border-2 border-yellow-400 dark:border-yellow-600 p-6">
|
||||||
|
<div class="flex items-start gap-3 mb-4">
|
||||||
|
<svg class="w-6 h-6 text-yellow-600 dark:text-yellow-400 flex-shrink-0 mt-1" 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 class="flex-1">
|
||||||
|
<h2 class="text-xl font-bold text-yellow-900 dark:text-yellow-200">Unknown Emitters Detected</h2>
|
||||||
|
<p class="text-sm text-yellow-800 dark:text-yellow-300 mt-1">
|
||||||
|
{{ unknown_units|length }} unit(s) are reporting but not in the roster. Add them to track them properly.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
{% for unit in unknown_units %}
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 flex items-center justify-between border border-yellow-300 dark:border-yellow-700">
|
||||||
|
<div class="flex items-center gap-4 flex-1">
|
||||||
|
<div class="font-mono font-bold text-lg text-gray-900 dark:text-white">
|
||||||
|
{{ unit.id }}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<span class="px-2 py-1 rounded-full
|
||||||
|
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
|
||||||
|
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
|
||||||
|
{% else %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300{% endif %}">
|
||||||
|
{{ unit.status }}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-600 dark:text-gray-400">
|
||||||
|
Last seen: {{ unit.age }}
|
||||||
|
</span>
|
||||||
|
{% if unit.fname %}
|
||||||
|
<span class="text-gray-500 dark:text-gray-500 text-xs truncate max-w-xs">
|
||||||
|
{{ unit.fname }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 flex gap-2">
|
||||||
|
<button
|
||||||
|
onclick="addUnknownUnit('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||||
|
<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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
|
</svg>
|
||||||
|
Add to Roster
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onclick="ignoreUnknownUnit('{{ unit.id }}')"
|
||||||
|
class="px-4 py-2 bg-gray-500 hover:bg-gray-600 text-white rounded-lg flex items-center gap-2 transition-colors whitespace-nowrap">
|
||||||
|
<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="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"></path>
|
||||||
|
</svg>
|
||||||
|
Ignore
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
1137
app/ui/templates/roster.html
Normal file
76
app/ui/templates/seismographs.html
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Seismographs - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Seismographs</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage and monitor seismograph units</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Auto-refresh stats every 30 seconds -->
|
||||||
|
<div hx-get="/api/seismo-dashboard/stats"
|
||||||
|
hx-trigger="load, every 30s"
|
||||||
|
hx-swap="innerHTML"
|
||||||
|
class="mb-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div class="rounded-lg shadow bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 text-sm">Loading...</p>
|
||||||
|
<p class="text-3xl font-bold text-gray-900 dark:text-white mt-2">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph List -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">All Seismographs</h2>
|
||||||
|
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="seismo-search"
|
||||||
|
placeholder="Search seismographs..."
|
||||||
|
class="w-full sm:w-64 px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="keyup changed delay:300ms"
|
||||||
|
hx-target="#seismo-units-list"
|
||||||
|
hx-include="[name='search']"
|
||||||
|
name="search"
|
||||||
|
/>
|
||||||
|
<svg class="absolute right-3 top-2.5 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Units List (loaded via HTMX) -->
|
||||||
|
<div id="seismo-units-list"
|
||||||
|
hx-get="/api/seismo-dashboard/units"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading seismographs...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Clear search input on escape key
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const searchInput = document.getElementById('seismo-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('keydown', function(e) {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
this.value = '';
|
||||||
|
htmx.trigger(this, 'keyup');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
1376
app/ui/templates/settings.html
Normal file
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 %}
|
||||||
1008
app/ui/templates/unit_detail.html
Normal file
115
create_test_db.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create a fresh test database with the new schema and some sample data.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from datetime import datetime, date, timedelta
|
||||||
|
from backend.models import Base, RosterUnit, Emitter
|
||||||
|
|
||||||
|
# Create a new test database
|
||||||
|
TEST_DB_PATH = "/tmp/sfm_test.db"
|
||||||
|
engine = create_engine(f"sqlite:///{TEST_DB_PATH}", connect_args={"check_same_thread": False})
|
||||||
|
|
||||||
|
# Drop all tables and recreate them with the new schema
|
||||||
|
Base.metadata.drop_all(bind=engine)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Create a session
|
||||||
|
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Add some test seismographs
|
||||||
|
seismo1 = RosterUnit(
|
||||||
|
id="BE9449",
|
||||||
|
device_type="seismograph",
|
||||||
|
unit_type="series3",
|
||||||
|
deployed=True,
|
||||||
|
note="Primary field unit",
|
||||||
|
project_id="PROJ-001",
|
||||||
|
location="Site A",
|
||||||
|
last_calibrated=date(2024, 1, 15),
|
||||||
|
next_calibration_due=date(2025, 1, 15),
|
||||||
|
deployed_with_modem_id="MDM001",
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
seismo2 = RosterUnit(
|
||||||
|
id="BE9450",
|
||||||
|
device_type="seismograph",
|
||||||
|
unit_type="series3",
|
||||||
|
deployed=False,
|
||||||
|
note="Benched for maintenance",
|
||||||
|
project_id="PROJ-001",
|
||||||
|
location="Warehouse",
|
||||||
|
last_calibrated=date(2023, 6, 20),
|
||||||
|
next_calibration_due=date(2024, 6, 20), # Past due
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add some test modems
|
||||||
|
modem1 = RosterUnit(
|
||||||
|
id="MDM001",
|
||||||
|
device_type="modem",
|
||||||
|
unit_type="modem",
|
||||||
|
deployed=True,
|
||||||
|
note="Paired with BE9449",
|
||||||
|
project_id="PROJ-001",
|
||||||
|
location="Site A",
|
||||||
|
ip_address="192.168.1.100",
|
||||||
|
phone_number="+1-555-0123",
|
||||||
|
hardware_model="Raven XTV",
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
modem2 = RosterUnit(
|
||||||
|
id="MDM002",
|
||||||
|
device_type="modem",
|
||||||
|
unit_type="modem",
|
||||||
|
deployed=False,
|
||||||
|
note="Spare modem",
|
||||||
|
project_id="PROJ-001",
|
||||||
|
location="Warehouse",
|
||||||
|
ip_address="192.168.1.101",
|
||||||
|
phone_number="+1-555-0124",
|
||||||
|
hardware_model="Raven XT",
|
||||||
|
last_updated=datetime.utcnow(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add test emitters (status reports)
|
||||||
|
emitter1 = Emitter(
|
||||||
|
id="BE9449",
|
||||||
|
unit_type="series3",
|
||||||
|
last_seen=datetime.utcnow() - timedelta(hours=2),
|
||||||
|
last_file="BE9449.2024.336.12.00.mseed",
|
||||||
|
status="OK",
|
||||||
|
notes="Running normally",
|
||||||
|
)
|
||||||
|
|
||||||
|
emitter2 = Emitter(
|
||||||
|
id="BE9450",
|
||||||
|
unit_type="series3",
|
||||||
|
last_seen=datetime.utcnow() - timedelta(days=30),
|
||||||
|
last_file="BE9450.2024.306.08.00.mseed",
|
||||||
|
status="Missing",
|
||||||
|
notes="No data received",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add all units
|
||||||
|
db.add_all([seismo1, seismo2, modem1, modem2, emitter1, emitter2])
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f"✓ Test database created at {TEST_DB_PATH}")
|
||||||
|
print(f"✓ Added 2 seismographs (BE9449, BE9450)")
|
||||||
|
print(f"✓ Added 2 modems (MDM001, MDM002)")
|
||||||
|
print(f"✓ Added 2 emitter status reports")
|
||||||
|
print(f"\nDatabase is ready for testing!")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error creating test database: {e}")
|
||||||
|
db.rollback()
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
10
data-dev/backups/snapshot_20251216_201738.db.meta.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"filename": "snapshot_20251216_201738.db",
|
||||||
|
"created_at": "20251216_201738",
|
||||||
|
"created_at_iso": "2025-12-16T20:17:38.638982",
|
||||||
|
"description": "Auto-backup before restore",
|
||||||
|
"size_bytes": 57344,
|
||||||
|
"size_mb": 0.05,
|
||||||
|
"original_db_size_bytes": 57344,
|
||||||
|
"type": "manual"
|
||||||
|
}
|
||||||