Compare commits
38 Commits
c1bdf17454
...
archive/0.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/_lsp/
|
||||
__marimo__/
|
||||
|
||||
# Seismo Fleet Manager
|
||||
# SQLite database files
|
||||
*.db
|
||||
*.db-journal
|
||||
data/
|
||||
|
||||
361
CHANGELOG.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to Seismo Fleet Manager will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [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"]
|
||||
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.
|
||||
553
README.md
@@ -1,2 +1,551 @@
|
||||
# seismo-fleet-manager
|
||||
Web app and backend for tracking deployed units.
|
||||
# Seismo Fleet Manager v0.4.2
|
||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||
|
||||
## 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)
|
||||
|
||||
31
backend/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()
|
||||
527
backend/main.py
Normal file
@@ -0,0 +1,527 @@
|
||||
import os
|
||||
import logging
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse, FileResponse, JSONResponse
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui, slm_dashboard, seismo_dashboard
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Read environment (development or production)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.4.2"
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
version=VERSION
|
||||
)
|
||||
|
||||
# Add validation error handler to log details
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError):
|
||||
logger.error(f"Validation error on {request.url}: {exc.errors()}")
|
||||
logger.error(f"Body: {await request.body()}")
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"detail": exc.errors()}
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
|
||||
# Setup Jinja2 templates
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Add custom context processor to inject environment variable into all templates
|
||||
@app.middleware("http")
|
||||
async def add_environment_to_context(request: Request, call_next):
|
||||
"""Middleware to add environment variable to request state"""
|
||||
request.state.environment = ENVIRONMENT
|
||||
response = await call_next(request)
|
||||
return response
|
||||
|
||||
# Override TemplateResponse to include environment and version in context
|
||||
original_template_response = templates.TemplateResponse
|
||||
def custom_template_response(name, context=None, *args, **kwargs):
|
||||
if context is None:
|
||||
context = {}
|
||||
context["environment"] = ENVIRONMENT
|
||||
context["version"] = VERSION
|
||||
return original_template_response(name, context, *args, **kwargs)
|
||||
templates.TemplateResponse = custom_template_response
|
||||
|
||||
# Include API routers
|
||||
app.include_router(roster.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(photos.router)
|
||||
app.include_router(roster_edit.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboard_tabs.router)
|
||||
app.include_router(activity.router)
|
||||
app.include_router(slmm.router)
|
||||
app.include_router(slm_ui.router)
|
||||
app.include_router(slm_dashboard.router)
|
||||
app.include_router(seismo_dashboard.router)
|
||||
|
||||
from backend.routers import settings
|
||||
app.include_router(settings.router)
|
||||
|
||||
|
||||
|
||||
# Legacy routes from the original backend
|
||||
from backend import routes as legacy_routes
|
||||
app.include_router(legacy_routes.router)
|
||||
|
||||
|
||||
# HTML page routes
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Dashboard home page"""
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/roster", response_class=HTMLResponse)
|
||||
async def roster_page(request: Request):
|
||||
"""Fleet roster page"""
|
||||
return templates.TemplateResponse("roster.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||
async def unit_detail_page(request: Request, unit_id: str):
|
||||
"""Unit detail page"""
|
||||
return templates.TemplateResponse("unit_detail.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@app.get("/settings", response_class=HTMLResponse)
|
||||
async def settings_page(request: Request):
|
||||
"""Settings page for roster management"""
|
||||
return templates.TemplateResponse("settings.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/sound-level-meters", response_class=HTMLResponse)
|
||||
async def sound_level_meters_page(request: Request):
|
||||
"""Sound Level Meters management dashboard"""
|
||||
return templates.TemplateResponse("sound_level_meters.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/seismographs", response_class=HTMLResponse)
|
||||
async def seismographs_page(request: Request):
|
||||
"""Seismographs management dashboard"""
|
||||
return templates.TemplateResponse("seismographs.html", {"request": request})
|
||||
|
||||
|
||||
# ===== PWA ROUTES =====
|
||||
|
||||
@app.get("/sw.js")
|
||||
async def service_worker():
|
||||
"""Serve service worker with proper headers for PWA"""
|
||||
return FileResponse(
|
||||
"backend/static/sw.js",
|
||||
media_type="application/javascript",
|
||||
headers={
|
||||
"Service-Worker-Allowed": "/",
|
||||
"Cache-Control": "no-cache"
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/offline-db.js")
|
||||
async def offline_db_script():
|
||||
"""Serve offline database script"""
|
||||
return FileResponse(
|
||||
"backend/static/offline-db.js",
|
||||
media_type="application/javascript",
|
||||
headers={"Cache-Control": "no-cache"}
|
||||
)
|
||||
|
||||
|
||||
# Pydantic model for sync edits
|
||||
class EditItem(BaseModel):
|
||||
id: int
|
||||
unitId: str
|
||||
changes: Dict
|
||||
timestamp: int
|
||||
|
||||
|
||||
class SyncEditsRequest(BaseModel):
|
||||
edits: List[EditItem]
|
||||
|
||||
|
||||
@app.post("/api/sync-edits")
|
||||
async def sync_edits(request: SyncEditsRequest, db: Session = Depends(get_db)):
|
||||
"""Process offline edit queue and sync to database"""
|
||||
from backend.models import RosterUnit
|
||||
|
||||
results = []
|
||||
synced_ids = []
|
||||
|
||||
for edit in request.edits:
|
||||
try:
|
||||
# Find the unit
|
||||
unit = db.query(RosterUnit).filter_by(id=edit.unitId).first()
|
||||
|
||||
if not unit:
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "error",
|
||||
"reason": f"Unit {edit.unitId} not found"
|
||||
})
|
||||
continue
|
||||
|
||||
# Apply changes
|
||||
for key, value in edit.changes.items():
|
||||
if hasattr(unit, key):
|
||||
# Handle boolean conversions
|
||||
if key in ['deployed', 'retired']:
|
||||
setattr(unit, key, value in ['true', True, 'True', '1', 1])
|
||||
else:
|
||||
setattr(unit, key, value if value != '' else None)
|
||||
|
||||
db.commit()
|
||||
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "success"
|
||||
})
|
||||
synced_ids.append(edit.id)
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
results.append({
|
||||
"id": edit.id,
|
||||
"status": "error",
|
||||
"reason": str(e)
|
||||
})
|
||||
|
||||
synced_count = len(synced_ids)
|
||||
|
||||
return JSONResponse({
|
||||
"synced": synced_count,
|
||||
"total": len(request.edits),
|
||||
"synced_ids": synced_ids,
|
||||
"results": results
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/roster-deployed", response_class=HTMLResponse)
|
||||
async def roster_deployed_partial(request: Request):
|
||||
"""Partial template for deployed units tab"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
for unit_id, unit_data in snapshot["active"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "Unknown"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": unit_data.get("deployed", False),
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||
|
||||
return templates.TemplateResponse("partials/roster_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/roster-benched", response_class=HTMLResponse)
|
||||
async def roster_benched_partial(request: Request):
|
||||
"""Partial template for benched units tab"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
for unit_id, unit_data in snapshot["benched"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "N/A"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": unit_data.get("deployed", False),
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Sort by ID
|
||||
units_list.sort(key=lambda x: x["id"])
|
||||
|
||||
return templates.TemplateResponse("partials/roster_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/roster-retired", response_class=HTMLResponse)
|
||||
async def roster_retired_partial(request: Request):
|
||||
"""Partial template for retired units tab"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
for unit_id, unit_data in snapshot["retired"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Sort by ID
|
||||
units_list.sort(key=lambda x: x["id"])
|
||||
|
||||
return templates.TemplateResponse("partials/retired_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/roster-ignored", response_class=HTMLResponse)
|
||||
async def roster_ignored_partial(request: Request, db: Session = Depends(get_db)):
|
||||
"""Partial template for ignored units tab"""
|
||||
from datetime import datetime
|
||||
|
||||
ignored = db.query(IgnoredUnit).all()
|
||||
ignored_list = []
|
||||
for unit in ignored:
|
||||
ignored_list.append({
|
||||
"id": unit.id,
|
||||
"reason": unit.reason or "",
|
||||
"ignored_at": unit.ignored_at.strftime("%Y-%m-%d %H:%M:%S") if unit.ignored_at else "Unknown"
|
||||
})
|
||||
|
||||
# Sort by ID
|
||||
ignored_list.sort(key=lambda x: x["id"])
|
||||
|
||||
return templates.TemplateResponse("partials/ignored_table.html", {
|
||||
"request": request,
|
||||
"ignored_units": ignored_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/unknown-emitters", response_class=HTMLResponse)
|
||||
async def unknown_emitters_partial(request: Request):
|
||||
"""Partial template for unknown emitters (HTMX)"""
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
unknown_list = []
|
||||
for unit_id, unit_data in snapshot.get("unknown", {}).items():
|
||||
unknown_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"fname": unit_data.get("fname", ""),
|
||||
})
|
||||
|
||||
# Sort by ID
|
||||
unknown_list.sort(key=lambda x: x["id"])
|
||||
|
||||
return templates.TemplateResponse("partials/unknown_emitters.html", {
|
||||
"request": request,
|
||||
"unknown_units": unknown_list
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/devices-all", response_class=HTMLResponse)
|
||||
async def devices_all_partial(request: Request):
|
||||
"""Unified partial template for ALL devices with comprehensive filtering support"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
|
||||
# Add deployed/active units
|
||||
for unit_id, unit_data in snapshot["active"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "Unknown"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add benched units
|
||||
for unit_id, unit_data in snapshot["benched"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data.get("status", "N/A"),
|
||||
"age": unit_data.get("age", "N/A"),
|
||||
"last_seen": unit_data.get("last", "Never"),
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add retired units
|
||||
for unit_id, unit_data in snapshot["retired"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Retired",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": True,
|
||||
"ignored": False,
|
||||
"note": unit_data.get("note", ""),
|
||||
"device_type": unit_data.get("device_type", "seismograph"),
|
||||
"address": unit_data.get("address", ""),
|
||||
"coordinates": unit_data.get("coordinates", ""),
|
||||
"project_id": unit_data.get("project_id", ""),
|
||||
"last_calibrated": unit_data.get("last_calibrated"),
|
||||
"next_calibration_due": unit_data.get("next_calibration_due"),
|
||||
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
|
||||
"ip_address": unit_data.get("ip_address"),
|
||||
"phone_number": unit_data.get("phone_number"),
|
||||
"hardware_model": unit_data.get("hardware_model"),
|
||||
})
|
||||
|
||||
# Add ignored units
|
||||
for unit_id, unit_data in snapshot.get("ignored", {}).items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": "Ignored",
|
||||
"age": "N/A",
|
||||
"last_seen": "N/A",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"ignored": True,
|
||||
"note": unit_data.get("note", unit_data.get("reason", "")),
|
||||
"device_type": unit_data.get("device_type", "unknown"),
|
||||
"address": "",
|
||||
"coordinates": "",
|
||||
"project_id": "",
|
||||
"last_calibrated": None,
|
||||
"next_calibration_due": None,
|
||||
"deployed_with_modem_id": None,
|
||||
"ip_address": None,
|
||||
"phone_number": None,
|
||||
"hardware_model": None,
|
||||
})
|
||||
|
||||
# Sort by status category, then by ID
|
||||
def sort_key(unit):
|
||||
# Priority: deployed (active) -> benched -> retired -> ignored
|
||||
if unit["deployed"]:
|
||||
return (0, unit["id"])
|
||||
elif not unit["retired"] and not unit["ignored"]:
|
||||
return (1, unit["id"])
|
||||
elif unit["retired"]:
|
||||
return (2, unit["id"])
|
||||
else:
|
||||
return (3, unit["id"])
|
||||
|
||||
units_list.sort(key=sort_key)
|
||||
|
||||
return templates.TemplateResponse("partials/devices_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": f"Seismo Fleet Manager v{VERSION}",
|
||||
"status": "running",
|
||||
"version": VERSION
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
84
backend/migrate_add_device_types.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Migration script to add device type support to the roster table.
|
||||
|
||||
This adds columns for:
|
||||
- device_type (seismograph/modem discriminator)
|
||||
- Seismograph-specific fields (calibration dates, modem pairing)
|
||||
- Modem-specific fields (IP address, phone number, hardware model)
|
||||
|
||||
Run this script once to migrate an existing database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Database path
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
def migrate_database():
|
||||
"""Add new columns to the roster table"""
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
print("The database will be created automatically when you run the application.")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if device_type column already exists
|
||||
cursor.execute("PRAGMA table_info(roster)")
|
||||
columns = [col[1] for col in cursor.fetchall()]
|
||||
|
||||
if "device_type" in columns:
|
||||
print("Migration already applied - device_type column exists")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Adding new columns to roster table...")
|
||||
|
||||
try:
|
||||
# Add device type discriminator
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN device_type TEXT DEFAULT 'seismograph'")
|
||||
print(" ✓ Added device_type column")
|
||||
|
||||
# Add seismograph-specific fields
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN last_calibrated DATE")
|
||||
print(" ✓ Added last_calibrated column")
|
||||
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN next_calibration_due DATE")
|
||||
print(" ✓ Added next_calibration_due column")
|
||||
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_modem_id TEXT")
|
||||
print(" ✓ Added deployed_with_modem_id column")
|
||||
|
||||
# Add modem-specific fields
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN ip_address TEXT")
|
||||
print(" ✓ Added ip_address column")
|
||||
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN phone_number TEXT")
|
||||
print(" ✓ Added phone_number column")
|
||||
|
||||
cursor.execute("ALTER TABLE roster ADD COLUMN hardware_model TEXT")
|
||||
print(" ✓ Added hardware_model column")
|
||||
|
||||
# Set all existing units to seismograph type
|
||||
cursor.execute("UPDATE roster SET device_type = 'seismograph' WHERE device_type IS NULL")
|
||||
print(" ✓ Set existing units to seismograph type")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nError during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
78
backend/migrate_add_slm_fields.py
Normal file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Database migration: Add sound level meter fields to roster table.
|
||||
|
||||
Adds columns for sound_level_meter device type support.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
def migrate():
|
||||
"""Add SLM fields to roster table if they don't exist."""
|
||||
|
||||
# Try multiple possible database locations
|
||||
possible_paths = [
|
||||
Path("data/seismo_fleet.db"),
|
||||
Path("data/sfm.db"),
|
||||
Path("data/seismo.db"),
|
||||
]
|
||||
|
||||
db_path = None
|
||||
for path in possible_paths:
|
||||
if path.exists():
|
||||
db_path = path
|
||||
break
|
||||
|
||||
if db_path is None:
|
||||
print(f"Database not found in any of: {[str(p) for p in possible_paths]}")
|
||||
print("Creating database with models.py will include new fields automatically.")
|
||||
return
|
||||
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if columns already exist
|
||||
cursor.execute("PRAGMA table_info(roster)")
|
||||
existing_columns = {row[1] for row in cursor.fetchall()}
|
||||
|
||||
new_columns = {
|
||||
"slm_host": "TEXT",
|
||||
"slm_tcp_port": "INTEGER",
|
||||
"slm_model": "TEXT",
|
||||
"slm_serial_number": "TEXT",
|
||||
"slm_frequency_weighting": "TEXT",
|
||||
"slm_time_weighting": "TEXT",
|
||||
"slm_measurement_range": "TEXT",
|
||||
"slm_last_check": "DATETIME",
|
||||
}
|
||||
|
||||
migrations_applied = []
|
||||
|
||||
for column_name, column_type in new_columns.items():
|
||||
if column_name not in existing_columns:
|
||||
try:
|
||||
cursor.execute(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}")
|
||||
migrations_applied.append(column_name)
|
||||
print(f"✓ Added column: {column_name} ({column_type})")
|
||||
except sqlite3.OperationalError as e:
|
||||
print(f"✗ Failed to add column {column_name}: {e}")
|
||||
else:
|
||||
print(f"○ Column already exists: {column_name}")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
if migrations_applied:
|
||||
print(f"\n✓ Migration complete! Added {len(migrations_applied)} new columns.")
|
||||
else:
|
||||
print("\n○ No migration needed - all columns already exist.")
|
||||
|
||||
print("\nSound level meter fields are now available in the roster table.")
|
||||
print("You can now set device_type='sound_level_meter' for SLM devices.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate()
|
||||
78
backend/migrate_add_unit_history.py
Normal file
@@ -0,0 +1,78 @@
|
||||
"""
|
||||
Migration script to add unit history timeline support.
|
||||
|
||||
This creates the unit_history table to track all changes to units:
|
||||
- Note changes (archived old notes, new notes)
|
||||
- Deployment status changes (deployed/benched)
|
||||
- Retired status changes
|
||||
- Other field changes
|
||||
|
||||
Run this script once to migrate an existing database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Database path
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
def migrate_database():
|
||||
"""Create the unit_history table"""
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
print("The database will be created automatically when you run the application.")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if unit_history table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='unit_history'")
|
||||
if cursor.fetchone():
|
||||
print("Migration already applied - unit_history table exists")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Creating unit_history table...")
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
CREATE TABLE unit_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
unit_id TEXT NOT NULL,
|
||||
change_type TEXT NOT NULL,
|
||||
field_name TEXT,
|
||||
old_value TEXT,
|
||||
new_value TEXT,
|
||||
changed_at TIMESTAMP NOT NULL,
|
||||
source TEXT DEFAULT 'manual',
|
||||
notes TEXT
|
||||
)
|
||||
""")
|
||||
print(" ✓ Created unit_history table")
|
||||
|
||||
# Create indexes for better query performance
|
||||
cursor.execute("CREATE INDEX idx_unit_history_unit_id ON unit_history(unit_id)")
|
||||
print(" ✓ Created index on unit_id")
|
||||
|
||||
cursor.execute("CREATE INDEX idx_unit_history_changed_at ON unit_history(changed_at)")
|
||||
print(" ✓ Created index on changed_at")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
print("Units will now track their complete history of changes.")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nError during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
80
backend/migrate_add_user_preferences.py
Normal file
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
Migration script to add user_preferences table.
|
||||
|
||||
This creates a new table for storing persistent user preferences:
|
||||
- Display settings (timezone, theme, date format)
|
||||
- Auto-refresh configuration
|
||||
- Calibration defaults
|
||||
- Status threshold customization
|
||||
|
||||
Run this script once to migrate an existing database.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
# Database path
|
||||
DB_PATH = "./data/seismo_fleet.db"
|
||||
|
||||
def migrate_database():
|
||||
"""Create user_preferences table"""
|
||||
|
||||
if not os.path.exists(DB_PATH):
|
||||
print(f"Database not found at {DB_PATH}")
|
||||
print("The database will be created automatically when you run the application.")
|
||||
return
|
||||
|
||||
print(f"Migrating database: {DB_PATH}")
|
||||
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Check if user_preferences table already exists
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'")
|
||||
table_exists = cursor.fetchone()
|
||||
|
||||
if table_exists:
|
||||
print("Migration already applied - user_preferences table exists")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
print("Creating user_preferences table...")
|
||||
|
||||
try:
|
||||
cursor.execute("""
|
||||
CREATE TABLE user_preferences (
|
||||
id INTEGER PRIMARY KEY DEFAULT 1,
|
||||
timezone TEXT DEFAULT 'America/New_York',
|
||||
theme TEXT DEFAULT 'auto',
|
||||
auto_refresh_interval INTEGER DEFAULT 10,
|
||||
date_format TEXT DEFAULT 'MM/DD/YYYY',
|
||||
table_rows_per_page INTEGER DEFAULT 25,
|
||||
calibration_interval_days INTEGER DEFAULT 365,
|
||||
calibration_warning_days INTEGER DEFAULT 30,
|
||||
status_ok_threshold_hours INTEGER DEFAULT 12,
|
||||
status_pending_threshold_hours INTEGER DEFAULT 24,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
print(" ✓ Created user_preferences table")
|
||||
|
||||
# Insert default preferences
|
||||
cursor.execute("""
|
||||
INSERT INTO user_preferences (id) VALUES (1)
|
||||
""")
|
||||
print(" ✓ Inserted default preferences")
|
||||
|
||||
conn.commit()
|
||||
print("\nMigration completed successfully!")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"\nError during migration: {e}")
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_database()
|
||||
110
backend/models.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||
from datetime import datetime
|
||||
from backend.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)
|
||||
146
backend/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 backend.database import get_db
|
||||
from backend.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
backend/routers/dashboard.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from fastapi import APIRouter, Request, Depends
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="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
backend/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 backend.database import get_db
|
||||
from backend.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}
|
||||
242
backend/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 backend.database import get_db
|
||||
from backend.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
backend/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 backend.database import get_db
|
||||
from backend.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
backend/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 backend.database import get_db
|
||||
from backend.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
backend/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 backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api/seismo-dashboard", tags=["seismo-dashboard"])
|
||||
templates = Jinja2Templates(directory="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
backend/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 backend.database import get_db
|
||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||
from backend.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)}")
|
||||
328
backend/routers/slm_dashboard.py
Normal file
@@ -0,0 +1,328 @@
|
||||
"""
|
||||
SLM Dashboard Router
|
||||
|
||||
Provides API endpoints for the Sound Level Meters dashboard page.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Request, Depends, Query
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import func
|
||||
from datetime import datetime, timedelta
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slm-dashboard", tags=["slm-dashboard"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
|
||||
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_slm_stats(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get summary statistics for SLM dashboard.
|
||||
Returns HTML partial with stat cards.
|
||||
"""
|
||||
# Query all SLMs
|
||||
all_slms = db.query(RosterUnit).filter_by(device_type="sound_level_meter").all()
|
||||
|
||||
# Count deployed vs benched
|
||||
deployed_count = sum(1 for slm in all_slms if slm.deployed and not slm.retired)
|
||||
benched_count = sum(1 for slm in all_slms if not slm.deployed and not slm.retired)
|
||||
retired_count = sum(1 for slm in all_slms if slm.retired)
|
||||
|
||||
# Count recently active (checked in last hour)
|
||||
one_hour_ago = datetime.utcnow() - timedelta(hours=1)
|
||||
active_count = sum(1 for slm in all_slms
|
||||
if slm.slm_last_check and slm.slm_last_check > one_hour_ago)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_stats.html", {
|
||||
"request": request,
|
||||
"total_count": len(all_slms),
|
||||
"deployed_count": deployed_count,
|
||||
"benched_count": benched_count,
|
||||
"active_count": active_count,
|
||||
"retired_count": retired_count
|
||||
})
|
||||
|
||||
|
||||
@router.get("/units", response_class=HTMLResponse)
|
||||
async def get_slm_units(
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
search: str = Query(None)
|
||||
):
|
||||
"""
|
||||
Get list of SLM units for the sidebar.
|
||||
Returns HTML partial with unit cards.
|
||||
"""
|
||||
query = db.query(RosterUnit).filter_by(device_type="sound_level_meter")
|
||||
|
||||
# Filter by search term if provided
|
||||
if search:
|
||||
search_term = f"%{search}%"
|
||||
query = query.filter(
|
||||
(RosterUnit.id.like(search_term)) |
|
||||
(RosterUnit.slm_model.like(search_term)) |
|
||||
(RosterUnit.address.like(search_term))
|
||||
)
|
||||
|
||||
# Only show deployed units by default
|
||||
units = query.filter_by(deployed=True, retired=False).order_by(RosterUnit.id).all()
|
||||
|
||||
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(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get live view panel for a specific SLM unit.
|
||||
Returns HTML partial with live metrics and chart.
|
||||
"""
|
||||
# Get unit from database
|
||||
unit = 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"
|
||||
})
|
||||
|
||||
# Get modem information if assigned
|
||||
modem = None
|
||||
modem_ip = None
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(id=unit.deployed_with_modem_id, device_type="modem").first()
|
||||
if modem:
|
||||
modem_ip = modem.ip_address
|
||||
else:
|
||||
logger.warning(f"SLM {unit_id} is assigned to modem {unit.deployed_with_modem_id} but modem not found")
|
||||
|
||||
# Fallback to direct slm_host if no modem assigned (backward compatibility)
|
||||
if not modem_ip and unit.slm_host:
|
||||
modem_ip = unit.slm_host
|
||||
logger.info(f"Using legacy slm_host for {unit_id}: {modem_ip}")
|
||||
|
||||
# Try to get current status from SLMM
|
||||
current_status = None
|
||||
measurement_state = None
|
||||
is_measuring = False
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
state_data = state_response.json()
|
||||
measurement_state = state_data.get("measurement_state", "Unknown")
|
||||
is_measuring = state_data.get("is_measuring", False)
|
||||
|
||||
# Get live status
|
||||
status_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/live"
|
||||
)
|
||||
if status_response.status_code == 200:
|
||||
status_data = status_response.json()
|
||||
current_status = status_data.get("data", {})
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get status for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_live_view.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"modem": modem,
|
||||
"modem_ip": modem_ip,
|
||||
"current_status": current_status,
|
||||
"measurement_state": measurement_state,
|
||||
"is_measuring": is_measuring
|
||||
})
|
||||
|
||||
|
||||
@router.post("/control/{unit_id}/{action}")
|
||||
async def control_slm(unit_id: str, action: str):
|
||||
"""
|
||||
Send control commands to SLM (start, stop, pause, resume, reset).
|
||||
Proxies to SLMM backend.
|
||||
"""
|
||||
valid_actions = ["start", "stop", "pause", "resume", "reset"]
|
||||
|
||||
if action not in valid_actions:
|
||||
return {"status": "error", "detail": f"Invalid action. Must be one of: {valid_actions}"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/{action}"
|
||||
)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json()
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to control {unit_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
@router.get("/config/{unit_id}", response_class=HTMLResponse)
|
||||
async def get_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Get configuration form for a specific SLM unit.
|
||||
Returns HTML partial with configuration form.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||
|
||||
if not unit:
|
||||
return HTMLResponse(
|
||||
content=f'<div class="text-red-500">Unit {unit_id} not found</div>',
|
||||
status_code=404
|
||||
)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_config_form.html", {
|
||||
"request": request,
|
||||
"unit": unit
|
||||
})
|
||||
|
||||
|
||||
@router.post("/config/{unit_id}")
|
||||
async def save_slm_config(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Save SLM configuration.
|
||||
Updates unit parameters in the database.
|
||||
"""
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id, device_type="sound_level_meter").first()
|
||||
|
||||
if not unit:
|
||||
return {"status": "error", "detail": f"Unit {unit_id} not found"}
|
||||
|
||||
try:
|
||||
# Get form data
|
||||
form_data = await request.form()
|
||||
|
||||
# Update SLM-specific fields
|
||||
unit.slm_model = form_data.get("slm_model") or None
|
||||
unit.slm_serial_number = form_data.get("slm_serial_number") or None
|
||||
unit.slm_frequency_weighting = form_data.get("slm_frequency_weighting") or None
|
||||
unit.slm_time_weighting = form_data.get("slm_time_weighting") or None
|
||||
unit.slm_measurement_range = form_data.get("slm_measurement_range") or None
|
||||
|
||||
# Update network configuration
|
||||
modem_id = form_data.get("deployed_with_modem_id")
|
||||
unit.deployed_with_modem_id = modem_id if modem_id else None
|
||||
|
||||
# Always update TCP and FTP ports (used regardless of modem assignment)
|
||||
unit.slm_tcp_port = int(form_data.get("slm_tcp_port")) if form_data.get("slm_tcp_port") else None
|
||||
unit.slm_ftp_port = int(form_data.get("slm_ftp_port")) if form_data.get("slm_ftp_port") else None
|
||||
|
||||
# Only update direct IP if no modem is assigned
|
||||
if not modem_id:
|
||||
unit.slm_host = form_data.get("slm_host") or None
|
||||
else:
|
||||
# Clear legacy direct IP field when modem is assigned
|
||||
unit.slm_host = None
|
||||
|
||||
db.commit()
|
||||
logger.info(f"Updated configuration for SLM {unit_id}")
|
||||
|
||||
# Sync updated configuration to SLMM cache
|
||||
logger.info(f"Syncing SLM {unit_id} config changes to SLMM cache...")
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=unit_id,
|
||||
host=unit.slm_host, # Use the updated host from Terra-View
|
||||
tcp_port=unit.slm_tcp_port,
|
||||
ftp_port=unit.slm_ftp_port,
|
||||
deployed_with_modem_id=unit.deployed_with_modem_id, # Resolve modem IP if assigned
|
||||
db=db
|
||||
)
|
||||
|
||||
if not result["success"]:
|
||||
logger.warning(f"SLMM cache sync warning for {unit_id}: {result['message']}")
|
||||
# Config still saved in Terra-View (source of truth)
|
||||
|
||||
return {"status": "success", "unit_id": unit_id}
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to save config for {unit_id}: {e}")
|
||||
return {"status": "error", "detail": str(e)}
|
||||
|
||||
|
||||
@router.get("/test-modem/{modem_id}")
|
||||
async def test_modem_connection(modem_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Test modem connectivity with a simple ping/health check.
|
||||
Returns response time and connection status.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
# Get modem from database
|
||||
modem = db.query(RosterUnit).filter_by(id=modem_id, device_type="modem").first()
|
||||
|
||||
if not modem:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} not found"}
|
||||
|
||||
if not modem.ip_address:
|
||||
return {"status": "error", "detail": f"Modem {modem_id} has no IP address configured"}
|
||||
|
||||
try:
|
||||
# Ping the modem (1 packet, 2 second timeout)
|
||||
start_time = time.time()
|
||||
result = subprocess.run(
|
||||
["ping", "-c", "1", "-W", "2", modem.ip_address],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3
|
||||
)
|
||||
response_time = int((time.time() - start_time) * 1000) # Convert to milliseconds
|
||||
|
||||
if result.returncode == 0:
|
||||
return {
|
||||
"status": "success",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"response_time": response_time,
|
||||
"message": "Modem is responding to ping"
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"detail": "Modem not responding to ping"
|
||||
}
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"ip_address": modem.ip_address,
|
||||
"detail": "Ping timeout (> 2 seconds)"
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to ping modem {modem_id}: {e}")
|
||||
return {
|
||||
"status": "error",
|
||||
"modem_id": modem_id,
|
||||
"detail": str(e)
|
||||
}
|
||||
123
backend/routers/slm_ui.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Sound Level Meter UI Router
|
||||
|
||||
Provides endpoints for SLM dashboard cards, detail pages, and real-time data.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/slm", tags=["slm-ui"])
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://172.19.0.1:8100")
|
||||
|
||||
|
||||
@router.get("/{unit_id}", response_class=HTMLResponse)
|
||||
async def slm_detail_page(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Sound level meter detail page with controls."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
return templates.TemplateResponse("slm_detail.html", {
|
||||
"request": request,
|
||||
"unit": unit,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@router.get("/api/{unit_id}/summary")
|
||||
async def get_slm_summary(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Get SLM summary data for dashboard card."""
|
||||
|
||||
# Get roster unit
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Try to get live status from SLMM
|
||||
status_data = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/api/nl43/{unit_id}/status")
|
||||
if response.status_code == 200:
|
||||
status_data = response.json().get("data")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM status for {unit_id}: {e}")
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": unit.deployed,
|
||||
"model": unit.slm_model or "NL-43",
|
||||
"location": unit.address or unit.location,
|
||||
"coordinates": unit.coordinates,
|
||||
"note": unit.note,
|
||||
"status": status_data,
|
||||
"last_check": unit.slm_last_check.isoformat() if unit.slm_last_check else None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/card", response_class=HTMLResponse)
|
||||
async def slm_dashboard_card(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM dashboard card partial."""
|
||||
|
||||
summary = await get_slm_summary(unit_id, db)
|
||||
|
||||
return templates.TemplateResponse("partials/slm_card.html", {
|
||||
"request": request,
|
||||
"slm": summary
|
||||
})
|
||||
|
||||
|
||||
@router.get("/partials/{unit_id}/controls", response_class=HTMLResponse)
|
||||
async def slm_controls_partial(request: Request, unit_id: str, db: Session = Depends(get_db)):
|
||||
"""Render SLM control panel partial."""
|
||||
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if not unit or unit.device_type != "sound_level_meter":
|
||||
raise HTTPException(status_code=404, detail="Sound level meter not found")
|
||||
|
||||
# Get current status from SLMM
|
||||
measurement_state = None
|
||||
battery_level = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=3.0) as client:
|
||||
# Get measurement state
|
||||
state_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/measurement-state"
|
||||
)
|
||||
if state_response.status_code == 200:
|
||||
measurement_state = state_response.json().get("measurement_state")
|
||||
|
||||
# Get battery level
|
||||
battery_response = await client.get(
|
||||
f"{SLMM_BASE_URL}/api/nl43/{unit_id}/battery"
|
||||
)
|
||||
if battery_response.status_code == 200:
|
||||
battery_level = battery_response.json().get("battery_level")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to get SLM control data for {unit_id}: {e}")
|
||||
|
||||
return templates.TemplateResponse("partials/slm_controls.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id,
|
||||
"unit": unit,
|
||||
"measurement_state": measurement_state,
|
||||
"battery_level": battery_level,
|
||||
"is_measuring": measurement_state == "Start"
|
||||
})
|
||||
301
backend/routers/slmm.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""
|
||||
SLMM (Sound Level Meter Manager) Proxy Router
|
||||
|
||||
Proxies requests from SFM to the standalone SLMM backend service.
|
||||
SLMM runs on port 8100 and handles NL43/NL53 sound level meter communication.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import StreamingResponse
|
||||
import httpx
|
||||
import websockets
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/slmm", tags=["slmm"])
|
||||
|
||||
# SLMM backend URL - configurable via environment variable
|
||||
SLMM_BASE_URL = os.getenv("SLMM_BASE_URL", "http://localhost:8100")
|
||||
# WebSocket URL derived from HTTP URL
|
||||
SLMM_WS_BASE_URL = SLMM_BASE_URL.replace("http://", "ws://").replace("https://", "wss://")
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def check_slmm_health():
|
||||
"""
|
||||
Check if the SLMM backend service is reachable and healthy.
|
||||
"""
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
response = await client.get(f"{SLMM_BASE_URL}/health")
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
return {
|
||||
"status": "ok",
|
||||
"slmm_status": "connected",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"slmm_version": data.get("version", "unknown"),
|
||||
"slmm_response": data
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "degraded",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": f"SLMM returned status {response.status_code}"
|
||||
}
|
||||
|
||||
except httpx.ConnectError:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "unreachable",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": "Cannot connect to SLMM backend. Is it running?"
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"status": "error",
|
||||
"slmm_status": "error",
|
||||
"slmm_url": SLMM_BASE_URL,
|
||||
"detail": str(e)
|
||||
}
|
||||
|
||||
|
||||
# WebSocket routes MUST come before the catch-all route
|
||||
@router.websocket("/{unit_id}/stream")
|
||||
async def proxy_websocket_stream(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /stream endpoint.
|
||||
|
||||
This allows real-time streaming of measurement data from NL43 devices
|
||||
through the SFM unified interface.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id}")
|
||||
|
||||
# Build target WebSocket URL
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id}")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend: {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client: {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id}: {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id}")
|
||||
|
||||
|
||||
@router.websocket("/{unit_id}/live")
|
||||
async def proxy_websocket_live(websocket: WebSocket, unit_id: str):
|
||||
"""
|
||||
Proxy WebSocket connections to SLMM's /live endpoint.
|
||||
|
||||
Alternative WebSocket endpoint that may be used by some frontend components.
|
||||
"""
|
||||
await websocket.accept()
|
||||
logger.info(f"WebSocket connection accepted for SLMM unit {unit_id} (live endpoint)")
|
||||
|
||||
# Build target WebSocket URL - try /stream endpoint as SLMM uses that for WebSocket
|
||||
target_ws_url = f"{SLMM_WS_BASE_URL}/api/nl43/{unit_id}/stream"
|
||||
logger.info(f"Connecting to SLMM WebSocket: {target_ws_url}")
|
||||
|
||||
backend_ws = None
|
||||
|
||||
try:
|
||||
# Connect to SLMM backend WebSocket
|
||||
backend_ws = await websockets.connect(target_ws_url)
|
||||
logger.info(f"Connected to SLMM backend WebSocket for {unit_id} (live endpoint)")
|
||||
|
||||
# Create tasks for bidirectional communication
|
||||
async def forward_to_backend():
|
||||
"""Forward messages from client to SLMM backend"""
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_text()
|
||||
await backend_ws.send(data)
|
||||
except WebSocketDisconnect:
|
||||
logger.info(f"Client WebSocket disconnected for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to backend (live): {e}")
|
||||
|
||||
async def forward_to_client():
|
||||
"""Forward messages from SLMM backend to client"""
|
||||
try:
|
||||
async for message in backend_ws:
|
||||
await websocket.send_text(message)
|
||||
except websockets.exceptions.ConnectionClosed:
|
||||
logger.info(f"Backend WebSocket closed for {unit_id} (live)")
|
||||
except Exception as e:
|
||||
logger.error(f"Error forwarding to client (live): {e}")
|
||||
|
||||
# Run both forwarding tasks concurrently
|
||||
await asyncio.gather(
|
||||
forward_to_backend(),
|
||||
forward_to_client(),
|
||||
return_exceptions=True
|
||||
)
|
||||
|
||||
except websockets.exceptions.WebSocketException as e:
|
||||
logger.error(f"WebSocket error connecting to SLMM backend (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Failed to connect to SLMM backend",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error in WebSocket proxy for {unit_id} (live): {e}")
|
||||
try:
|
||||
await websocket.send_json({
|
||||
"error": "Internal server error",
|
||||
"detail": str(e)
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# Clean up connections
|
||||
if backend_ws:
|
||||
try:
|
||||
await backend_ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await websocket.close()
|
||||
except Exception:
|
||||
pass
|
||||
logger.info(f"WebSocket proxy closed for {unit_id} (live)")
|
||||
|
||||
|
||||
# HTTP catch-all route MUST come after specific routes (including WebSocket routes)
|
||||
@router.api_route("/{path:path}", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
async def proxy_to_slmm(path: str, request: Request):
|
||||
"""
|
||||
Proxy all requests to the SLMM backend service.
|
||||
|
||||
This allows SFM to act as a unified frontend for all device types,
|
||||
while SLMM remains a standalone backend service.
|
||||
"""
|
||||
# Build target URL
|
||||
target_url = f"{SLMM_BASE_URL}/api/nl43/{path}"
|
||||
|
||||
# Get query parameters
|
||||
query_params = dict(request.query_params)
|
||||
|
||||
# Get request body if present
|
||||
body = None
|
||||
if request.method in ["POST", "PUT", "PATCH"]:
|
||||
try:
|
||||
body = await request.body()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read request body: {e}")
|
||||
body = None
|
||||
|
||||
# Get headers (exclude host and other proxy-specific headers)
|
||||
headers = dict(request.headers)
|
||||
headers_to_exclude = ["host", "content-length", "transfer-encoding", "connection"]
|
||||
proxy_headers = {k: v for k, v in headers.items() if k.lower() not in headers_to_exclude}
|
||||
|
||||
logger.info(f"Proxying {request.method} request to SLMM: {target_url}")
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
# Forward the request to SLMM
|
||||
response = await client.request(
|
||||
method=request.method,
|
||||
url=target_url,
|
||||
params=query_params,
|
||||
headers=proxy_headers,
|
||||
content=body
|
||||
)
|
||||
|
||||
# Return the response from SLMM
|
||||
return Response(
|
||||
content=response.content,
|
||||
status_code=response.status_code,
|
||||
headers=dict(response.headers),
|
||||
media_type=response.headers.get("content-type")
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
logger.error(f"Failed to connect to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail=f"SLMM backend service unavailable. Is SLMM running on {SLMM_BASE_URL}?"
|
||||
)
|
||||
except httpx.TimeoutException:
|
||||
logger.error(f"Timeout connecting to SLMM backend at {SLMM_BASE_URL}")
|
||||
raise HTTPException(
|
||||
status_code=504,
|
||||
detail="SLMM backend timeout"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error proxying to SLMM: {e}")
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to proxy request to SLMM: {str(e)}"
|
||||
)
|
||||
44
backend/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 backend.database import get_db
|
||||
from backend.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
backend/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 backend.database import get_db
|
||||
from backend.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
|
||||
}
|
||||
145
backend/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 backend.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
backend/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
backend/services/snapshot.py
Normal file
@@ -0,0 +1,191 @@
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from backend.database import get_db_session
|
||||
from backend.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 backend.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()
|
||||
78
backend/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
backend/static/icons/icon-128.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
4
backend/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
backend/static/icons/icon-144.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
backend/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
backend/static/icons/icon-152.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
4
backend/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
backend/static/icons/icon-192.png
Normal file
|
After Width: | Height: | Size: 2.9 KiB |
4
backend/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
backend/static/icons/icon-384.png
Normal file
|
After Width: | Height: | Size: 5.8 KiB |
4
backend/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
backend/static/icons/icon-512.png
Normal file
|
After Width: | Height: | Size: 7.8 KiB |
4
backend/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
backend/static/icons/icon-72.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
4
backend/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
backend/static/icons/icon-96.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
4
backend/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
backend/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
backend/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
backend/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
backend/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
backend/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
backend/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');
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"filename": "snapshot_uploaded_20251216_201732.db",
|
||||
"created_at": "20251216_201732",
|
||||
"created_at_iso": "2025-12-16T20:17:32.574205",
|
||||
"description": "Uploaded: snapshot_20251216_200259.db",
|
||||
"size_bytes": 77824,
|
||||
"size_mb": 0.07,
|
||||
"type": "uploaded"
|
||||
}
|
||||
71
docker-compose.yml
Normal file
@@ -0,0 +1,71 @@
|
||||
services:
|
||||
|
||||
# --- TERRA-VIEW PRODUCTION ---
|
||||
terra-view-prod:
|
||||
build: .
|
||||
container_name: terra-view
|
||||
ports:
|
||||
- "8001:8001"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=production
|
||||
- SLMM_BASE_URL=http://slmm:8100
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# --- TERRA-VIEW DEVELOPMENT ---
|
||||
terra-view-dev:
|
||||
build: .
|
||||
container_name: terra-view-dev
|
||||
ports:
|
||||
- "1001:8001"
|
||||
volumes:
|
||||
- ./data-dev:/app/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- ENVIRONMENT=development
|
||||
- SLMM_BASE_URL=http://slmm:8100
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- slmm
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# --- SLMM (Sound Level Meter Manager) ---
|
||||
slmm:
|
||||
build:
|
||||
context: ../../slmm
|
||||
dockerfile: Dockerfile
|
||||
container_name: slmm
|
||||
ports:
|
||||
- "8100:8100"
|
||||
volumes:
|
||||
- ../../slmm/data:/app/data
|
||||
environment:
|
||||
- PYTHONUNBUFFERED=1
|
||||
- PORT=8100
|
||||
- CORS_ORIGINS=*
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8100/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
data:
|
||||
data-dev:
|
||||
477
docs/DATABASE_MANAGEMENT.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# Database Management Guide
|
||||
|
||||
This guide covers the comprehensive database management features available in the Seismo Fleet Manager, including manual snapshots, restoration, remote cloning, and automatic backups.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Manual Database Snapshots](#manual-database-snapshots)
|
||||
2. [Restore from Snapshot](#restore-from-snapshot)
|
||||
3. [Download and Upload Snapshots](#download-and-upload-snapshots)
|
||||
4. [Clone Database to Dev Server](#clone-database-to-dev-server)
|
||||
5. [Automatic Backup Service](#automatic-backup-service)
|
||||
6. [API Reference](#api-reference)
|
||||
|
||||
---
|
||||
|
||||
## Manual Database Snapshots
|
||||
|
||||
### Creating a Snapshot via UI
|
||||
|
||||
1. Navigate to **Settings** → **Danger Zone** tab
|
||||
2. Scroll to the **Database Management** section
|
||||
3. Click **"Create Snapshot"**
|
||||
4. Optionally enter a description
|
||||
5. The snapshot will be created and appear in the "Available Snapshots" list
|
||||
|
||||
### Creating a Snapshot via API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/settings/database/snapshot \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"description": "Pre-deployment backup"}'
|
||||
```
|
||||
|
||||
### What Happens
|
||||
|
||||
- A full copy of the SQLite database is created using the SQLite backup API
|
||||
- The snapshot is stored in `./data/backups/` directory
|
||||
- A metadata JSON file is created alongside the snapshot
|
||||
- No downtime or interruption to the running application
|
||||
|
||||
### Snapshot Files
|
||||
|
||||
Snapshots are stored as:
|
||||
- **Database file**: `snapshot_YYYYMMDD_HHMMSS.db`
|
||||
- **Metadata file**: `snapshot_YYYYMMDD_HHMMSS.db.meta.json`
|
||||
|
||||
Example:
|
||||
```
|
||||
data/backups/
|
||||
├── snapshot_20250101_143022.db
|
||||
├── snapshot_20250101_143022.db.meta.json
|
||||
├── snapshot_20250102_080000.db
|
||||
└── snapshot_20250102_080000.db.meta.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Restore from Snapshot
|
||||
|
||||
### Restoring via UI
|
||||
|
||||
1. Navigate to **Settings** → **Danger Zone** tab
|
||||
2. In the **Available Snapshots** section, find the snapshot you want to restore
|
||||
3. Click the **restore icon** (circular arrow) next to the snapshot
|
||||
4. Confirm the restoration warning
|
||||
5. A safety backup of the current database is automatically created
|
||||
6. The database is replaced with the snapshot
|
||||
7. The page reloads automatically
|
||||
|
||||
### Restoring via API
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/settings/database/restore \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"filename": "snapshot_20250101_143022.db",
|
||||
"create_backup": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Important Notes
|
||||
|
||||
- **Always creates a safety backup** before restoring (unless explicitly disabled)
|
||||
- **Application reload required** - Users should refresh their browsers
|
||||
- **Atomic operation** - The entire database is replaced at once
|
||||
- **Cannot be undone** - But you'll have the safety backup
|
||||
|
||||
---
|
||||
|
||||
## Download and Upload Snapshots
|
||||
|
||||
### Download a Snapshot
|
||||
|
||||
**Via UI**: Click the download icon next to any snapshot in the list
|
||||
|
||||
**Via Browser**:
|
||||
```
|
||||
http://localhost:8000/api/settings/database/snapshot/snapshot_20250101_143022.db
|
||||
```
|
||||
|
||||
**Via Command Line**:
|
||||
```bash
|
||||
curl -o backup.db http://localhost:8000/api/settings/database/snapshot/snapshot_20250101_143022.db
|
||||
```
|
||||
|
||||
### Upload a Snapshot
|
||||
|
||||
**Via UI**:
|
||||
1. Navigate to **Settings** → **Danger Zone** tab
|
||||
2. Find the **Upload Snapshot** section
|
||||
3. Click **"Choose File"** and select a `.db` file
|
||||
4. Click **"Upload Snapshot"**
|
||||
|
||||
**Via Command Line**:
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/api/settings/database/upload-snapshot \
|
||||
-F "file=@/path/to/your/backup.db"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Clone Database to Dev Server
|
||||
|
||||
The clone tool allows you to copy the production database to a remote development server over the network.
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Remote dev server must have the same Seismo Fleet Manager installation
|
||||
- Network connectivity between production and dev servers
|
||||
- Python 3 and `requests` library installed
|
||||
|
||||
### Basic Usage
|
||||
|
||||
```bash
|
||||
# Clone current database to dev server
|
||||
python3 scripts/clone_db_to_dev.py --url https://dev.example.com
|
||||
|
||||
# Clone using existing snapshot
|
||||
python3 scripts/clone_db_to_dev.py \
|
||||
--url https://dev.example.com \
|
||||
--snapshot snapshot_20250101_143022.db
|
||||
|
||||
# Clone with authentication token
|
||||
python3 scripts/clone_db_to_dev.py \
|
||||
--url https://dev.example.com \
|
||||
--token YOUR_AUTH_TOKEN
|
||||
```
|
||||
|
||||
### What Happens
|
||||
|
||||
1. Creates a snapshot of the production database (or uses existing one)
|
||||
2. Uploads the snapshot to the remote dev server
|
||||
3. Automatically restores the snapshot on the dev server
|
||||
4. Creates a safety backup on the dev server before restoring
|
||||
|
||||
### Remote Server Setup
|
||||
|
||||
The remote dev server needs no special setup - it just needs to be running the same Seismo Fleet Manager application with the database management endpoints enabled.
|
||||
|
||||
### Use Cases
|
||||
|
||||
- **Testing**: Test changes against production data in a dev environment
|
||||
- **Debugging**: Investigate production issues with real data safely
|
||||
- **Training**: Provide realistic data for user training
|
||||
- **Development**: Build new features with realistic data
|
||||
|
||||
---
|
||||
|
||||
## Automatic Backup Service
|
||||
|
||||
The automatic backup service runs scheduled backups in the background and manages backup retention.
|
||||
|
||||
### Configuration
|
||||
|
||||
The backup scheduler can be configured programmatically or via environment variables.
|
||||
|
||||
**Programmatic Configuration**:
|
||||
|
||||
```python
|
||||
from backend.services.backup_scheduler import get_backup_scheduler
|
||||
|
||||
scheduler = get_backup_scheduler()
|
||||
scheduler.configure(
|
||||
interval_hours=24, # Backup every 24 hours
|
||||
keep_count=10, # Keep last 10 backups
|
||||
enabled=True # Enable automatic backups
|
||||
)
|
||||
scheduler.start()
|
||||
```
|
||||
|
||||
**Environment Variables** (add to your `.env` or deployment config):
|
||||
|
||||
```bash
|
||||
AUTO_BACKUP_ENABLED=true
|
||||
AUTO_BACKUP_INTERVAL_HOURS=24
|
||||
AUTO_BACKUP_KEEP_COUNT=10
|
||||
```
|
||||
|
||||
### Integration with Application Startup
|
||||
|
||||
Add to `backend/main.py`:
|
||||
|
||||
```python
|
||||
from backend.services.backup_scheduler import get_backup_scheduler
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup_event():
|
||||
# Start automatic backup scheduler
|
||||
scheduler = get_backup_scheduler()
|
||||
scheduler.configure(
|
||||
interval_hours=24, # Daily backups
|
||||
keep_count=10, # Keep 10 most recent
|
||||
enabled=True
|
||||
)
|
||||
scheduler.start()
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown_event():
|
||||
# Stop backup scheduler gracefully
|
||||
scheduler = get_backup_scheduler()
|
||||
scheduler.stop()
|
||||
```
|
||||
|
||||
### Manual Control
|
||||
|
||||
```python
|
||||
from backend.services.backup_scheduler import get_backup_scheduler
|
||||
|
||||
scheduler = get_backup_scheduler()
|
||||
|
||||
# Get current status
|
||||
status = scheduler.get_status()
|
||||
print(status)
|
||||
# {'enabled': True, 'running': True, 'interval_hours': 24, 'keep_count': 10, 'next_run': '2025-01-02T14:00:00'}
|
||||
|
||||
# Create backup immediately
|
||||
scheduler.create_automatic_backup()
|
||||
|
||||
# Stop scheduler
|
||||
scheduler.stop()
|
||||
|
||||
# Start scheduler
|
||||
scheduler.start()
|
||||
```
|
||||
|
||||
### Backup Retention
|
||||
|
||||
The scheduler automatically deletes old backups based on the `keep_count` setting. For example, if `keep_count=10`, only the 10 most recent backups are kept, and older ones are automatically deleted.
|
||||
|
||||
---
|
||||
|
||||
## API Reference
|
||||
|
||||
### Database Statistics
|
||||
|
||||
```http
|
||||
GET /api/settings/database/stats
|
||||
```
|
||||
|
||||
Returns database size, row counts, and last modified time.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"database_path": "./data/seismo_fleet.db",
|
||||
"size_bytes": 1048576,
|
||||
"size_mb": 1.0,
|
||||
"total_rows": 1250,
|
||||
"tables": {
|
||||
"roster": 450,
|
||||
"emitters": 600,
|
||||
"ignored_units": 50,
|
||||
"unit_history": 150
|
||||
},
|
||||
"last_modified": "2025-01-01T14:30:22"
|
||||
}
|
||||
```
|
||||
|
||||
### Create Snapshot
|
||||
|
||||
```http
|
||||
POST /api/settings/database/snapshot
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"description": "Optional description"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"message": "Snapshot created successfully",
|
||||
"snapshot": {
|
||||
"filename": "snapshot_20250101_143022.db",
|
||||
"created_at": "20250101_143022",
|
||||
"created_at_iso": "2025-01-01T14:30:22",
|
||||
"description": "Optional description",
|
||||
"size_bytes": 1048576,
|
||||
"size_mb": 1.0,
|
||||
"type": "manual"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### List Snapshots
|
||||
|
||||
```http
|
||||
GET /api/settings/database/snapshots
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"snapshots": [
|
||||
{
|
||||
"filename": "snapshot_20250101_143022.db",
|
||||
"created_at": "20250101_143022",
|
||||
"created_at_iso": "2025-01-01T14:30:22",
|
||||
"description": "Manual backup",
|
||||
"size_mb": 1.0,
|
||||
"type": "manual"
|
||||
}
|
||||
],
|
||||
"count": 1
|
||||
}
|
||||
```
|
||||
|
||||
### Download Snapshot
|
||||
|
||||
```http
|
||||
GET /api/settings/database/snapshot/{filename}
|
||||
```
|
||||
|
||||
Returns the snapshot file as a download.
|
||||
|
||||
### Delete Snapshot
|
||||
|
||||
```http
|
||||
DELETE /api/settings/database/snapshot/{filename}
|
||||
```
|
||||
|
||||
### Restore Database
|
||||
|
||||
```http
|
||||
POST /api/settings/database/restore
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"filename": "snapshot_20250101_143022.db",
|
||||
"create_backup": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"message": "Database restored successfully",
|
||||
"restored_from": "snapshot_20250101_143022.db",
|
||||
"restored_at": "2025-01-01T15:00:00",
|
||||
"backup_created": "snapshot_20250101_150000.db"
|
||||
}
|
||||
```
|
||||
|
||||
### Upload Snapshot
|
||||
|
||||
```http
|
||||
POST /api/settings/database/upload-snapshot
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <binary data>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Regular Backups
|
||||
|
||||
- **Enable automatic backups** with a 24-hour interval
|
||||
- **Keep at least 7-10 backups** for historical coverage
|
||||
- **Create manual snapshots** before major changes
|
||||
|
||||
### 2. Before Major Operations
|
||||
|
||||
Always create a snapshot before:
|
||||
- Software upgrades
|
||||
- Bulk data imports
|
||||
- Database schema changes
|
||||
- Testing destructive operations
|
||||
|
||||
### 3. Testing Restores
|
||||
|
||||
Periodically test your restore process:
|
||||
1. Download a snapshot
|
||||
2. Test restoration on a dev environment
|
||||
3. Verify data integrity
|
||||
|
||||
### 4. Off-Site Backups
|
||||
|
||||
For production systems:
|
||||
- **Download snapshots** to external storage regularly
|
||||
- Use the clone tool to **sync to remote servers**
|
||||
- Store backups in **multiple geographic locations**
|
||||
|
||||
### 5. Snapshot Management
|
||||
|
||||
- Delete old snapshots when no longer needed
|
||||
- Use descriptive names/descriptions for manual snapshots
|
||||
- Keep pre-deployment snapshots separate
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Snapshot Creation Fails
|
||||
|
||||
**Problem**: "Database is locked" error
|
||||
|
||||
**Solution**: The database is being written to. Wait a moment and try again. The SQLite backup API handles most locking automatically.
|
||||
|
||||
### Restore Doesn't Complete
|
||||
|
||||
**Problem**: Restore appears to hang
|
||||
|
||||
**Solution**:
|
||||
- Check server logs for errors
|
||||
- Ensure sufficient disk space
|
||||
- Verify the snapshot file isn't corrupted
|
||||
|
||||
### Upload Fails on Dev Server
|
||||
|
||||
**Problem**: "Permission denied" or "File too large"
|
||||
|
||||
**Solutions**:
|
||||
- Check file upload size limits in your web server config (nginx/apache)
|
||||
- Verify write permissions on `./data/backups/` directory
|
||||
- Ensure sufficient disk space
|
||||
|
||||
### Automatic Backups Not Running
|
||||
|
||||
**Problem**: No automatic backups being created
|
||||
|
||||
**Solutions**:
|
||||
1. Check if scheduler is enabled: `scheduler.get_status()`
|
||||
2. Check application logs for scheduler errors
|
||||
3. Ensure `schedule` library is installed: `pip install schedule`
|
||||
4. Verify scheduler was started in application startup
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
1. **Access Control**: Restrict access to the Settings → Danger Zone to administrators only
|
||||
2. **Backup Storage**: Store backups in a secure location with proper permissions
|
||||
3. **Remote Cloning**: Use authentication tokens when cloning to remote servers
|
||||
4. **Data Sensitivity**: Remember that snapshots contain all database data - treat them with the same security as the live database
|
||||
|
||||
---
|
||||
|
||||
## File Locations
|
||||
|
||||
- **Database**: `./data/seismo_fleet.db`
|
||||
- **Backups Directory**: `./data/backups/`
|
||||
- **Clone Script**: `./scripts/clone_db_to_dev.py`
|
||||
- **Backup Service**: `./backend/services/database_backup.py`
|
||||
- **Scheduler Service**: `./backend/services/backup_scheduler.py`
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
1. Check application logs in `./logs/`
|
||||
2. Review this documentation
|
||||
3. Test with a small database first
|
||||
4. Contact your system administrator
|
||||
276
docs/DEVICE_TYPE_DASHBOARDS.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Device Type Dashboards
|
||||
|
||||
This document describes the separate dashboard system for different device types in SFM.
|
||||
|
||||
## Overview
|
||||
|
||||
SFM now has dedicated dashboards for each device type:
|
||||
- **Main Dashboard** (`/`) - Combined overview of all devices
|
||||
- **Seismographs Dashboard** (`/seismographs`) - Seismograph-specific view
|
||||
- **Sound Level Meters Dashboard** (`/sound-level-meters`) - SLM-specific view
|
||||
- **Fleet Roster** (`/roster`) - All devices with filtering and management
|
||||
|
||||
## Architecture
|
||||
|
||||
### 1. Main Dashboard
|
||||
|
||||
**Route**: `/`
|
||||
**Template**: [templates/dashboard.html](../templates/dashboard.html)
|
||||
**Purpose**: Combined overview showing all device types
|
||||
|
||||
**Features**:
|
||||
- Fleet summary card now includes device type breakdown
|
||||
- Shows count of seismographs and SLMs separately
|
||||
- Links to dedicated dashboards for each device type
|
||||
- Shared components: map, alerts, recent photos, fleet status
|
||||
|
||||
**Device Type Counts**:
|
||||
The dashboard calculates device type counts in JavaScript from the snapshot data:
|
||||
|
||||
```javascript
|
||||
// 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++;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Seismographs Dashboard
|
||||
|
||||
**Route**: `/seismographs`
|
||||
**Template**: [templates/seismographs.html](../templates/seismographs.html)
|
||||
**Router**: [backend/routers/seismo_dashboard.py](../backend/routers/seismo_dashboard.py)
|
||||
|
||||
**API Endpoints**:
|
||||
- `GET /api/seismo-dashboard/stats` - Statistics summary (HTML partial)
|
||||
- `GET /api/seismo-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
||||
|
||||
**Features**:
|
||||
- Statistics cards (total, deployed, benched, with/without modem)
|
||||
- Searchable unit list with real-time filtering
|
||||
- Shows modem assignments
|
||||
- Links to individual unit detail pages
|
||||
|
||||
**Stats Calculation** ([backend/routers/seismo_dashboard.py:18](../backend/routers/seismo_dashboard.py#L18)):
|
||||
|
||||
```python
|
||||
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)
|
||||
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
|
||||
```
|
||||
|
||||
### 3. Sound Level Meters Dashboard
|
||||
|
||||
**Route**: `/sound-level-meters`
|
||||
**Template**: [templates/sound_level_meters.html](../templates/sound_level_meters.html)
|
||||
**Router**: [backend/routers/slm_dashboard.py](../backend/routers/slm_dashboard.py)
|
||||
|
||||
**API Endpoints**:
|
||||
- `GET /api/slm-dashboard/stats` - Statistics summary (HTML partial)
|
||||
- `GET /api/slm-dashboard/units?search=<query>` - Unit list with search (HTML partial)
|
||||
- `GET /api/slm-dashboard/live-view/{unit_id}` - Live view panel (HTML partial)
|
||||
|
||||
**Features**:
|
||||
- Statistics cards (total, deployed, benched, measuring)
|
||||
- Searchable unit list
|
||||
- Live view panel with real-time measurement data
|
||||
- WebSocket integration for DRD streaming
|
||||
- Shows modem assignments and IP resolution
|
||||
|
||||
See [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) for detailed SLM dashboard documentation.
|
||||
|
||||
## Navigation
|
||||
|
||||
The sidebar navigation ([templates/base.html:116-128](../templates/base.html#L116-L128)) includes links to both dashboards:
|
||||
|
||||
```html
|
||||
<a href="/seismographs">
|
||||
<svg>...</svg>
|
||||
Seismographs
|
||||
</a>
|
||||
|
||||
<a href="/sound-level-meters">
|
||||
<svg>...</svg>
|
||||
Sound Level Meters
|
||||
</a>
|
||||
```
|
||||
|
||||
Active page highlighting is automatic based on `request.url.path`.
|
||||
|
||||
## Database Queries
|
||||
|
||||
All dashboards filter by device type using SQLAlchemy:
|
||||
|
||||
### Seismographs Query
|
||||
```python
|
||||
seismos = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
).all()
|
||||
```
|
||||
|
||||
### Sound Level Meters Query
|
||||
```python
|
||||
slms = db.query(RosterUnit).filter_by(
|
||||
device_type="sound_level_meter",
|
||||
retired=False
|
||||
).all()
|
||||
```
|
||||
|
||||
### With Search Filter
|
||||
```python
|
||||
query = db.query(RosterUnit).filter_by(
|
||||
device_type="seismograph",
|
||||
retired=False
|
||||
)
|
||||
|
||||
if search:
|
||||
query = query.filter(
|
||||
(RosterUnit.id.ilike(f"%{search}%")) |
|
||||
(RosterUnit.note.ilike(f"%{search}%")) |
|
||||
(RosterUnit.address.ilike(f"%{search}%"))
|
||||
)
|
||||
```
|
||||
|
||||
## UI Components
|
||||
|
||||
### Stats Cards (Partials)
|
||||
|
||||
**Seismograph Stats**: [templates/partials/seismo_stats.html](../templates/partials/seismo_stats.html)
|
||||
- Total Seismographs
|
||||
- Deployed
|
||||
- Benched
|
||||
- With Modem (showing X / Y deployed)
|
||||
|
||||
**SLM Stats**: [templates/partials/slm_stats.html](../templates/partials/slm_stats.html)
|
||||
- Total SLMs
|
||||
- Deployed
|
||||
- Benched
|
||||
- Currently Measuring (from live status)
|
||||
|
||||
### Unit Lists (Partials)
|
||||
|
||||
**Seismograph List**: [templates/partials/seismo_unit_list.html](../templates/partials/seismo_unit_list.html)
|
||||
|
||||
Table columns:
|
||||
- Unit ID (link to detail page)
|
||||
- Status (Deployed/Benched badge)
|
||||
- Modem (link to modem unit)
|
||||
- Location (address or coordinates)
|
||||
- Notes
|
||||
- Actions (View Details link)
|
||||
|
||||
**SLM List**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
||||
|
||||
Table columns:
|
||||
- Unit ID (link to detail page)
|
||||
- Model (NL-43, NL-53)
|
||||
- Status (Deployed/Benched badge)
|
||||
- Modem (link to modem unit)
|
||||
- Location
|
||||
- Actions (View Details, Live View)
|
||||
|
||||
## HTMX Integration
|
||||
|
||||
Both dashboards use HTMX for dynamic updates:
|
||||
|
||||
### Auto-refresh Stats
|
||||
```html
|
||||
<div hx-get="/api/seismo-dashboard/stats"
|
||||
hx-trigger="load, every 30s"
|
||||
hx-swap="innerHTML">
|
||||
```
|
||||
|
||||
### Search with Debouncing
|
||||
```html
|
||||
<input type="text"
|
||||
hx-get="/api/seismo-dashboard/units"
|
||||
hx-trigger="keyup changed delay:300ms"
|
||||
hx-target="#seismo-units-list"
|
||||
name="search" />
|
||||
```
|
||||
|
||||
## Adding New Device Types
|
||||
|
||||
To add support for a new device type (e.g., "modem"):
|
||||
|
||||
1. **Create Router** (`backend/routers/modem_dashboard.py`):
|
||||
```python
|
||||
@router.get("/stats", response_class=HTMLResponse)
|
||||
async def get_modem_stats(request: Request, db: Session = Depends(get_db)):
|
||||
modems = db.query(RosterUnit).filter_by(
|
||||
device_type="modem",
|
||||
retired=False
|
||||
).all()
|
||||
# Calculate stats and return partial
|
||||
```
|
||||
|
||||
2. **Create Templates**:
|
||||
- `templates/modems.html` - Main dashboard page
|
||||
- `templates/partials/modem_stats.html` - Stats cards
|
||||
- `templates/partials/modem_unit_list.html` - Unit list table
|
||||
|
||||
3. **Register in main.py**:
|
||||
```python
|
||||
from backend.routers import modem_dashboard
|
||||
app.include_router(modem_dashboard.router)
|
||||
|
||||
@app.get("/modems", response_class=HTMLResponse)
|
||||
async def modems_page(request: Request):
|
||||
return templates.TemplateResponse("modems.html", {"request": request})
|
||||
```
|
||||
|
||||
4. **Add to Navigation** (`templates/base.html`):
|
||||
```html
|
||||
<a href="/modems">
|
||||
<svg>...</svg>
|
||||
Modems
|
||||
</a>
|
||||
```
|
||||
|
||||
5. **Update Main Dashboard** (`templates/dashboard.html`):
|
||||
Add modem count to the device type breakdown:
|
||||
```html
|
||||
<div class="flex justify-between items-center">
|
||||
<a href="/modems">Modems</a>
|
||||
<span id="modem-count">--</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
Update JavaScript to count modems:
|
||||
```javascript
|
||||
let modemCount = 0;
|
||||
Object.values(data.units || {}).forEach(unit => {
|
||||
if (unit.device_type === 'modem' && !unit.retired) {
|
||||
modemCount++;
|
||||
}
|
||||
});
|
||||
document.getElementById('modem-count').textContent = modemCount;
|
||||
```
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Separation of Concerns**: Each device type has its own dedicated interface
|
||||
2. **Scalability**: Easy to add new device types following the established pattern
|
||||
3. **Performance**: Queries are filtered by device type, reducing data transfer
|
||||
4. **User Experience**: Users can focus on specific device types without clutter
|
||||
5. **Maintainability**: Each dashboard is self-contained and easy to modify
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - SLM dashboard details
|
||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - Adding SLM device type support
|
||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
||||
159
docs/DEVICE_TYPE_SLM_SUPPORT.md
Normal file
@@ -0,0 +1,159 @@
|
||||
# Sound Level Meter Device Type Support
|
||||
|
||||
## Overview
|
||||
|
||||
Added full support for "Sound Level Meter" as a device type in the roster management system. Users can now create, edit, and manage SLM units through the Fleet Roster interface.
|
||||
|
||||
## Changes Made
|
||||
|
||||
### 1. Frontend - Unit Detail Page
|
||||
|
||||
**File**: `templates/unit_detail.html`
|
||||
|
||||
#### Added Device Type Option
|
||||
- Added "Sound Level Meter" option to device type dropdown (line 243)
|
||||
- Value: `sound_level_meter`
|
||||
|
||||
#### Added SLM-Specific Fields Section (lines 320-370)
|
||||
New form fields for Sound Level Meter configuration:
|
||||
|
||||
- **Host (IP Address)**: Device network address
|
||||
- Field name: `slm_host`
|
||||
- Example: `192.168.1.100`
|
||||
|
||||
- **TCP Port**: Control port (default 2255)
|
||||
- Field name: `slm_tcp_port`
|
||||
- Type: number
|
||||
|
||||
- **Model**: Device model designation
|
||||
- Field name: `slm_model`
|
||||
- Example: `NL-43`, `NL-53`
|
||||
|
||||
- **Serial Number**: Manufacturer serial number
|
||||
- Field name: `slm_serial_number`
|
||||
|
||||
- **Frequency Weighting**: Sound measurement weighting curve
|
||||
- Field name: `slm_frequency_weighting`
|
||||
- Options: A-weighting, C-weighting, Z-weighting (Flat)
|
||||
|
||||
- **Time Weighting**: Temporal averaging method
|
||||
- Field name: `slm_time_weighting`
|
||||
- Options: Fast (125ms), Slow (1s), Impulse (35ms)
|
||||
|
||||
- **Measurement Range**: Device measurement capability
|
||||
- Field name: `slm_measurement_range`
|
||||
- Example: `30-130 dB`
|
||||
|
||||
#### Updated JavaScript Functions
|
||||
|
||||
**toggleDetailFields()** (lines 552-571)
|
||||
- Now handles three device types: seismograph, modem, sound_level_meter
|
||||
- Hides all device-specific sections, then shows only the relevant one
|
||||
- Shows `slmFields` section when device type is `sound_level_meter`
|
||||
|
||||
**populateEditForm()** (lines 527-558)
|
||||
- Populates all 7 SLM-specific fields from unit data
|
||||
- Sets empty string as default if field is null
|
||||
|
||||
### 2. Backend - API Endpoints
|
||||
|
||||
**File**: `backend/routers/roster_edit.py`
|
||||
|
||||
#### Updated Add Unit Endpoint
|
||||
`POST /api/roster/add`
|
||||
|
||||
**New Parameters** (lines 61-67):
|
||||
- `slm_host`: str (optional)
|
||||
- `slm_tcp_port`: int (optional)
|
||||
- `slm_model`: str (optional)
|
||||
- `slm_serial_number`: str (optional)
|
||||
- `slm_frequency_weighting`: str (optional)
|
||||
- `slm_time_weighting`: str (optional)
|
||||
- `slm_measurement_range`: str (optional)
|
||||
|
||||
**Unit Creation** (lines 108-115):
|
||||
All SLM fields are set when creating new unit.
|
||||
|
||||
#### Updated Get Unit Endpoint
|
||||
`GET /api/roster/{unit_id}`
|
||||
|
||||
**New Response Fields** (lines 146-152):
|
||||
Returns all 7 SLM fields in the response, with empty string as default if null.
|
||||
|
||||
#### Updated Edit Unit Endpoint
|
||||
`POST /api/roster/edit/{unit_id}`
|
||||
|
||||
**New Parameters** (lines 177-183):
|
||||
Same 7 SLM-specific parameters as add endpoint.
|
||||
|
||||
**Unit Update** (lines 232-239):
|
||||
All SLM fields are updated when editing existing unit.
|
||||
|
||||
### 3. Database Schema
|
||||
|
||||
**File**: `backend/models.py`
|
||||
|
||||
The database schema already included SLM fields (no changes needed):
|
||||
- All fields are nullable to support multiple device types
|
||||
- Fields are only relevant when `device_type = "sound_level_meter"`
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a New SLM Unit
|
||||
|
||||
1. Go to Fleet Roster page
|
||||
2. Click "Add Unit" or edit an existing unit
|
||||
3. Select "Sound Level Meter" from Device Type dropdown
|
||||
4. Fill in SLM-specific fields (Host, Port, Model, etc.)
|
||||
5. Save
|
||||
|
||||
### Converting Existing Unit to SLM
|
||||
|
||||
1. Open unit detail page
|
||||
2. Click "Edit Unit"
|
||||
3. Change Device Type to "Sound Level Meter"
|
||||
4. SLM fields section will appear
|
||||
5. Fill in required SLM configuration
|
||||
6. Save changes
|
||||
|
||||
### Field Visibility
|
||||
|
||||
The form automatically shows/hides relevant fields based on device type:
|
||||
- **Seismograph**: Shows calibration dates, modem deployment info
|
||||
- **Modem**: Shows IP, phone number, hardware model
|
||||
- **Sound Level Meter**: Shows host, port, model, serial, weightings, range
|
||||
|
||||
## Integration with SLMM Dashboard
|
||||
|
||||
Units with `device_type = "sound_level_meter"` will:
|
||||
- Appear in the Sound Level Meters dashboard (`/sound-level-meters`)
|
||||
- Be available for live monitoring and control
|
||||
- Use the configured `slm_host` and `slm_tcp_port` for device communication
|
||||
|
||||
## Testing
|
||||
|
||||
Test SLM units have been added via `add_test_slms.py`:
|
||||
- `nl43-001` - Deployed at construction site A
|
||||
- `nl43-002` - Deployed at construction site B
|
||||
- `nl53-001` - Deployed at residential area
|
||||
- `nl43-003` - Benched for calibration
|
||||
|
||||
You can edit any of these units to verify the form works correctly.
|
||||
|
||||
## Files Modified
|
||||
|
||||
1. `templates/unit_detail.html` - Added dropdown option, SLM fields section, updated JavaScript
|
||||
2. `backend/routers/roster_edit.py` - Added SLM parameters to add/edit/get endpoints
|
||||
3. `backend/models.py` - No changes (schema already supported SLM)
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
- Existing seismograph and modem units are unaffected
|
||||
- All SLM fields are optional/nullable
|
||||
- Forms gracefully handle units with missing device_type (defaults to seismograph)
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Date**: January 5, 2026
|
||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md
|
||||
132
docs/DEV_DATABASE_SETUP.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# DEV Database Setup Instructions
|
||||
|
||||
## Current Situation
|
||||
|
||||
The test SLM and modem data was accidentally added to the **PRODUCTION** database (`data/seismo_fleet.db`).
|
||||
|
||||
**Good news**: I've already removed it! The production database is clean.
|
||||
|
||||
**Issue**: The DEV database (`data-dev/seismo_fleet.db`) is:
|
||||
1. Owned by root (read-only for your user)
|
||||
2. Missing the SLM-specific columns in its schema
|
||||
|
||||
## What You Need to Do
|
||||
|
||||
### Step 1: Fix DEV Database Permissions
|
||||
|
||||
Run this command to make the DEV database writable:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
sudo chown serversdown:serversdown data-dev/seismo_fleet.db
|
||||
sudo chmod 664 data-dev/seismo_fleet.db
|
||||
```
|
||||
|
||||
### Step 2: Migrate DEV Database Schema
|
||||
|
||||
Add the SLM columns to the DEV database:
|
||||
|
||||
```bash
|
||||
python3 scripts/migrate_dev_db.py
|
||||
```
|
||||
|
||||
This will add these columns to the `roster` table:
|
||||
- `slm_host`
|
||||
- `slm_tcp_port`
|
||||
- `slm_model`
|
||||
- `slm_serial_number`
|
||||
- `slm_frequency_weighting`
|
||||
- `slm_time_weighting`
|
||||
- `slm_measurement_range`
|
||||
- `slm_last_check`
|
||||
|
||||
### Step 3: Add Test Data to DEV
|
||||
|
||||
Now you can safely add test data to the DEV database:
|
||||
|
||||
```bash
|
||||
# Add test SLMs
|
||||
python3 scripts/add_test_slms.py
|
||||
|
||||
# Add test modems and assign to SLMs
|
||||
python3 scripts/add_test_modems.py
|
||||
```
|
||||
|
||||
This will create:
|
||||
- 4 test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
||||
- 4 test modem units (modem-001, modem-002, modem-003, modem-004)
|
||||
- Assign modems to the SLMs
|
||||
|
||||
## Production Database Status
|
||||
|
||||
✅ **Production database is CLEAN** - all test data has been removed.
|
||||
|
||||
The production database (`data/seismo_fleet.db`) is ready for real production use.
|
||||
|
||||
## Test Scripts
|
||||
|
||||
All test scripts have been updated to use the DEV database:
|
||||
|
||||
### Scripts Updated:
|
||||
1. `scripts/add_test_slms.py` - Now uses `data-dev/seismo_fleet.db`
|
||||
2. `scripts/add_test_modems.py` - Now uses `data-dev/seismo_fleet.db`
|
||||
|
||||
### Scripts Created:
|
||||
1. `scripts/remove_test_data_from_prod.py` - Removes test data from production (already run)
|
||||
2. `scripts/update_dev_db_schema.py` - Updates schema (doesn't work for SQLite ALTER)
|
||||
3. `scripts/migrate_dev_db.py` - Adds SLM columns to DEV database
|
||||
|
||||
All helper scripts are located in the `scripts/` directory. See [scripts/README.md](../scripts/README.md) for detailed usage instructions.
|
||||
|
||||
## Verification
|
||||
|
||||
After running the steps above, verify everything worked:
|
||||
|
||||
```bash
|
||||
# Check DEV database has test data
|
||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
||||
```
|
||||
|
||||
You should see:
|
||||
```
|
||||
nl43-001|sound_level_meter
|
||||
nl43-002|sound_level_meter
|
||||
nl53-001|sound_level_meter
|
||||
nl43-003|sound_level_meter
|
||||
modem-001|modem
|
||||
modem-002|modem
|
||||
modem-003|modem
|
||||
modem-004|modem
|
||||
```
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### When to Use DEV Database
|
||||
|
||||
To use the DEV database, set the environment variable:
|
||||
|
||||
```bash
|
||||
# Not implemented yet, but you could add this to database.py:
|
||||
export DATABASE_ENV=dev
|
||||
```
|
||||
|
||||
Or modify `backend/database.py` to check for an environment variable.
|
||||
|
||||
### Current Setup
|
||||
|
||||
Right now, the application always uses `data/seismo_fleet.db` (production).
|
||||
|
||||
For development/testing, you could:
|
||||
1. Point SFM to use the DEV database temporarily
|
||||
2. Or keep test data in production (not recommended)
|
||||
3. Or implement environment-based database selection
|
||||
|
||||
## Summary
|
||||
|
||||
- ✅ Production DB cleaned (no test data)
|
||||
- ⚠️ DEV DB needs permission fix (run sudo commands above)
|
||||
- ⚠️ DEV DB needs schema migration (run scripts/migrate_dev_db.py)
|
||||
- ✅ Test scripts updated to use DEV DB
|
||||
- ✅ All test data ready to be added to DEV DB
|
||||
|
||||
Run the commands above and you'll be all set!
|
||||
62
docs/FIX_DEV_PERMISSIONS.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# Fix DEV Database Permissions
|
||||
|
||||
## The Problem
|
||||
|
||||
SQLite needs write access to both the database file AND the directory it's in (to create temporary files like journals and WAL files).
|
||||
|
||||
Currently:
|
||||
- ✅ Database file: `data-dev/seismo_fleet.db` - permissions fixed
|
||||
- ❌ Directory: `data-dev/` - still owned by root
|
||||
|
||||
## The Fix
|
||||
|
||||
Run this command to fix the directory ownership:
|
||||
|
||||
```bash
|
||||
sudo chown -R serversdown:serversdown /home/serversdown/sfm/seismo-fleet-manager/data-dev/
|
||||
```
|
||||
|
||||
Then run the migration again:
|
||||
|
||||
```bash
|
||||
python3 scripts/migrate_dev_db.py
|
||||
```
|
||||
|
||||
## Full Setup Commands
|
||||
|
||||
Here's the complete sequence:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
|
||||
# Fix directory ownership (includes all files inside)
|
||||
sudo chown -R serversdown:serversdown data-dev/
|
||||
|
||||
# Migrate schema
|
||||
python3 scripts/migrate_dev_db.py
|
||||
|
||||
# Add test data
|
||||
python3 scripts/add_test_slms.py
|
||||
python3 scripts/add_test_modems.py
|
||||
```
|
||||
|
||||
## Verify It Worked
|
||||
|
||||
After running the migration, you should see:
|
||||
|
||||
```
|
||||
Migrating DEV database to add SLM columns...
|
||||
============================================================
|
||||
✓ Added column: slm_host
|
||||
✓ Added column: slm_tcp_port
|
||||
✓ Added column: slm_model
|
||||
✓ Added column: slm_serial_number
|
||||
✓ Added column: slm_frequency_weighting
|
||||
✓ Added column: slm_time_weighting
|
||||
✓ Added column: slm_measurement_range
|
||||
✓ Added column: slm_last_check
|
||||
============================================================
|
||||
DEV database migration completed!
|
||||
```
|
||||
|
||||
Then the test data scripts should work without errors!
|
||||
375
docs/MODEM_INTEGRATION.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# Modem Integration System
|
||||
|
||||
## Overview
|
||||
|
||||
The modem integration system allows Sound Level Meters (SLMs) and Seismographs to be deployed with network connectivity modems. Instead of storing IP addresses directly on each device, units are assigned to modems which provide the network connection. This enables:
|
||||
|
||||
- Centralized modem management and tracking
|
||||
- IP address updates in one place
|
||||
- Future modem API integration for diagnostics
|
||||
- Proper asset tracking for network equipment
|
||||
|
||||
## Architecture
|
||||
|
||||
### Database Design
|
||||
|
||||
**Modem Units**: Stored as `RosterUnit` with `device_type = "modem"`
|
||||
|
||||
**Modem-Specific Fields**:
|
||||
- `ip_address`: Network IP address or hostname
|
||||
- `phone_number`: Cellular phone number (if applicable)
|
||||
- `hardware_model`: Modem hardware (e.g., "Raven XTV", "Sierra Wireless AirLink")
|
||||
|
||||
**Device Assignment**:
|
||||
- Both SLMs and Seismographs use `deployed_with_modem_id` to reference their modem
|
||||
- This is a foreign key to another `RosterUnit.id` where `device_type = "modem"`
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Create Modem Units**
|
||||
- Add modems as regular roster units with `device_type = "modem"`
|
||||
- Set IP address, phone number, and hardware model
|
||||
- Deploy or bench modems like any other asset
|
||||
|
||||
2. **Assign Devices to Modems**
|
||||
- When editing an SLM or Seismograph, select modem from dropdown
|
||||
- The `deployed_with_modem_id` field stores the modem ID
|
||||
- IP address is fetched from the assigned modem at runtime
|
||||
|
||||
3. **Runtime Resolution**
|
||||
- When SLM dashboard needs to connect to a device:
|
||||
1. Load SLM unit data
|
||||
2. Check `deployed_with_modem_id`
|
||||
3. Fetch modem unit
|
||||
4. Use modem's `ip_address` for connection
|
||||
5. Fallback to legacy `slm_host` if no modem assigned
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Frontend Changes
|
||||
|
||||
#### Unit Detail Page (`templates/unit_detail.html`)
|
||||
|
||||
**SLM Fields** (lines 320-375):
|
||||
- Removed direct "Host (IP Address)" field
|
||||
- Added "Deployed With Modem" dropdown selector
|
||||
- Dropdown populated with all active modems via JavaScript
|
||||
- Shows modem ID, IP address, and hardware model in dropdown
|
||||
|
||||
**JavaScript** (lines 456-485):
|
||||
```javascript
|
||||
async function loadModemsList() {
|
||||
// Fetches all modems from /api/roster/modems
|
||||
// Populates both seismograph and SLM modem dropdowns
|
||||
// Shows format: "modem-001 (192.168.1.100) - Raven XTV"
|
||||
}
|
||||
```
|
||||
|
||||
#### SLM Dashboard (`backend/routers/slm_dashboard.py`)
|
||||
|
||||
**Live View Endpoint** (lines 84-148):
|
||||
```python
|
||||
# Get modem information if assigned
|
||||
modem = None
|
||||
modem_ip = None
|
||||
if unit.deployed_with_modem_id:
|
||||
modem = db.query(RosterUnit).filter_by(
|
||||
id=unit.deployed_with_modem_id,
|
||||
device_type="modem"
|
||||
).first()
|
||||
if modem:
|
||||
modem_ip = modem.ip_address
|
||||
|
||||
# Fallback to direct slm_host (backward compatibility)
|
||||
if not modem_ip and unit.slm_host:
|
||||
modem_ip = unit.slm_host
|
||||
```
|
||||
|
||||
**Live View Template** (`templates/partials/slm_live_view.html`):
|
||||
- Displays modem information in header
|
||||
- Shows "via Modem: modem-001 (192.168.1.100)"
|
||||
- Warning if no modem assigned
|
||||
|
||||
### Backend Changes
|
||||
|
||||
#### New API Endpoint (`backend/routers/roster_edit.py`)
|
||||
|
||||
```python
|
||||
GET /api/roster/modems
|
||||
```
|
||||
|
||||
Returns list of all non-retired modem units:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "modem-001",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV",
|
||||
"deployed": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Used by frontend to populate modem selection dropdowns.
|
||||
|
||||
#### Database Schema (`backend/models.py`)
|
||||
|
||||
**Modem Assignment Field** (line 44):
|
||||
```python
|
||||
# Shared by seismographs and SLMs
|
||||
deployed_with_modem_id = Column(String, nullable=True)
|
||||
```
|
||||
|
||||
**Modem Fields** (lines 46-48):
|
||||
```python
|
||||
ip_address = Column(String, nullable=True)
|
||||
phone_number = Column(String, nullable=True)
|
||||
hardware_model = Column(String, nullable=True)
|
||||
```
|
||||
|
||||
**Legacy SLM Fields** (kept for backward compatibility):
|
||||
```python
|
||||
slm_host = Column(String, nullable=True) # Deprecated - use modem instead
|
||||
slm_tcp_port = Column(Integer, nullable=True) # Still used
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating Modem Units
|
||||
|
||||
1. Go to Fleet Roster
|
||||
2. Click "Add Unit"
|
||||
3. Set Device Type to "Modem"
|
||||
4. Fill in:
|
||||
- Unit ID (e.g., "modem-001")
|
||||
- IP Address (e.g., "192.168.1.100")
|
||||
- Phone Number (if cellular)
|
||||
- Hardware Model (e.g., "Raven XTV")
|
||||
- Address/Coordinates (physical location)
|
||||
5. Set Deployed status
|
||||
6. Save
|
||||
|
||||
### Assigning Modem to SLM
|
||||
|
||||
1. Open SLM unit detail page
|
||||
2. Click "Edit Unit"
|
||||
3. Ensure Device Type is "Sound Level Meter"
|
||||
4. In "Deployed With Modem" dropdown, select modem
|
||||
5. Verify TCP Port (default 2255)
|
||||
6. Save
|
||||
|
||||
### Assigning Modem to Seismograph
|
||||
|
||||
Same process - both device types use the same modem selection field.
|
||||
|
||||
## Test Data
|
||||
|
||||
Use the included script to create test modems:
|
||||
|
||||
```bash
|
||||
python3 add_test_modems.py
|
||||
```
|
||||
|
||||
This creates:
|
||||
- **modem-001**: 192.168.1.100, Raven XTV → assigned to nl43-001
|
||||
- **modem-002**: 192.168.1.101, Raven XTV → assigned to nl43-002
|
||||
- **modem-003**: 192.168.1.102, Sierra Wireless → assigned to nl53-001
|
||||
- **modem-004**: Spare modem (not deployed)
|
||||
|
||||
## Benefits
|
||||
|
||||
### For Operations
|
||||
|
||||
1. **Centralized IP Management**
|
||||
- Update modem IP once, affects all assigned devices
|
||||
- Easy to track which modem serves which devices
|
||||
- Inventory management for network equipment
|
||||
|
||||
2. **Asset Tracking**
|
||||
- Modems are first-class assets in the roster
|
||||
- Track deployment status, location, notes
|
||||
- Can bench/retire modems independently
|
||||
|
||||
3. **Future Capabilities**
|
||||
- Modem API integration (signal strength, data usage)
|
||||
- Automatic IP updates from DHCP/cellular network
|
||||
- Modem health monitoring
|
||||
- Remote modem diagnostics
|
||||
|
||||
### For Maintenance
|
||||
|
||||
1. **Easier Troubleshooting**
|
||||
- See which modem serves a device
|
||||
- Check modem status separately
|
||||
- Swap modems without reconfiguring devices
|
||||
|
||||
2. **Configuration Changes**
|
||||
- Change IP addresses system-wide
|
||||
- Move devices between modems
|
||||
- Test with backup modems
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
### For Existing SLMs with Direct IP
|
||||
|
||||
Legacy SLMs with `slm_host` set still work:
|
||||
- System checks `deployed_with_modem_id` first
|
||||
- Falls back to `slm_host` if no modem assigned
|
||||
- Logs fallback usage for visibility
|
||||
|
||||
### Migration Steps
|
||||
|
||||
1. Create modem units for each IP address
|
||||
2. Assign SLMs to their modems
|
||||
3. System will use modem IP automatically
|
||||
4. Legacy `slm_host` can be cleared (optional)
|
||||
|
||||
Script `add_test_modems.py` demonstrates this:
|
||||
```python
|
||||
# Clear legacy field after modem assignment
|
||||
slm.slm_host = None
|
||||
slm.deployed_with_modem_id = "modem-001"
|
||||
```
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Near-term
|
||||
|
||||
1. **Modem Status Dashboard**
|
||||
- List all modems with connection status
|
||||
- Show which devices use each modem
|
||||
- Signal strength, data usage indicators
|
||||
|
||||
2. **Automatic IP Discovery**
|
||||
- Query cellular provider API for modem IPs
|
||||
- Auto-update IP addresses in database
|
||||
- Alert on IP changes
|
||||
|
||||
3. **Modem Health Monitoring**
|
||||
- Ping modems periodically
|
||||
- Check cellular signal quality
|
||||
- Data usage tracking
|
||||
|
||||
### Long-term
|
||||
|
||||
1. **Modem API Integration**
|
||||
- Direct modem management (Raven, Sierra APIs)
|
||||
- Remote reboot capability
|
||||
- Configuration backup/restore
|
||||
- Firmware updates
|
||||
|
||||
2. **Network Topology View**
|
||||
- Graphical view of modem-device relationships
|
||||
- Network health visualization
|
||||
- Troubleshooting tools
|
||||
|
||||
3. **Multi-Modem Support**
|
||||
- Failover between modems
|
||||
- Load balancing
|
||||
- Automatic fallback on modem failure
|
||||
|
||||
## API Reference
|
||||
|
||||
### Get Modems List
|
||||
|
||||
**Endpoint**: `GET /api/roster/modems`
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "modem-001",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV",
|
||||
"deployed": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
**Used By**:
|
||||
- Unit detail page (modem dropdown)
|
||||
- Future modem management dashboard
|
||||
|
||||
### Get Unit with Modem Info
|
||||
|
||||
**Endpoint**: `GET /api/roster/{unit_id}`
|
||||
|
||||
**Response** (for SLM):
|
||||
```json
|
||||
{
|
||||
"id": "nl43-001",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed_with_modem_id": "modem-001",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then fetch modem separately or use dashboard endpoint which resolves it automatically.
|
||||
|
||||
### SLM Live View (with modem resolution)
|
||||
|
||||
**Endpoint**: `GET /api/slm-dashboard/live-view/{unit_id}`
|
||||
|
||||
**Process**:
|
||||
1. Loads SLM unit
|
||||
2. Resolves `deployed_with_modem_id` to modem unit
|
||||
3. Extracts `modem.ip_address`
|
||||
4. Uses IP to connect to SLMM backend
|
||||
5. Returns live view HTML with modem info
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### Modified
|
||||
1. `backend/models.py` - Clarified modem assignment field
|
||||
2. `templates/unit_detail.html` - Added modem selector for SLMs
|
||||
3. `backend/routers/roster_edit.py` - Added modems list endpoint
|
||||
4. `backend/routers/slm_dashboard.py` - Modem resolution logic
|
||||
5. `templates/partials/slm_live_view.html` - Display modem info
|
||||
|
||||
### Created
|
||||
1. `add_test_modems.py` - Script to create test modems and assignments
|
||||
2. `docs/MODEM_INTEGRATION.md` - This documentation
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### SLM shows "No modem assigned"
|
||||
|
||||
**Cause**: Unit has no `deployed_with_modem_id` set
|
||||
|
||||
**Solution**:
|
||||
1. Edit the SLM unit
|
||||
2. Select a modem from dropdown
|
||||
3. Save
|
||||
|
||||
### Modem dropdown is empty
|
||||
|
||||
**Cause**: No modem units in database
|
||||
|
||||
**Solution**:
|
||||
1. Create modem units first
|
||||
2. Set `device_type = "modem"`
|
||||
3. Ensure they're not retired
|
||||
|
||||
### Can't connect to SLM
|
||||
|
||||
**Possible Causes**:
|
||||
1. Modem IP is incorrect
|
||||
2. Modem is offline
|
||||
3. SLM TCP port is wrong
|
||||
4. Network routing issue
|
||||
|
||||
**Debug Steps**:
|
||||
1. Check modem unit's IP address
|
||||
2. Ping the modem IP
|
||||
3. Check SLM's `slm_tcp_port` (default 2255)
|
||||
4. Review logs for connection errors
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Date**: January 5, 2026
|
||||
**Related**: SOUND_LEVEL_METERS_DASHBOARD.md, DEVICE_TYPE_SLM_SUPPORT.md
|
||||
275
docs/SLM_CONFIGURATION.md
Normal file
@@ -0,0 +1,275 @@
|
||||
# SLM Configuration Interface
|
||||
|
||||
This document describes the SLM configuration interface added to the Sound Level Meters dashboard.
|
||||
|
||||
## Overview
|
||||
|
||||
Sound Level Meters can now be configured directly from the dashboard without needing to navigate to the unit detail page. A configuration button appears on each SLM unit card on hover, opening a modal with all configurable parameters.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Quick Access Configuration Button
|
||||
|
||||
- **Location**: Appears on each SLM unit card in the unit list
|
||||
- **Behavior**: Shows on hover (desktop) or always visible (mobile)
|
||||
- **Icon**: Gear/settings icon in top-right corner of unit card
|
||||
|
||||
### 2. Configuration Modal
|
||||
|
||||
The configuration modal provides a comprehensive interface for all SLM parameters:
|
||||
|
||||
#### Device Information
|
||||
- **Model**: Dropdown selection (NL-43, NL-53)
|
||||
- **Serial Number**: Text input for device serial number
|
||||
|
||||
#### Measurement Parameters
|
||||
- **Frequency Weighting**: A, C, or Z (Linear)
|
||||
- **Time Weighting**: Fast (125ms), Slow (1s), or Impulse
|
||||
- **Measurement Range**: 30-130 dB, 40-140 dB, or 50-140 dB
|
||||
|
||||
#### Network Configuration
|
||||
- **Assigned Modem**: Dropdown list of available modems
|
||||
- Shows modem ID and IP address
|
||||
- Option for "No modem (direct connection)"
|
||||
- **Direct IP Address**: Only shown when no modem assigned
|
||||
- **TCP Port**: Only shown when no modem assigned (default: 502)
|
||||
|
||||
### 3. Actions
|
||||
|
||||
The modal provides three action buttons:
|
||||
|
||||
- **Test Connection**: Tests network connectivity to the SLM
|
||||
- Uses current form values (not saved values)
|
||||
- Shows toast notification with results
|
||||
- Green: Connection successful
|
||||
- Yellow: Connection failed or device offline
|
||||
- Red: Test error
|
||||
|
||||
- **Cancel**: Closes modal without saving changes
|
||||
|
||||
- **Save Configuration**: Saves all changes to database
|
||||
- Shows success/error toast
|
||||
- Refreshes unit list on success
|
||||
- Auto-closes modal after 2 seconds
|
||||
|
||||
## Implementation
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### Unit List Partial
|
||||
**File**: [templates/partials/slm_unit_list.html](../templates/partials/slm_unit_list.html)
|
||||
|
||||
```html
|
||||
<!-- Configure button (appears on hover) -->
|
||||
<button onclick="event.stopPropagation(); openConfigModal('{{ unit.id }}');"
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100...">
|
||||
<svg>...</svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
#### Configuration Modal
|
||||
**File**: [templates/sound_level_meters.html](../templates/sound_level_meters.html#L73)
|
||||
|
||||
```html
|
||||
<!-- Configuration Modal -->
|
||||
<div id="config-modal" class="hidden fixed inset-0 bg-black bg-opacity-50...">
|
||||
<div id="config-modal-content">
|
||||
<!-- Content loaded via HTMX -->
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
#### Configuration Form
|
||||
**File**: [templates/partials/slm_config_form.html](../templates/partials/slm_config_form.html)
|
||||
|
||||
Form fields mapped to database columns:
|
||||
- `slm_model` → `unit.slm_model`
|
||||
- `slm_serial_number` → `unit.slm_serial_number`
|
||||
- `slm_frequency_weighting` → `unit.slm_frequency_weighting`
|
||||
- `slm_time_weighting` → `unit.slm_time_weighting`
|
||||
- `slm_measurement_range` → `unit.slm_measurement_range`
|
||||
- `deployed_with_modem_id` → `unit.deployed_with_modem_id`
|
||||
- `slm_host` → `unit.slm_host` (legacy, only if no modem)
|
||||
- `slm_tcp_port` → `unit.slm_tcp_port` (legacy, only if no modem)
|
||||
|
||||
### Backend Endpoints
|
||||
|
||||
#### GET /api/slm-dashboard/config/{unit_id}
|
||||
**File**: [backend/routers/slm_dashboard.py:184](../backend/routers/slm_dashboard.py#L184)
|
||||
|
||||
Returns configuration form HTML partial with current unit values pre-populated.
|
||||
|
||||
**Response**: HTML partial (slm_config_form.html)
|
||||
|
||||
#### POST /api/slm-dashboard/config/{unit_id}
|
||||
**File**: [backend/routers/slm_dashboard.py:203](../backend/routers/slm_dashboard.py#L203)
|
||||
|
||||
Saves configuration changes to database.
|
||||
|
||||
**Request**: Form data with configuration parameters
|
||||
|
||||
**Response**: JSON
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"unit_id": "nl43-001"
|
||||
}
|
||||
```
|
||||
|
||||
**Behavior**:
|
||||
- Updates all SLM-specific fields from form data
|
||||
- If modem is assigned: clears legacy `slm_host` and `slm_tcp_port`
|
||||
- If no modem: uses direct IP fields from form
|
||||
|
||||
### JavaScript Functions
|
||||
|
||||
#### openConfigModal(unitId)
|
||||
**File**: [templates/sound_level_meters.html:127](../templates/sound_level_meters.html#L127)
|
||||
|
||||
Opens configuration modal and loads form via HTMX.
|
||||
|
||||
```javascript
|
||||
function openConfigModal(unitId) {
|
||||
const modal = document.getElementById('config-modal');
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
htmx.ajax('GET', `/api/slm-dashboard/config/${unitId}`, {
|
||||
target: '#config-modal-content',
|
||||
swap: 'innerHTML'
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
#### closeConfigModal()
|
||||
**File**: [templates/sound_level_meters.html:136](../templates/sound_level_meters.html#L136)
|
||||
|
||||
Closes configuration modal.
|
||||
|
||||
#### handleConfigSave(event)
|
||||
**File**: [templates/partials/slm_config_form.html:109](../templates/partials/slm_config_form.html#L109)
|
||||
|
||||
Handles HTMX response after form submission:
|
||||
- Shows success/error toast
|
||||
- Refreshes unit list
|
||||
- Auto-closes modal after 2 seconds
|
||||
|
||||
#### testConnection(unitId)
|
||||
**File**: [templates/partials/slm_config_form.html:129](../templates/partials/slm_config_form.html#L129)
|
||||
|
||||
Tests connection to SLM unit:
|
||||
```javascript
|
||||
async function testConnection(unitId) {
|
||||
const response = await fetch(`/api/slmm/${unitId}/status`);
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok && data.status === 'online') {
|
||||
// Show success toast
|
||||
} else {
|
||||
// Show warning toast
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### loadModemsForConfig()
|
||||
**File**: [templates/partials/slm_config_form.html:87](../templates/partials/slm_config_form.html#L87)
|
||||
|
||||
Loads available modems from `/api/roster/modems` and populates dropdown.
|
||||
|
||||
## User Workflow
|
||||
|
||||
### Configuring an SLM
|
||||
|
||||
1. Navigate to Sound Level Meters dashboard ([/sound-level-meters](../sound-level-meters))
|
||||
2. Hover over desired SLM unit card in the list
|
||||
3. Click the gear icon that appears in top-right corner
|
||||
4. Configuration modal opens with current values pre-filled
|
||||
5. Modify desired parameters:
|
||||
- Update model/serial if needed
|
||||
- Set measurement parameters (frequency/time weighting, range)
|
||||
- Choose network configuration:
|
||||
- **Option A**: Select a modem from dropdown (recommended)
|
||||
- **Option B**: Enter direct IP address and port
|
||||
6. (Optional) Click "Test Connection" to verify network settings
|
||||
7. Click "Save Configuration"
|
||||
8. Modal shows success message and auto-closes
|
||||
9. Unit list refreshes to show updated information
|
||||
|
||||
### Network Configuration Options
|
||||
|
||||
**Modem Assignment (Recommended)**:
|
||||
- Select modem from dropdown
|
||||
- IP address automatically resolved from modem's `ip_address` field
|
||||
- Direct IP/port fields hidden
|
||||
- Enables modem tracking and management
|
||||
|
||||
**Direct Connection (Legacy)**:
|
||||
- Select "No modem (direct connection)"
|
||||
- Enter IP address and TCP port manually
|
||||
- Direct IP/port fields become visible
|
||||
- Useful for temporary setups or non-modem connections
|
||||
|
||||
## Database Schema
|
||||
|
||||
The configuration interface updates these `roster` table columns:
|
||||
|
||||
```sql
|
||||
-- SLM-specific fields
|
||||
slm_model VARCHAR -- Device model (NL-43, NL-53)
|
||||
slm_serial_number VARCHAR -- Serial number
|
||||
slm_frequency_weighting VARCHAR -- A, C, or Z weighting
|
||||
slm_time_weighting VARCHAR -- Fast, Slow, or Impulse
|
||||
slm_measurement_range VARCHAR -- Measurement range (30-130, 40-140, 50-140)
|
||||
|
||||
-- Network configuration
|
||||
deployed_with_modem_id VARCHAR -- FK to modem unit (preferred method)
|
||||
slm_host VARCHAR -- Legacy direct IP (only if no modem)
|
||||
slm_tcp_port INTEGER -- Legacy TCP port (only if no modem)
|
||||
```
|
||||
|
||||
## UI/UX Design
|
||||
|
||||
### Modal Behavior
|
||||
- **Opens**: Via configure button on unit card
|
||||
- **Closes**:
|
||||
- Cancel button
|
||||
- X button in header
|
||||
- Escape key
|
||||
- Clicking outside modal (on backdrop)
|
||||
- **Auto-close**: After successful save (2 second delay)
|
||||
|
||||
### Responsive Design
|
||||
- **Desktop**: Configuration button appears on hover
|
||||
- **Mobile**: Configuration button always visible
|
||||
- **Modal**: Responsive width, scrollable on small screens
|
||||
- **Form**: Two-column layout on desktop, single column on mobile
|
||||
|
||||
### Visual Feedback
|
||||
- **Loading**: Skeleton loader while form loads
|
||||
- **Saving**: HTMX handles form submission
|
||||
- **Success**: Green toast notification
|
||||
- **Error**: Red toast notification
|
||||
- **Testing**: Blue toast while testing, then green/yellow/red based on result
|
||||
|
||||
### Accessibility
|
||||
- **Keyboard**: Modal can be closed with Escape key
|
||||
- **Focus**: Modal traps focus when open
|
||||
- **Labels**: All form fields have proper labels
|
||||
- **Colors**: Sufficient contrast in dark/light modes
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for future versions:
|
||||
|
||||
1. **Bulk Configuration**: Configure multiple SLMs at once
|
||||
2. **Configuration Templates**: Save and apply configuration presets
|
||||
3. **Configuration History**: Track configuration changes over time
|
||||
4. **Remote Configuration**: Push configuration directly to device via SLMM
|
||||
5. **Validation**: Real-time validation of IP addresses and ports
|
||||
6. **Advanced Settings**: Additional NL-43/NL-53 specific parameters
|
||||
7. **Configuration Import/Export**: JSON/CSV configuration files
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [SOUND_LEVEL_METERS_DASHBOARD.md](SOUND_LEVEL_METERS_DASHBOARD.md) - Main SLM dashboard
|
||||
- [MODEM_INTEGRATION.md](MODEM_INTEGRATION.md) - Modem assignment architecture
|
||||
- [DEVICE_TYPE_SLM_SUPPORT.md](DEVICE_TYPE_SLM_SUPPORT.md) - SLM device type implementation
|
||||
333
docs/SOUND_LEVEL_METERS_DASHBOARD.md
Normal file
@@ -0,0 +1,333 @@
|
||||
# Sound Level Meters Dashboard
|
||||
|
||||
## Overview
|
||||
|
||||
The Sound Level Meters dashboard is a new feature in SFM (soon to be rebranded as Terra-view) that provides real-time monitoring and control of Rion NL-43/NL-53 sound level meters through the SLMM backend integration.
|
||||
|
||||
## Features
|
||||
|
||||
### 1. Dashboard Summary Statistics
|
||||
- **Total Units**: Count of all SLM devices in the system
|
||||
- **Deployed Units**: Active devices currently in the field
|
||||
- **Active Now**: Units that have checked in within the last hour
|
||||
- **Benched Units**: Devices not currently deployed
|
||||
|
||||
### 2. Unit List (Sidebar)
|
||||
- Searchable list of all deployed SLM units
|
||||
- Real-time status indicators:
|
||||
- 🟢 Green: Active (recently checked in)
|
||||
- ⚪ Gray: No check-in data
|
||||
- Quick unit information:
|
||||
- Device model (NL-43, NL-53, etc.)
|
||||
- Location/address
|
||||
- Network address (IP:port)
|
||||
- Click any unit to view its live data
|
||||
|
||||
### 3. Live View Panel
|
||||
|
||||
When a unit is selected, the live view panel displays:
|
||||
|
||||
#### Control Buttons
|
||||
- **Start**: Begin measurement
|
||||
- **Pause**: Pause current measurement
|
||||
- **Stop**: Stop measurement
|
||||
- **Reset**: Reset measurement data
|
||||
- **Start Live Stream**: Open WebSocket connection for real-time DRD data
|
||||
|
||||
#### Real-time Metrics
|
||||
- **Lp (Current)**: Instantaneous sound level in dB
|
||||
- **Leq (Average)**: Equivalent continuous sound level
|
||||
- **Lmax (Peak)**: Maximum sound level recorded
|
||||
- **Lmin**: Minimum sound level recorded
|
||||
|
||||
#### Live Chart
|
||||
- Real-time line chart showing Lp and Leq over time
|
||||
- 60-second rolling window (adjustable)
|
||||
- Chart.js-powered visualization with dark mode support
|
||||
- No animation for smooth real-time updates
|
||||
|
||||
#### Device Information
|
||||
- Battery level and power source
|
||||
- Frequency weighting (A, C, Z)
|
||||
- Time weighting (F, S, I)
|
||||
- SD card remaining space
|
||||
|
||||
## Architecture
|
||||
|
||||
### Frontend Components
|
||||
|
||||
#### Main Template
|
||||
**File**: `templates/sound_level_meters.html`
|
||||
|
||||
The main dashboard page that includes:
|
||||
- Page header and navigation integration
|
||||
- Stats summary section (auto-refreshes every 10s)
|
||||
- Two-column layout: unit list (left) + live view (right)
|
||||
- JavaScript functions for unit selection and WebSocket streaming
|
||||
|
||||
#### Partial Templates
|
||||
|
||||
1. **slm_stats.html** - Summary statistics cards
|
||||
- Auto-loads on page load
|
||||
- Refreshes every 10 seconds via HTMX
|
||||
|
||||
2. **slm_unit_list.html** - Searchable unit list
|
||||
- Auto-loads on page load
|
||||
- Refreshes every 10 seconds via HTMX
|
||||
- Supports search filtering
|
||||
|
||||
3. **slm_live_view.html** - Live data panel for selected unit
|
||||
- Loaded on-demand when unit is selected
|
||||
- Includes Chart.js for visualization
|
||||
- WebSocket connection for streaming data
|
||||
|
||||
4. **slm_live_view_error.html** - Error state display
|
||||
|
||||
### Backend Components
|
||||
|
||||
#### Router: `backend/routers/slm_dashboard.py`
|
||||
|
||||
**Endpoints:**
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/stats
|
||||
```
|
||||
Returns HTML partial with summary statistics.
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/units?search={term}
|
||||
```
|
||||
Returns HTML partial with filtered unit list.
|
||||
|
||||
```python
|
||||
GET /api/slm-dashboard/live-view/{unit_id}
|
||||
```
|
||||
Returns HTML partial with live view panel for specific unit.
|
||||
- Fetches unit details from database
|
||||
- Queries SLMM API for current measurement state
|
||||
- Queries SLMM API for live status (DOD data)
|
||||
|
||||
```python
|
||||
POST /api/slm-dashboard/control/{unit_id}/{action}
|
||||
```
|
||||
Sends control commands to SLMM backend.
|
||||
- Valid actions: start, stop, pause, resume, reset
|
||||
- Proxies to `http://localhost:8100/api/nl43/{unit_id}/{action}`
|
||||
|
||||
### Integration with SLMM
|
||||
|
||||
The dashboard communicates with the SLMM backend service running on port 8100:
|
||||
|
||||
**REST API Calls:**
|
||||
- `GET /api/nl43/{unit_id}/measurement-state` - Check if measuring
|
||||
- `GET /api/nl43/{unit_id}/live` - Get current DOD data
|
||||
- `POST /api/nl43/{unit_id}/start|stop|pause|resume|reset` - Control commands
|
||||
|
||||
**WebSocket Streaming:**
|
||||
- `WS /api/nl43/{unit_id}/live` - Real-time DRD data stream
|
||||
- Proxied through SFM at `/api/slmm/{unit_id}/live`
|
||||
- Streams continuous measurement data for live charting
|
||||
|
||||
### Database Schema
|
||||
|
||||
**Table**: `roster`
|
||||
|
||||
SLM-specific fields in the RosterUnit model:
|
||||
|
||||
```python
|
||||
device_type = "sound_level_meter" # Distinguishes SLMs from seismographs
|
||||
slm_host = String # Device IP or hostname
|
||||
slm_tcp_port = Integer # TCP control port (default 2255)
|
||||
slm_model = String # NL-43, NL-53, etc.
|
||||
slm_serial_number = String # Device serial number
|
||||
slm_frequency_weighting = String # A, C, or Z weighting
|
||||
slm_time_weighting = String # F (Fast), S (Slow), I (Impulse)
|
||||
slm_measurement_range = String # e.g., "30-130 dB"
|
||||
slm_last_check = DateTime # Last communication timestamp
|
||||
```
|
||||
|
||||
## Navigation
|
||||
|
||||
The Sound Level Meters page is accessible from:
|
||||
- **URL**: `/sound-level-meters`
|
||||
- **Sidebar**: "Sound Level Meters" menu item (between Fleet Roster and Projects)
|
||||
- **Icon**: Speaker/sound wave SVG icon
|
||||
|
||||
## Real-time Updates
|
||||
|
||||
The dashboard uses three mechanisms for real-time updates:
|
||||
|
||||
1. **HTMX Polling** (10-second intervals)
|
||||
- Summary statistics
|
||||
- Unit list
|
||||
- Ensures data freshness even without user interaction
|
||||
|
||||
2. **On-Demand Loading** (HTMX)
|
||||
- Live view panel loads when unit is selected
|
||||
- Control button responses
|
||||
|
||||
3. **WebSocket Streaming** (continuous)
|
||||
- Real-time DRD data for live charting
|
||||
- User-initiated via "Start Live Stream" button
|
||||
- Automatically closed on page unload or unit change
|
||||
|
||||
## Measurement Duration Tracking
|
||||
|
||||
**Important**: The NL-43/NL-53 devices do not expose measurement duration via their API. Elapsed time and interval counts are only visible on the device's on-screen display (OSD).
|
||||
|
||||
**Solution**: Track measurement start time in your application when calling the `/start` endpoint:
|
||||
|
||||
```javascript
|
||||
// When starting measurement
|
||||
const startTime = new Date();
|
||||
localStorage.setItem(`slm_${unitId}_start`, startTime.toISOString());
|
||||
|
||||
// Calculate elapsed time
|
||||
const startTime = new Date(localStorage.getItem(`slm_${unitId}_start`));
|
||||
const elapsed = (new Date() - startTime) / 1000; // seconds
|
||||
```
|
||||
|
||||
**Future Enhancement**: SLMM backend could store measurement start times in a database table to track duration across sessions.
|
||||
|
||||
## Testing
|
||||
|
||||
### Add Test Data
|
||||
|
||||
Use the included script to add test SLM units:
|
||||
|
||||
```bash
|
||||
python3 add_test_slms.py
|
||||
```
|
||||
|
||||
This creates:
|
||||
- 3 deployed test units (nl43-001, nl43-002, nl53-001)
|
||||
- 1 benched unit (nl43-003)
|
||||
|
||||
### Running the Dashboard
|
||||
|
||||
1. Start SLMM backend (port 8100):
|
||||
```bash
|
||||
cd /home/serversdown/slmm
|
||||
uvicorn main:app --host 0.0.0.0 --port 8100
|
||||
```
|
||||
|
||||
2. Start SFM (port 8000):
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8000 --reload
|
||||
```
|
||||
|
||||
3. Access dashboard:
|
||||
```
|
||||
http://localhost:8000/sound-level-meters
|
||||
```
|
||||
|
||||
### Testing Without Physical Devices
|
||||
|
||||
The dashboard will work without physical NL-43 devices connected:
|
||||
- Unit list will display based on database records
|
||||
- Live view will show connection errors (gracefully handled)
|
||||
- Mock data can be added to SLMM for testing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Near-term
|
||||
1. **Measurement Duration Tracking**
|
||||
- Add database table to track measurement sessions
|
||||
- Display elapsed time in live view
|
||||
- Store start/stop timestamps
|
||||
|
||||
2. **Historical Data View**
|
||||
- Chart historical Leq intervals
|
||||
- Export measurement data
|
||||
- Comparison between units
|
||||
|
||||
3. **Alerts & Thresholds**
|
||||
- Configurable sound level alerts
|
||||
- Email/SMS notifications when thresholds exceeded
|
||||
- Visual indicators on dashboard
|
||||
|
||||
### Long-term
|
||||
1. **Map View**
|
||||
- Display all SLMs on a map (like seismographs)
|
||||
- Click map markers to view live data
|
||||
- Color-coded by current sound level
|
||||
|
||||
2. **Batch Operations**
|
||||
- Start/stop multiple units simultaneously
|
||||
- Synchronized measurements
|
||||
- Group configurations
|
||||
|
||||
3. **Advanced Analytics**
|
||||
- Noise compliance reports
|
||||
- Statistical summaries
|
||||
- Trend analysis
|
||||
|
||||
## Integration with Terra-view
|
||||
|
||||
When SFM is rebranded to Terra-view:
|
||||
|
||||
1. **Multi-Module Dashboard**
|
||||
- Sound Level Meters module (this dashboard)
|
||||
- Seismograph Fleet Manager module (existing)
|
||||
- Future monitoring modules
|
||||
|
||||
2. **Project Management**
|
||||
- Link SLMs to projects
|
||||
- Combined project view with seismographs + SLMs
|
||||
- Project-level reporting
|
||||
|
||||
3. **Unified Navigation**
|
||||
- Top-level module switcher
|
||||
- Consistent UI/UX across modules
|
||||
- Shared authentication and settings
|
||||
|
||||
## Technical Notes
|
||||
|
||||
### HTMX Integration
|
||||
The dashboard extensively uses HTMX for dynamic updates without full page reloads:
|
||||
- `hx-get`: Fetch and swap content
|
||||
- `hx-trigger`: Auto-refresh intervals
|
||||
- `hx-swap`: Content replacement strategy
|
||||
- `hx-target`: Specify update target
|
||||
|
||||
### Dark Mode Support
|
||||
All components support dark mode:
|
||||
- Chart.js colors adapt to theme
|
||||
- Tailwind dark: classes throughout
|
||||
- Automatic theme detection
|
||||
|
||||
### Performance Considerations
|
||||
- WebSocket connections are per-unit (only one active at a time)
|
||||
- Chart data limited to 60 points (1 minute) to prevent memory bloat
|
||||
- Polling intervals balanced for responsiveness vs server load
|
||||
- Lazy loading of live view panel (only when unit selected)
|
||||
|
||||
## Files Modified/Created
|
||||
|
||||
### New Files
|
||||
- `templates/sound_level_meters.html`
|
||||
- `templates/partials/slm_stats.html`
|
||||
- `templates/partials/slm_unit_list.html`
|
||||
- `templates/partials/slm_live_view.html`
|
||||
- `templates/partials/slm_live_view_error.html`
|
||||
- `backend/routers/slm_dashboard.py`
|
||||
- `add_test_slms.py`
|
||||
- `docs/SOUND_LEVEL_METERS_DASHBOARD.md`
|
||||
|
||||
### Modified Files
|
||||
- `backend/main.py` - Added route and router import
|
||||
- `templates/base.html` - Added navigation menu item
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions:
|
||||
- Check SLMM API documentation: `/home/serversdown/slmm/docs/API.md`
|
||||
- Review SFM changelog: `CHANGELOG.md`
|
||||
- Submit issues to project repository
|
||||
|
||||
---
|
||||
|
||||
**Version**: 1.0.0
|
||||
**Created**: January 2026
|
||||
**Last Updated**: January 5, 2026
|
||||
9
requirements.txt
Normal file
@@ -0,0 +1,9 @@
|
||||
fastapi==0.104.1
|
||||
uvicorn[standard]==0.24.0
|
||||
sqlalchemy==2.0.23
|
||||
pydantic==2.5.0
|
||||
python-multipart==0.0.6
|
||||
jinja2==3.1.2
|
||||
aiofiles==23.2.1
|
||||
Pillow==10.1.0
|
||||
httpx==0.25.2
|
||||
6
sample_roster.csv
Normal file
@@ -0,0 +1,6 @@
|
||||
unit_id,unit_type,deployed,retired,note,project_id,location
|
||||
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
|
||||
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
|
||||
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
|
||||
BE3456,series3,true,false,,PROJ-003,New York NY
|
||||
BE7890,series3,false,true,Decommissioned 2024,,Storage
|
||||
|
120
scripts/README.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# Helper Scripts
|
||||
|
||||
This directory contains helper scripts for database management and testing.
|
||||
|
||||
## Database Migration Scripts
|
||||
|
||||
### migrate_dev_db.py
|
||||
Migrates the DEV database schema to add SLM-specific columns to the `roster` table.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
python3 scripts/migrate_dev_db.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Adds 8 SLM-specific columns to the DEV database (data-dev/seismo_fleet.db)
|
||||
- Columns: slm_host, slm_tcp_port, slm_model, slm_serial_number, slm_frequency_weighting, slm_time_weighting, slm_measurement_range, slm_last_check
|
||||
- Safe to run multiple times (skips existing columns)
|
||||
|
||||
### update_dev_db_schema.py
|
||||
Inspects and displays the DEV database schema.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 scripts/update_dev_db_schema.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Shows all tables in the DEV database
|
||||
- Lists all columns in the roster table
|
||||
- Useful for verifying schema after migrations
|
||||
|
||||
## Test Data Scripts
|
||||
|
||||
### add_test_slms.py
|
||||
Adds test Sound Level Meter units to the DEV database.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 scripts/add_test_slms.py
|
||||
```
|
||||
|
||||
**What it creates:**
|
||||
- nl43-001: NL-43 SLM at Construction Site A
|
||||
- nl43-002: NL-43 SLM at Construction Site B
|
||||
- nl53-001: NL-53 SLM at Residential Area
|
||||
- nl43-003: NL-43 SLM (not deployed, spare unit)
|
||||
|
||||
### add_test_modems.py
|
||||
Adds test modem units to the DEV database and assigns them to SLMs.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 scripts/add_test_modems.py
|
||||
```
|
||||
|
||||
**What it creates:**
|
||||
- modem-001, modem-002, modem-003: Deployed modems (Raven XTV and Sierra Wireless)
|
||||
- modem-004: Spare modem (not deployed)
|
||||
|
||||
**Modem assignments:**
|
||||
- nl43-001 → modem-001
|
||||
- nl43-002 → modem-002
|
||||
- nl53-001 → modem-003
|
||||
|
||||
## Cleanup Scripts
|
||||
|
||||
### remove_test_data_from_prod.py
|
||||
**⚠️ PRODUCTION DATABASE CLEANUP**
|
||||
|
||||
Removes test data from the production database (data/seismo_fleet.db).
|
||||
|
||||
**Status:** Already executed successfully. Production database is clean.
|
||||
|
||||
**What it removed:**
|
||||
- All test SLM units (nl43-001, nl43-002, nl53-001, nl43-003)
|
||||
- All test modem units (modem-001, modem-002, modem-003, modem-004)
|
||||
|
||||
## Database Cloning
|
||||
|
||||
### clone_db_to_dev.py
|
||||
Clones the production database to create/update the DEV database.
|
||||
|
||||
**Usage:**
|
||||
```bash
|
||||
python3 scripts/clone_db_to_dev.py
|
||||
```
|
||||
|
||||
**What it does:**
|
||||
- Copies data/seismo_fleet.db → data-dev/seismo_fleet.db
|
||||
- Useful for syncing DEV database with production schema/data
|
||||
|
||||
## Setup Sequence
|
||||
|
||||
To set up a fresh DEV database with test data:
|
||||
|
||||
```bash
|
||||
cd /home/serversdown/sfm/seismo-fleet-manager
|
||||
|
||||
# 1. Fix permissions (if needed)
|
||||
sudo chown -R serversdown:serversdown data-dev/
|
||||
|
||||
# 2. Migrate schema
|
||||
python3 scripts/migrate_dev_db.py
|
||||
|
||||
# 3. Add test data
|
||||
python3 scripts/add_test_slms.py
|
||||
python3 scripts/add_test_modems.py
|
||||
|
||||
# 4. Verify
|
||||
sqlite3 data-dev/seismo_fleet.db "SELECT id, device_type FROM roster WHERE device_type IN ('sound_level_meter', 'modem');"
|
||||
```
|
||||
|
||||
## Important Notes
|
||||
|
||||
- **DEV Database**: `data-dev/seismo_fleet.db` - Used for development and testing
|
||||
- **Production Database**: `data/seismo_fleet.db` - Used by the running application
|
||||
- All test scripts are configured to use the DEV database only
|
||||
- Never run test data scripts against production
|
||||
39
scripts/add_slm_ftp_port.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add slm_ftp_port column to roster table for FTP data retrieval port
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
import os
|
||||
|
||||
# Determine database based on environment
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
if ENVIRONMENT == "development":
|
||||
DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
else:
|
||||
DB_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
def add_ftp_port_column():
|
||||
print(f"Adding slm_ftp_port column to {DB_URL}...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
with engine.connect() as conn:
|
||||
try:
|
||||
# Try to add the column
|
||||
conn.execute(text("ALTER TABLE roster ADD COLUMN slm_ftp_port INTEGER"))
|
||||
conn.commit()
|
||||
print("✓ Added column: slm_ftp_port (INTEGER)")
|
||||
except Exception as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
print(" Column slm_ftp_port already exists, skipping")
|
||||
else:
|
||||
print(f"✗ Error adding slm_ftp_port: {e}")
|
||||
raise
|
||||
|
||||
print("=" * 60)
|
||||
print("Migration completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_ftp_port_column()
|
||||
105
scripts/add_test_modems.py
Executable file
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add test modem units and assign them to SLMs in DEV database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
from datetime import datetime
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def add_test_modems():
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# Test modems
|
||||
test_modems = [
|
||||
{
|
||||
"id": "modem-001",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Raven XTV modem for Construction Site A",
|
||||
"address": "123 Main St, Construction Site A",
|
||||
"coordinates": "34.0522,-118.2437",
|
||||
"ip_address": "192.168.1.100",
|
||||
"phone_number": "+1-555-0100",
|
||||
"hardware_model": "Raven XTV"
|
||||
},
|
||||
{
|
||||
"id": "modem-002",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Raven XTV modem for Construction Site B",
|
||||
"address": "456 Oak Ave, Construction Site B",
|
||||
"coordinates": "34.0622,-118.2537",
|
||||
"ip_address": "192.168.1.101",
|
||||
"phone_number": "+1-555-0101",
|
||||
"hardware_model": "Raven XTV"
|
||||
},
|
||||
{
|
||||
"id": "modem-003",
|
||||
"device_type": "modem",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Sierra Wireless modem for Residential Area",
|
||||
"address": "789 Elm St, Residential Area",
|
||||
"coordinates": "34.0722,-118.2637",
|
||||
"ip_address": "192.168.1.102",
|
||||
"phone_number": "+1-555-0102",
|
||||
"hardware_model": "Sierra Wireless AirLink"
|
||||
},
|
||||
{
|
||||
"id": "modem-004",
|
||||
"device_type": "modem",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"note": "Spare modem - not deployed",
|
||||
"ip_address": None,
|
||||
"phone_number": "+1-555-0103",
|
||||
"hardware_model": "Raven XTV"
|
||||
}
|
||||
]
|
||||
|
||||
for modem_data in test_modems:
|
||||
# Check if modem already exists
|
||||
existing = db.query(RosterUnit).filter_by(id=modem_data["id"]).first()
|
||||
if existing:
|
||||
print(f"Modem {modem_data['id']} already exists, skipping...")
|
||||
continue
|
||||
|
||||
modem = RosterUnit(**modem_data)
|
||||
db.add(modem)
|
||||
print(f"Added {modem_data['id']}")
|
||||
|
||||
# Assign modems to existing SLMs
|
||||
slm_modem_assignments = {
|
||||
"nl43-001": "modem-001",
|
||||
"nl43-002": "modem-002",
|
||||
"nl53-001": "modem-003"
|
||||
}
|
||||
|
||||
for slm_id, modem_id in slm_modem_assignments.items():
|
||||
slm = db.query(RosterUnit).filter_by(id=slm_id).first()
|
||||
if slm:
|
||||
slm.deployed_with_modem_id = modem_id
|
||||
# Remove legacy slm_host since we're using modems now
|
||||
slm.slm_host = None
|
||||
print(f"Assigned {slm_id} to {modem_id}")
|
||||
else:
|
||||
print(f"SLM {slm_id} not found, skipping assignment...")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print("\nTest modems added and assigned to SLMs successfully in DEV database (data-dev/seismo_fleet.db)!")
|
||||
print("\nModem assignments:")
|
||||
for slm_id, modem_id in slm_modem_assignments.items():
|
||||
print(f" {slm_id} → {modem_id}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_test_modems()
|
||||
106
scripts/add_test_slms.py
Executable file
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Add test Sound Level Meter units to the DEV database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
from datetime import datetime
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def add_test_slms():
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
test_slms = [
|
||||
{
|
||||
"id": "nl43-001",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at construction site A",
|
||||
"address": "123 Main St, Construction Site A",
|
||||
"coordinates": "34.0522,-118.2437",
|
||||
"slm_host": "192.168.1.100",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123456",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl43-002",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at construction site B",
|
||||
"address": "456 Oak Ave, Construction Site B",
|
||||
"coordinates": "34.0622,-118.2537",
|
||||
"slm_host": "192.168.1.101",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123457",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "S",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl53-001",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": True,
|
||||
"retired": False,
|
||||
"note": "Test unit at residential monitoring",
|
||||
"address": "789 Elm St, Residential Area",
|
||||
"coordinates": "34.0722,-118.2637",
|
||||
"slm_host": "192.168.1.102",
|
||||
"slm_tcp_port": 2255,
|
||||
"slm_model": "NL-53",
|
||||
"slm_serial_number": "234567",
|
||||
"slm_frequency_weighting": "C",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "25-140 dB",
|
||||
"slm_last_check": datetime.utcnow()
|
||||
},
|
||||
{
|
||||
"id": "nl43-003",
|
||||
"device_type": "sound_level_meter",
|
||||
"deployed": False,
|
||||
"retired": False,
|
||||
"note": "Benched for calibration",
|
||||
"address": None,
|
||||
"coordinates": None,
|
||||
"slm_host": None,
|
||||
"slm_tcp_port": None,
|
||||
"slm_model": "NL-43",
|
||||
"slm_serial_number": "123458",
|
||||
"slm_frequency_weighting": "A",
|
||||
"slm_time_weighting": "F",
|
||||
"slm_measurement_range": "30-130 dB",
|
||||
"slm_last_check": None
|
||||
}
|
||||
]
|
||||
|
||||
for slm_data in test_slms:
|
||||
# Check if unit already exists
|
||||
existing = db.query(RosterUnit).filter_by(id=slm_data["id"]).first()
|
||||
if existing:
|
||||
print(f"Unit {slm_data['id']} already exists, skipping...")
|
||||
continue
|
||||
|
||||
unit = RosterUnit(**slm_data)
|
||||
db.add(unit)
|
||||
print(f"Added {slm_data['id']}")
|
||||
|
||||
db.commit()
|
||||
db.close()
|
||||
print("\nTest SLM units added successfully to DEV database (data-dev/seismo_fleet.db)!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
add_test_slms()
|
||||
149
scripts/clone_db_to_dev.py
Executable file
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Clone Production Database to Dev Server
|
||||
Helper script to clone the production database to a remote development server
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import requests
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from backend.services.database_backup import DatabaseBackupService
|
||||
|
||||
|
||||
def clone_to_dev(remote_url: str, snapshot_filename: str = None, auth_token: str = None):
|
||||
"""Clone database to remote dev server"""
|
||||
|
||||
backup_service = DatabaseBackupService()
|
||||
|
||||
print(f"🔄 Cloning database to {remote_url}...")
|
||||
|
||||
try:
|
||||
# If no snapshot specified, create a new one
|
||||
if snapshot_filename:
|
||||
print(f"📦 Using existing snapshot: {snapshot_filename}")
|
||||
snapshot_path = backup_service.backups_dir / snapshot_filename
|
||||
if not snapshot_path.exists():
|
||||
print(f"❌ Error: Snapshot {snapshot_filename} not found")
|
||||
return False
|
||||
else:
|
||||
print("📸 Creating new snapshot...")
|
||||
snapshot_info = backup_service.create_snapshot(description="Clone to dev server")
|
||||
snapshot_filename = snapshot_info["filename"]
|
||||
snapshot_path = backup_service.backups_dir / snapshot_filename
|
||||
print(f"✅ Snapshot created: {snapshot_filename} ({snapshot_info['size_mb']} MB)")
|
||||
|
||||
# Upload to remote server
|
||||
print(f"📤 Uploading to {remote_url}...")
|
||||
|
||||
headers = {}
|
||||
if auth_token:
|
||||
headers["Authorization"] = f"Bearer {auth_token}"
|
||||
|
||||
with open(snapshot_path, 'rb') as f:
|
||||
files = {'file': (snapshot_filename, f, 'application/x-sqlite3')}
|
||||
|
||||
response = requests.post(
|
||||
f"{remote_url.rstrip('/')}/api/settings/database/upload-snapshot",
|
||||
files=files,
|
||||
headers=headers,
|
||||
timeout=300
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
print(f"✅ Upload successful!")
|
||||
print(f" Remote filename: {result['snapshot']['filename']}")
|
||||
print(f" Size: {result['snapshot']['size_mb']} MB")
|
||||
|
||||
# Now restore on remote server
|
||||
print("🔄 Restoring on remote server...")
|
||||
restore_response = requests.post(
|
||||
f"{remote_url.rstrip('/')}/api/settings/database/restore",
|
||||
json={
|
||||
"filename": result['snapshot']['filename'],
|
||||
"create_backup": True
|
||||
},
|
||||
headers=headers,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
restore_response.raise_for_status()
|
||||
restore_result = restore_response.json()
|
||||
|
||||
print(f"✅ Database cloned successfully!")
|
||||
print(f" Restored from: {restore_result['restored_from']}")
|
||||
print(f" Remote backup created: {restore_result.get('backup_created', 'N/A')}")
|
||||
|
||||
return True
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f"❌ Network error: {str(e)}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Error: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Clone production database to development server",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Clone current database to dev server
|
||||
python clone_db_to_dev.py --url https://dev.example.com
|
||||
|
||||
# Clone using existing snapshot
|
||||
python clone_db_to_dev.py --url https://dev.example.com --snapshot snapshot_20250101_120000.db
|
||||
|
||||
# Clone with authentication
|
||||
python clone_db_to_dev.py --url https://dev.example.com --token YOUR_TOKEN
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--url',
|
||||
required=True,
|
||||
help='Remote dev server URL (e.g., https://dev.example.com)'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--snapshot',
|
||||
help='Use existing snapshot instead of creating new one'
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
'--token',
|
||||
help='Authentication token for remote server'
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print(" Database Cloning Tool - Production to Dev")
|
||||
print("=" * 60)
|
||||
print()
|
||||
|
||||
success = clone_to_dev(
|
||||
remote_url=args.url,
|
||||
snapshot_filename=args.snapshot,
|
||||
auth_token=args.token
|
||||
)
|
||||
|
||||
print()
|
||||
if success:
|
||||
print("🎉 Cloning completed successfully!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("💥 Cloning failed")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
46
scripts/migrate_dev_db.py
Normal file
@@ -0,0 +1,46 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migrate DEV database to add SLM-specific columns
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def migrate_dev_database():
|
||||
print("Migrating DEV database to add SLM columns...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
# SLM columns to add
|
||||
slm_columns = [
|
||||
("slm_host", "VARCHAR"),
|
||||
("slm_tcp_port", "INTEGER"),
|
||||
("slm_model", "VARCHAR"),
|
||||
("slm_serial_number", "VARCHAR"),
|
||||
("slm_frequency_weighting", "VARCHAR"),
|
||||
("slm_time_weighting", "VARCHAR"),
|
||||
("slm_measurement_range", "VARCHAR"),
|
||||
("slm_last_check", "DATETIME"),
|
||||
]
|
||||
|
||||
with engine.connect() as conn:
|
||||
for column_name, column_type in slm_columns:
|
||||
try:
|
||||
# Try to add the column
|
||||
conn.execute(text(f"ALTER TABLE roster ADD COLUMN {column_name} {column_type}"))
|
||||
conn.commit()
|
||||
print(f"✓ Added column: {column_name}")
|
||||
except Exception as e:
|
||||
if "duplicate column name" in str(e).lower():
|
||||
print(f" Column {column_name} already exists, skipping")
|
||||
else:
|
||||
print(f"✗ Error adding {column_name}: {e}")
|
||||
|
||||
print("=" * 60)
|
||||
print("DEV database migration completed!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
migrate_dev_database()
|
||||
45
scripts/remove_test_data_from_prod.py
Normal file
@@ -0,0 +1,45 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Remove test SLMs and modems from PRODUCTION database
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from backend.models import RosterUnit
|
||||
|
||||
# PRODUCTION database
|
||||
PROD_DB_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
def remove_test_data():
|
||||
engine = create_engine(PROD_DB_URL, connect_args={"check_same_thread": False})
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
db = SessionLocal()
|
||||
|
||||
# IDs to remove
|
||||
test_slm_ids = ["nl43-001", "nl43-002", "nl53-001", "nl43-003"]
|
||||
test_modem_ids = ["modem-001", "modem-002", "modem-003", "modem-004"]
|
||||
|
||||
all_test_ids = test_slm_ids + test_modem_ids
|
||||
|
||||
removed = []
|
||||
for unit_id in all_test_ids:
|
||||
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
|
||||
if unit:
|
||||
db.delete(unit)
|
||||
removed.append(unit_id)
|
||||
print(f"Removed {unit_id}")
|
||||
|
||||
if removed:
|
||||
db.commit()
|
||||
print(f"\nRemoved {len(removed)} test units from PRODUCTION database")
|
||||
else:
|
||||
print("No test units found in production database")
|
||||
|
||||
db.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Removing test data from PRODUCTION database...")
|
||||
print("=" * 60)
|
||||
remove_test_data()
|
||||
print("=" * 60)
|
||||
print("Done! Production database is clean.")
|
||||
39
scripts/update_dev_db_schema.py
Normal file
@@ -0,0 +1,39 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Update DEV database schema to match current models
|
||||
"""
|
||||
|
||||
from sqlalchemy import create_engine, inspect
|
||||
from backend.models import Base
|
||||
|
||||
# DEV database
|
||||
DEV_DB_URL = "sqlite:///./data-dev/seismo_fleet.db"
|
||||
|
||||
def update_schema():
|
||||
print("Updating DEV database schema...")
|
||||
print("=" * 60)
|
||||
|
||||
engine = create_engine(DEV_DB_URL, connect_args={"check_same_thread": False})
|
||||
|
||||
# Create all tables (will update existing tables with new columns)
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Inspect to see what we have
|
||||
inspector = inspect(engine)
|
||||
tables = inspector.get_table_names()
|
||||
|
||||
print(f"Tables in DEV database: {tables}")
|
||||
|
||||
if 'roster' in tables:
|
||||
columns = [col['name'] for col in inspector.get_columns('roster')]
|
||||
print(f"\nColumns in roster table:")
|
||||
for col in sorted(columns):
|
||||
print(f" - {col}")
|
||||
|
||||
print("=" * 60)
|
||||
print("DEV database schema updated successfully!")
|
||||
print("\nNote: SQLite doesn't support ALTER COLUMN, so existing")
|
||||
print("columns won't be modified, but new columns will be added.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
update_schema()
|
||||
7
sfm.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
67
sync_slms_to_slmm.py
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
One-time script to sync existing SLM devices from Terra-View roster to SLMM cache.
|
||||
Run this after implementing the automatic sync to backfill existing devices.
|
||||
"""
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path for imports
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from backend.database import SessionLocal
|
||||
from backend.models import RosterUnit
|
||||
from backend.routers.roster_edit import sync_slm_to_slmm_cache
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def sync_all_slms():
|
||||
"""Sync all SLM devices from Terra-View roster to SLMM cache."""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
# Get all SLM devices from Terra-View (source of truth)
|
||||
slm_devices = db.query(RosterUnit).filter_by(
|
||||
device_type="sound_level_meter"
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(slm_devices)} SLM devices in Terra-View roster")
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
for device in slm_devices:
|
||||
logger.info(f"\nProcessing: {device.id}")
|
||||
logger.info(f" Host: {device.slm_host}")
|
||||
logger.info(f" TCP Port: {device.slm_tcp_port}")
|
||||
logger.info(f" Modem: {device.deployed_with_modem_id}")
|
||||
|
||||
result = await sync_slm_to_slmm_cache(
|
||||
unit_id=device.id,
|
||||
host=device.slm_host,
|
||||
tcp_port=device.slm_tcp_port,
|
||||
ftp_port=device.slm_ftp_port,
|
||||
deployed_with_modem_id=device.deployed_with_modem_id,
|
||||
db=db
|
||||
)
|
||||
|
||||
if result["success"]:
|
||||
logger.info(f"✓ {device.id}: {result['message']}")
|
||||
success_count += 1
|
||||
else:
|
||||
logger.error(f"✗ {device.id}: {result['message']}")
|
||||
failed_count += 1
|
||||
|
||||
logger.info(f"\n{'='*60}")
|
||||
logger.info(f"Cache sync complete: {success_count} succeeded, {failed_count} failed")
|
||||
logger.info(f"{'='*60}")
|
||||
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(sync_all_slms())
|
||||
384
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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>
|
||||
567
templates/partials/slm_live_view.html
Normal file
@@ -0,0 +1,567 @@
|
||||
<!-- 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 Status Cards -->
|
||||
<div class="mt-6">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Device Status</h3>
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
<!-- Battery Status -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Battery</span>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V4a2 2 0 00-2-2H6zm0 2h8v12H6V4zm7 2a1 1 0 011 1v6a1 1 0 11-2 0V7a1 1 0 011-1z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="battery-level" class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}--{% endif %}
|
||||
</div>
|
||||
<div class="mt-2 bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
||||
<div id="battery-bar" class="bg-green-500 h-2 rounded-full transition-all"
|
||||
style="width: {% if current_status and current_status.battery_level %}{{ current_status.battery_level }}%{% else %}0%{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Power Source -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Power</span>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="power-source" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if current_status and current_status.power_source %}{{ current_status.power_source }}{% else %}--{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SD Card Space -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">SD Card</span>
|
||||
<svg class="w-4 h-4 text-gray-500" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M3 4a2 2 0 012-2h10a2 2 0 012 2v12a2 2 0 01-2 2H5a2 2 0 01-2-2V4zm2 0v12h10V4H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="sd-remaining" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{% if current_status and current_status.sd_remaining_mb %}{{ current_status.sd_remaining_mb }} MB{% else %}--{% endif %}
|
||||
</div>
|
||||
<div id="sd-ratio" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{% if current_status and current_status.sd_free_ratio %}{{ current_status.sd_free_ratio }}% free{% else %}--{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Last Update -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-4 border border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400">Last Update</span>
|
||||
<svg class="w-4 h-4 text-gray-500" 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>
|
||||
</div>
|
||||
<div id="last-update" class="text-xs font-medium text-gray-700 dark:text-gray-300">
|
||||
Just now
|
||||
</div>
|
||||
<div id="auto-refresh-indicator" class="mt-2 flex items-center text-xs text-green-600 dark:text-green-400">
|
||||
<span class="w-2 h-2 bg-green-500 rounded-full mr-1 animate-pulse"></span>
|
||||
Auto-refresh: 30s
|
||||
</div>
|
||||
</div>
|
||||
</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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Auto-refresh status every 30 seconds
|
||||
let refreshInterval;
|
||||
const REFRESH_INTERVAL_MS = 30000; // 30 seconds
|
||||
const unit_id = '{{ unit.id }}';
|
||||
|
||||
function updateDeviceStatus() {
|
||||
fetch(`/api/slmm/${unit_id}/live`)
|
||||
.then(response => response.json())
|
||||
.then(result => {
|
||||
if (result.status === 'ok' && result.data) {
|
||||
const data = result.data;
|
||||
|
||||
// Update battery
|
||||
if (document.getElementById('battery-level')) {
|
||||
const batteryLevel = data.battery_level || '--';
|
||||
document.getElementById('battery-level').textContent = batteryLevel === '--' ? '--' : `${batteryLevel}%`;
|
||||
|
||||
// Update battery bar
|
||||
const batteryBar = document.getElementById('battery-bar');
|
||||
if (batteryBar && batteryLevel !== '--') {
|
||||
const level = parseInt(batteryLevel);
|
||||
batteryBar.style.width = `${level}%`;
|
||||
|
||||
// Color based on level
|
||||
if (level > 50) {
|
||||
batteryBar.className = 'bg-green-500 h-2 rounded-full transition-all';
|
||||
} else if (level > 20) {
|
||||
batteryBar.className = 'bg-yellow-500 h-2 rounded-full transition-all';
|
||||
} else {
|
||||
batteryBar.className = 'bg-red-500 h-2 rounded-full transition-all';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update power source
|
||||
if (document.getElementById('power-source')) {
|
||||
document.getElementById('power-source').textContent = data.power_source || '--';
|
||||
}
|
||||
|
||||
// Update SD card info
|
||||
if (document.getElementById('sd-remaining')) {
|
||||
const sdRemaining = data.sd_remaining_mb || '--';
|
||||
document.getElementById('sd-remaining').textContent = sdRemaining === '--' ? '--' : `${sdRemaining} MB`;
|
||||
}
|
||||
if (document.getElementById('sd-ratio')) {
|
||||
const sdRatio = data.sd_free_ratio || '--';
|
||||
document.getElementById('sd-ratio').textContent = sdRatio === '--' ? '--' : `${sdRatio}% free`;
|
||||
}
|
||||
|
||||
// Update last update timestamp
|
||||
if (document.getElementById('last-update')) {
|
||||
const now = new Date();
|
||||
document.getElementById('last-update').textContent = now.toLocaleTimeString();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Failed to refresh device status:', error);
|
||||
// Update last update with error indicator
|
||||
if (document.getElementById('last-update')) {
|
||||
document.getElementById('last-update').textContent = 'Update failed';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start auto-refresh
|
||||
function startAutoRefresh() {
|
||||
// Initial update
|
||||
updateDeviceStatus();
|
||||
|
||||
// Set up interval
|
||||
refreshInterval = setInterval(updateDeviceStatus, REFRESH_INTERVAL_MS);
|
||||
console.log('Auto-refresh started (30s interval)');
|
||||
}
|
||||
|
||||
// Stop auto-refresh
|
||||
function stopAutoRefresh() {
|
||||
if (refreshInterval) {
|
||||
clearInterval(refreshInterval);
|
||||
refreshInterval = null;
|
||||
console.log('Auto-refresh stopped');
|
||||
}
|
||||
}
|
||||
|
||||
// Start auto-refresh when page loads
|
||||
document.addEventListener('DOMContentLoaded', startAutoRefresh);
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.currentWebSocket) {
|
||||
window.currentWebSocket.close();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
438
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
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
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
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
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
templates/roster.html
Normal file
76
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 %}
|
||||