Merge pull request 'v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF' (#1) from claude/dev-015sto5mf2MpPCE57TbNKtaF into main
Reviewed-on: http://10.0.0.2:9010/serversdown/seismo-fleet-manager/pulls/1
41
.dockerignore
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Python cache / compiled
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
.Python
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
*.so
|
||||||
|
*.egg
|
||||||
|
*.egg-info
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
|
||||||
|
# VCS
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Databases (must live in volumes)
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
|
||||||
|
# Environment / virtualenv
|
||||||
|
.env
|
||||||
|
.venv
|
||||||
|
venv/
|
||||||
|
ENV/
|
||||||
|
|
||||||
|
# Runtime data (mounted volumes)
|
||||||
|
data/
|
||||||
|
|
||||||
|
# Editors / OS junk
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
.claude
|
||||||
|
sfm.code-workspace
|
||||||
|
|
||||||
|
# Tests (optional)
|
||||||
|
tests/
|
||||||
6
.gitignore
vendored
@@ -205,3 +205,9 @@ cython_debug/
|
|||||||
marimo/_static/
|
marimo/_static/
|
||||||
marimo/_lsp/
|
marimo/_lsp/
|
||||||
__marimo__/
|
__marimo__/
|
||||||
|
|
||||||
|
# Seismo Fleet Manager
|
||||||
|
# SQLite database files
|
||||||
|
*.db
|
||||||
|
*.db-journal
|
||||||
|
data/
|
||||||
|
|||||||
304
CHANGELOG.md
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
# 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.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.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
|
||||||
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 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
|
# Seismo Fleet Manager v0.4.0
|
||||||
Web app and backend for tracking deployed units.
|
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()
|
||||||
373
backend/main.py
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
import os
|
||||||
|
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 sqlalchemy.orm import Session
|
||||||
|
from typing import List, Dict
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from backend.database import engine, Base, get_db
|
||||||
|
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity, slmm, slm_ui
|
||||||
|
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.0"
|
||||||
|
app = FastAPI(
|
||||||
|
title="Seismo Fleet Manager",
|
||||||
|
description="Backend API for managing seismograph fleet status",
|
||||||
|
version=VERSION
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
# ===== 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("/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()
|
||||||
107
backend/models.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
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)
|
||||||
|
deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit
|
||||||
|
|
||||||
|
# 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_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}
|
||||||
529
backend/routers/roster_edit.py
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from datetime import datetime, date
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
|
||||||
|
from backend.database import get_db
|
||||||
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/add")
|
||||||
|
def add_roster_unit(
|
||||||
|
id: str = Form(...),
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: bool = Form(False),
|
||||||
|
retired: bool = Form(False),
|
||||||
|
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),
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
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,
|
||||||
|
retired=retired,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
db.add(unit)
|
||||||
|
db.commit()
|
||||||
|
return {"message": "Unit added", "id": id, "device_type": device_type}
|
||||||
|
|
||||||
|
|
||||||
|
@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 "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/edit/{unit_id}")
|
||||||
|
def edit_roster_unit(
|
||||||
|
unit_id: str,
|
||||||
|
device_type: str = Form("seismograph"),
|
||||||
|
unit_type: str = Form("series3"),
|
||||||
|
deployed: bool = Form(False),
|
||||||
|
retired: bool = Form(False),
|
||||||
|
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),
|
||||||
|
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")
|
||||||
|
|
||||||
|
# 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
|
||||||
|
unit.retired = retired
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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}
|
||||||
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)}")
|
||||||
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"
|
||||||
|
})
|
||||||
130
backend/routers/slmm.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
|
import httpx
|
||||||
|
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")
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
154
backend/services/snapshot.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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 emit_status_snapshot():
|
||||||
|
"""
|
||||||
|
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||||
|
"""
|
||||||
|
|
||||||
|
db = get_db_session()
|
||||||
|
try:
|
||||||
|
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:
|
||||||
|
status = e.status
|
||||||
|
last_seen = ensure_utc(e.last_seen)
|
||||||
|
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)
|
||||||
|
units[unit_id] = {
|
||||||
|
"id": unit_id,
|
||||||
|
"status": e.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;
|
||||||
352
backend/static/offline-db.js
Normal file
@@ -0,0 +1,352 @@
|
|||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Export for use in other scripts
|
||||||
|
export default OfflineDB;
|
||||||
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"
|
||||||
|
}
|
||||||
44
docker-compose.yml
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
# --- PRODUCTION ---
|
||||||
|
seismo-backend:
|
||||||
|
build: .
|
||||||
|
container_name: seismo-fleet-manager
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- ENVIRONMENT=production
|
||||||
|
- SLMM_BASE_URL=http://172.19.0.1:8100
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# --- DEVELOPMENT ---
|
||||||
|
sfm-dev:
|
||||||
|
build: .
|
||||||
|
container_name: sfm-dev
|
||||||
|
ports:
|
||||||
|
- "1001:8001"
|
||||||
|
volumes:
|
||||||
|
- ./data-dev:/app/data
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- ENVIRONMENT=development
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
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
|
||||||
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
|
||||||
|
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()
|
||||||
7
sfm.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": ".."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
370
templates/base.html
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
<!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>
|
||||||
|
Fleet Roster
|
||||||
|
</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>Roster</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>
|
||||||
620
templates/dashboard.html
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
{% 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">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;
|
||||||
|
|
||||||
|
// ===== 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 %}
|
||||||
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>
|
||||||
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>
|
||||||
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 %}
|
||||||
915
templates/roster.html
Normal file
@@ -0,0 +1,915 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||||
|
</svg>
|
||||||
|
Add Unit
|
||||||
|
</button>
|
||||||
|
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||||
|
</svg>
|
||||||
|
Import CSV
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unknown Emitters Section -->
|
||||||
|
<div hx-get="/partials/unknown-emitters" hx-trigger="load, every 10s" hx-swap="innerHTML">
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fleet Roster with Tabs -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="roster-search"
|
||||||
|
placeholder="Search by Unit ID, Type, or Note..."
|
||||||
|
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
onkeyup="filterRosterTable()">
|
||||||
|
<svg class="absolute left-3 top-3 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>
|
||||||
|
|
||||||
|
<!-- 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 roster-tab-button active-roster-tab"
|
||||||
|
data-endpoint="/partials/roster-deployed"
|
||||||
|
hx-get="/partials/roster-deployed"
|
||||||
|
hx-target="#roster-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Deployed
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||||
|
data-endpoint="/partials/roster-benched"
|
||||||
|
hx-get="/partials/roster-benched"
|
||||||
|
hx-target="#roster-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Benched
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||||
|
data-endpoint="/partials/roster-retired"
|
||||||
|
hx-get="/partials/roster-retired"
|
||||||
|
hx-target="#roster-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Retired
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="px-4 py-2 text-sm font-medium roster-tab-button"
|
||||||
|
data-endpoint="/partials/roster-ignored"
|
||||||
|
hx-get="/partials/roster-ignored"
|
||||||
|
hx-target="#roster-content"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
Ignored
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab Content Target -->
|
||||||
|
<div id="roster-content"
|
||||||
|
hx-get="/partials/roster-deployed"
|
||||||
|
hx-trigger="load"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Loading roster data...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Unit Modal -->
|
||||||
|
<div id="addUnitModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Add Unit</h2>
|
||||||
|
<button onclick="closeAddUnitModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
|
||||||
|
<input type="text" name="id" required
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
placeholder="BE1234">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
|
||||||
|
<select name="device_type" id="deviceTypeSelect" onchange="toggleDeviceFields()"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="modem">Modem</option>
|
||||||
|
<option value="sound_level_meter">Sound Level Meter</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
||||||
|
<input type="text" name="unit_type" value="series3"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
|
<input type="text" name="project_id"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
placeholder="PROJ-001">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||||
|
<input type="text" name="location"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
placeholder="San Francisco, CA">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph-specific fields -->
|
||||||
|
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||||
|
<input type="date" name="last_calibrated"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||||
|
<input type="date" name="next_calibration_due"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Typically 1 year after last calibration</p>
|
||||||
|
</div>
|
||||||
|
<div id="modemPairingField" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
|
<input type="text" name="deployed_with_modem_id" placeholder="Modem ID"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Only needed when deployed</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem-specific fields -->
|
||||||
|
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Modem Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
|
||||||
|
<input type="text" name="ip_address" placeholder="192.168.1.100"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
|
||||||
|
<input type="text" name="phone_number" placeholder="+1-555-0123"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
||||||
|
<input type="text" name="hardware_model" placeholder="e.g., Raven XTV"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sound Level Meter-specific fields -->
|
||||||
|
<div id="slmFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Sound Level Meter Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">SLM Model</label>
|
||||||
|
<input type="text" name="slm_model" placeholder="NL-43"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Host/IP Address</label>
|
||||||
|
<input type="text" name="slm_host" placeholder="192.168.1.100"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">TCP Port</label>
|
||||||
|
<input type="number" name="slm_tcp_port" placeholder="2255"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Serial Number</label>
|
||||||
|
<input type="text" name="slm_serial_number" placeholder="SN123456"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency Weighting</label>
|
||||||
|
<select name="slm_frequency_weighting"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="A">A-weighting</option>
|
||||||
|
<option value="C">C-weighting</option>
|
||||||
|
<option value="Z">Z-weighting (Flat)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Time Weighting</label>
|
||||||
|
<select name="slm_time_weighting"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="F">F (Fast)</option>
|
||||||
|
<option value="S">S (Slow)</option>
|
||||||
|
<option value="I">I (Impulse)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="deployed" id="deployedCheckbox" value="true" checked onchange="toggleModemPairing()"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="retired" id="retiredCheckbox" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea name="note" rows="3"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
placeholder="Additional notes..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
|
Add Unit
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeAddUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Unit Modal -->
|
||||||
|
<div id="editUnitModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Edit Unit</h2>
|
||||||
|
<button onclick="closeEditUnitModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="editUnitForm" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID</label>
|
||||||
|
<input type="text" name="id" id="editUnitId" readonly
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type *</label>
|
||||||
|
<select name="device_type" id="editDeviceTypeSelect" onchange="toggleEditDeviceFields()"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="modem">Modem</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
||||||
|
<input type="text" name="unit_type" id="editUnitType"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
|
<input type="text" name="project_id" id="editProjectId"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" name="address" id="editAddress" placeholder="123 Main St, City, State"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="editCoordinates" placeholder="34.0522,-118.2437"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph-specific fields -->
|
||||||
|
<div id="editSeismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Seismograph Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||||
|
<input type="date" name="last_calibrated" id="editLastCalibrated"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||||
|
<input type="date" name="next_calibration_due" id="editNextCalibrationDue"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div id="editModemPairingField" class="hidden">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
|
<input type="text" name="deployed_with_modem_id" id="editDeployedWithModemId" placeholder="Modem ID"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem-specific fields -->
|
||||||
|
<div id="editModemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<p class="text-sm font-semibold text-gray-700 dark:text-gray-300">Modem Information</p>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
|
||||||
|
<input type="text" name="ip_address" id="editIpAddress" placeholder="192.168.1.100"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
|
||||||
|
<input type="text" name="phone_number" id="editPhoneNumber" placeholder="+1-555-0123"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
||||||
|
<input type="text" name="hardware_model" id="editHardwareModel" placeholder="e.g., Raven XTV"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="deployed" id="editDeployedCheckbox" value="true" onchange="toggleEditModemPairing()"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="retired" id="editRetiredCheckbox" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea name="note" id="editNote" rows="3"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeEditUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Import CSV Modal -->
|
||||||
|
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
||||||
|
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Import CSV</h2>
|
||||||
|
<button onclick="closeImportModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form id="importForm" class="p-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File *</label>
|
||||||
|
<input type="file" name="file" accept=".csv" required
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Format: unit_id,unit_type,deployed,retired,note,project_id,location</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<input type="checkbox" name="update_existing" id="updateExisting" checked
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<label for="updateExisting" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Update existing units</label>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3 pt-4">
|
||||||
|
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors">
|
||||||
|
Import
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="closeImportModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="importResult" class="hidden mt-4 p-4 rounded-lg"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Add Unit Modal
|
||||||
|
function openAddUnitModal() {
|
||||||
|
document.getElementById('addUnitModal').classList.remove('hidden');
|
||||||
|
toggleDeviceFields(); // Initialize field visibility
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddUnitModal() {
|
||||||
|
document.getElementById('addUnitModal').classList.add('hidden');
|
||||||
|
document.getElementById('addUnitForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle device-specific fields based on device type selection
|
||||||
|
function toggleDeviceFields() {
|
||||||
|
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||||
|
const seismoFields = document.getElementById('seismographFields');
|
||||||
|
const modemFields = document.getElementById('modemFields');
|
||||||
|
const slmFields = document.getElementById('slmFields');
|
||||||
|
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoFields.classList.remove('hidden');
|
||||||
|
modemFields.classList.add('hidden');
|
||||||
|
slmFields.classList.add('hidden');
|
||||||
|
toggleModemPairing(); // Check if modem pairing should be shown
|
||||||
|
} else if (deviceType === 'modem') {
|
||||||
|
seismoFields.classList.add('hidden');
|
||||||
|
modemFields.classList.remove('hidden');
|
||||||
|
slmFields.classList.add('hidden');
|
||||||
|
} else if (deviceType === 'sound_level_meter') {
|
||||||
|
seismoFields.classList.add('hidden');
|
||||||
|
modemFields.classList.add('hidden');
|
||||||
|
slmFields.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle modem pairing field visibility (only for deployed seismographs)
|
||||||
|
function toggleModemPairing() {
|
||||||
|
const deviceType = document.getElementById('deviceTypeSelect').value;
|
||||||
|
const deployedCheckbox = document.getElementById('deployedCheckbox');
|
||||||
|
const modemPairingField = document.getElementById('modemPairingField');
|
||||||
|
|
||||||
|
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
|
||||||
|
modemPairingField.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modemPairingField.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add unknown unit to roster
|
||||||
|
function addUnknownUnit(unitId) {
|
||||||
|
openAddUnitModal();
|
||||||
|
// Pre-fill the unit ID
|
||||||
|
document.querySelector('#addUnitForm input[name="id"]').value = unitId;
|
||||||
|
// Set deployed to true by default
|
||||||
|
document.querySelector('#addUnitForm input[name="deployed"]').checked = true;
|
||||||
|
// Trigger field visibility updates
|
||||||
|
toggleModemPairing(); // Show modem pairing field for deployed seismographs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore unknown unit
|
||||||
|
async function ignoreUnknownUnit(unitId) {
|
||||||
|
if (!confirm(`Ignore unit ${unitId}? It will no longer appear in the unknown emitters list.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('reason', 'Ignored from unknown emitters');
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/ignore/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger refresh of unknown emitters
|
||||||
|
htmx.trigger(document.querySelector('[hx-get="/partials/unknown-emitters"]'), 'load');
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error ignoring unit: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error ignoring unit: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import Modal
|
||||||
|
function openImportModal() {
|
||||||
|
document.getElementById('importModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeImportModal() {
|
||||||
|
document.getElementById('importModal').classList.add('hidden');
|
||||||
|
document.getElementById('importForm').reset();
|
||||||
|
document.getElementById('importResult').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Add Unit form submission
|
||||||
|
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||||
|
if (event.detail.successful) {
|
||||||
|
closeAddUnitModal();
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
// Show success message
|
||||||
|
alert('Unit added successfully!');
|
||||||
|
} else {
|
||||||
|
alert('Error adding unit. Please check the form and try again.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Edit Unit Modal Functions
|
||||||
|
function openEditUnitModal() {
|
||||||
|
document.getElementById('editUnitModal').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditUnitModal() {
|
||||||
|
document.getElementById('editUnitModal').classList.add('hidden');
|
||||||
|
document.getElementById('editUnitForm').reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle device-specific fields in edit modal
|
||||||
|
function toggleEditDeviceFields() {
|
||||||
|
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||||
|
const seismoFields = document.getElementById('editSeismographFields');
|
||||||
|
const modemFields = document.getElementById('editModemFields');
|
||||||
|
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoFields.classList.remove('hidden');
|
||||||
|
modemFields.classList.add('hidden');
|
||||||
|
toggleEditModemPairing();
|
||||||
|
} else {
|
||||||
|
seismoFields.classList.add('hidden');
|
||||||
|
modemFields.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle modem pairing field in edit modal
|
||||||
|
function toggleEditModemPairing() {
|
||||||
|
const deviceType = document.getElementById('editDeviceTypeSelect').value;
|
||||||
|
const deployedCheckbox = document.getElementById('editDeployedCheckbox');
|
||||||
|
const modemPairingField = document.getElementById('editModemPairingField');
|
||||||
|
|
||||||
|
if (deviceType === 'seismograph' && deployedCheckbox.checked) {
|
||||||
|
modemPairingField.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
modemPairingField.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit Unit - Fetch data and populate form
|
||||||
|
async function editUnit(unitId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch unit data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const unit = await response.json();
|
||||||
|
|
||||||
|
// Populate form fields
|
||||||
|
document.getElementById('editUnitId').value = unit.id;
|
||||||
|
document.getElementById('editDeviceTypeSelect').value = unit.device_type;
|
||||||
|
document.getElementById('editUnitType').value = unit.unit_type;
|
||||||
|
document.getElementById('editProjectId').value = unit.project_id;
|
||||||
|
document.getElementById('editAddress').value = unit.address;
|
||||||
|
document.getElementById('editCoordinates').value = unit.coordinates;
|
||||||
|
document.getElementById('editNote').value = unit.note;
|
||||||
|
|
||||||
|
// Checkboxes
|
||||||
|
document.getElementById('editDeployedCheckbox').checked = unit.deployed;
|
||||||
|
document.getElementById('editRetiredCheckbox').checked = unit.retired;
|
||||||
|
|
||||||
|
// Seismograph fields
|
||||||
|
document.getElementById('editLastCalibrated').value = unit.last_calibrated;
|
||||||
|
document.getElementById('editNextCalibrationDue').value = unit.next_calibration_due;
|
||||||
|
document.getElementById('editDeployedWithModemId').value = unit.deployed_with_modem_id;
|
||||||
|
|
||||||
|
// Modem fields
|
||||||
|
document.getElementById('editIpAddress').value = unit.ip_address;
|
||||||
|
document.getElementById('editPhoneNumber').value = unit.phone_number;
|
||||||
|
document.getElementById('editHardwareModel').value = unit.hardware_model;
|
||||||
|
|
||||||
|
// Store unit ID for form submission
|
||||||
|
document.getElementById('editUnitForm').dataset.unitId = unitId;
|
||||||
|
|
||||||
|
// Show/hide fields based on device type
|
||||||
|
toggleEditDeviceFields();
|
||||||
|
|
||||||
|
// Open modal
|
||||||
|
openEditUnitModal();
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error loading unit data: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle Edit Unit form submission
|
||||||
|
document.getElementById('editUnitForm').addEventListener('submit', async function(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
const unitId = this.dataset.unitId;
|
||||||
|
if (!unitId) {
|
||||||
|
alert('Error: Unit ID not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
closeEditUnitModal();
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
alert('Unit updated successfully!');
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error updating unit: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle Deployed Status
|
||||||
|
async function toggleDeployed(unitId, deployed) {
|
||||||
|
const action = deployed ? 'deploy' : 'bench';
|
||||||
|
if (!confirm(`Are you sure you want to ${action} unit ${unitId}?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('deployed', deployed);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
alert(`Unit ${deployed ? 'deployed' : 'benched'} successfully!`);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to Ignore List
|
||||||
|
async function moveToIgnore(unitId) {
|
||||||
|
const reason = prompt(`Why are you ignoring unit ${unitId}?`, '');
|
||||||
|
if (reason === null) {
|
||||||
|
return; // User cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('reason', reason);
|
||||||
|
|
||||||
|
const response = await fetch(`/api/roster/ignore/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
alert(`Unit ${unitId} moved to ignore list`);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete Unit
|
||||||
|
async function deleteUnit(unitId) {
|
||||||
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
alert(`Unit ${unitId} deleted successfully`);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle CSV Import
|
||||||
|
document.getElementById('importForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
const resultDiv = document.getElementById('importResult');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/roster/import-csv', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<p class="font-semibold mb-2">Import Successful!</p>
|
||||||
|
<ul class="text-sm space-y-1">
|
||||||
|
<li>✅ Added: ${result.summary.added}</li>
|
||||||
|
<li>🔄 Updated: ${result.summary.updated}</li>
|
||||||
|
<li>⏭️ Skipped: ${result.summary.skipped}</li>
|
||||||
|
<li>❌ Errors: ${result.summary.errors}</li>
|
||||||
|
</ul>
|
||||||
|
`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
// Trigger roster refresh for current active tab
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close modal after 2 seconds
|
||||||
|
setTimeout(() => closeImportModal(), 2000);
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${result.detail || 'Unknown error'}</p>`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${error.message}</p>`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle roster tab switching with auto-refresh
|
||||||
|
let currentRosterEndpoint = '/partials/roster-deployed'; // Default to deployed tab
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabButtons = document.querySelectorAll('.roster-tab-button');
|
||||||
|
|
||||||
|
tabButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', function() {
|
||||||
|
// Remove active-roster-tab class from all buttons
|
||||||
|
tabButtons.forEach(btn => btn.classList.remove('active-roster-tab'));
|
||||||
|
// Add active-roster-tab class to clicked button
|
||||||
|
this.classList.add('active-roster-tab');
|
||||||
|
|
||||||
|
// Update current endpoint for auto-refresh
|
||||||
|
currentRosterEndpoint = this.getAttribute('data-endpoint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Auto-refresh the current active tab every 10 seconds
|
||||||
|
setInterval(() => {
|
||||||
|
const rosterContent = document.getElementById('roster-content');
|
||||||
|
if (rosterContent) {
|
||||||
|
// Use HTMX to trigger a refresh of the current endpoint
|
||||||
|
htmx.ajax('GET', currentRosterEndpoint, {
|
||||||
|
target: '#roster-content',
|
||||||
|
swap: 'innerHTML'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 10000); // 10 seconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Un-ignore Unit (remove from ignored list)
|
||||||
|
async function unignoreUnit(unitId) {
|
||||||
|
if (!confirm(`Remove unit ${unitId} from ignore list?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/ignore/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger ignored tab refresh
|
||||||
|
htmx.trigger(document.querySelector('[hx-get="/partials/roster-ignored"]'), 'load');
|
||||||
|
alert(`Unit ${unitId} removed from ignore list`);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete ignored unit completely (from emitters table)
|
||||||
|
async function deleteIgnoredUnit(unitId) {
|
||||||
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis will remove it from the ignore list and delete all records.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First remove from ignore list
|
||||||
|
await fetch(`/api/roster/ignore/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Then delete the unit
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Trigger ignored tab refresh
|
||||||
|
htmx.trigger(document.querySelector('[hx-get="/partials/roster-ignored"]'), 'load');
|
||||||
|
alert(`Unit ${unitId} deleted successfully`);
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter roster table based on search input
|
||||||
|
function filterRosterTable() {
|
||||||
|
const searchInput = document.getElementById('roster-search').value.toLowerCase();
|
||||||
|
const table = document.querySelector('#roster-content table tbody');
|
||||||
|
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const rows = table.getElementsByTagName('tr');
|
||||||
|
|
||||||
|
for (let row of rows) {
|
||||||
|
const cells = row.getElementsByTagName('td');
|
||||||
|
if (cells.length === 0) continue; // Skip header or empty rows
|
||||||
|
|
||||||
|
const unitId = cells[1]?.textContent?.toLowerCase() || '';
|
||||||
|
const unitType = cells[2]?.textContent?.toLowerCase() || '';
|
||||||
|
const note = cells[6]?.textContent?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const matches = unitId.includes(searchInput) ||
|
||||||
|
unitType.includes(searchInput) ||
|
||||||
|
note.includes(searchInput);
|
||||||
|
|
||||||
|
row.style.display = matches ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.roster-tab-button {
|
||||||
|
color: #6b7280; /* gray-500 */
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.roster-tab-button:hover {
|
||||||
|
color: #374151; /* gray-700 */
|
||||||
|
}
|
||||||
|
.active-roster-tab {
|
||||||
|
color: #f48b1c !important; /* seismo orange */
|
||||||
|
border-bottom: 2px solid #f48b1c !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
1376
templates/settings.html
Normal file
195
templates/slm_detail.html
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ unit_id }} - Sound Level Meter{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-orange-dark flex items-center">
|
||||||
|
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
Back to Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
|
||||||
|
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
{{ unit_id }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
{{ unit.slm_model or 'NL-43' }} Sound Level Meter
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full text-sm font-medium
|
||||||
|
{% if unit.deployed %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||||
|
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
|
||||||
|
{% if unit.deployed %}Deployed{% else %}Benched{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Control Panel -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Control Panel</h2>
|
||||||
|
<div hx-get="/slm/partials/{{ unit_id }}/controls"
|
||||||
|
hx-trigger="load, every 5s"
|
||||||
|
hx-swap="innerHTML">
|
||||||
|
<div class="text-center py-8 text-gray-500">Loading controls...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Real-time Data Stream -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Real-time Measurements</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div id="slm-stream-container">
|
||||||
|
<div class="text-center py-8">
|
||||||
|
<button onclick="startStream()"
|
||||||
|
id="stream-start-btn"
|
||||||
|
class="px-6 py-3 bg-seismo-orange text-white rounded-lg hover:bg-seismo-orange-dark transition-colors">
|
||||||
|
Start Real-time Stream
|
||||||
|
</button>
|
||||||
|
<p class="text-sm text-gray-500 mt-2">Click to begin streaming live measurement data</p>
|
||||||
|
</div>
|
||||||
|
<div id="stream-data" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lp (Instant)</div>
|
||||||
|
<div id="stream-lp" class="text-3xl font-bold text-gray-900 dark:text-white">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Leq (Average)</div>
|
||||||
|
<div id="stream-leq" class="text-3xl font-bold text-blue-600 dark:text-blue-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmax</div>
|
||||||
|
<div id="stream-lmax" class="text-3xl font-bold text-red-600 dark:text-red-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 dark:bg-gray-900 rounded-lg p-4">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">Lmin</div>
|
||||||
|
<div id="stream-lmin" class="text-3xl font-bold text-green-600 dark:text-green-400">--</div>
|
||||||
|
<div class="text-xs text-gray-500">dB</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="text-xs text-gray-500">
|
||||||
|
<span class="inline-block w-2 h-2 bg-green-500 rounded-full mr-2 animate-pulse"></span>
|
||||||
|
Streaming
|
||||||
|
</div>
|
||||||
|
<button onclick="stopStream()"
|
||||||
|
class="px-4 py-2 bg-red-600 text-white text-sm rounded-lg hover:bg-red-700 transition-colors">
|
||||||
|
Stop Stream
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Device Information -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Device Information</h2>
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Model</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_model or 'NL-43' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Serial Number</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_serial_number or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Host</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_host or 'Not configured' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">TCP Port</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_tcp_port or 'N/A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Frequency Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_frequency_weighting or 'A' }}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Time Weighting</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.slm_time_weighting or 'F (Fast)' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Location</div>
|
||||||
|
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ unit.address or unit.location or 'Not specified' }}</div>
|
||||||
|
</div>
|
||||||
|
{% if unit.note %}
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
|
||||||
|
<div class="text-gray-900 dark:text-white">{{ unit.note }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
function startStream() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const wsUrl = `${protocol}//${window.location.host}/api/slmm/{{ unit_id }}/stream`;
|
||||||
|
|
||||||
|
ws = new WebSocket(wsUrl);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
document.getElementById('stream-start-btn').classList.add('hidden');
|
||||||
|
document.getElementById('stream-data').classList.remove('hidden');
|
||||||
|
console.log('WebSocket connected');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.error) {
|
||||||
|
console.error('Stream error:', data.error);
|
||||||
|
stopStream();
|
||||||
|
alert('Error: ' + data.error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update values
|
||||||
|
document.getElementById('stream-lp').textContent = data.lp || '--';
|
||||||
|
document.getElementById('stream-leq').textContent = data.leq || '--';
|
||||||
|
document.getElementById('stream-lmax').textContent = data.lmax || '--';
|
||||||
|
document.getElementById('stream-lmin').textContent = data.lmin || '--';
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error:', error);
|
||||||
|
stopStream();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStream() {
|
||||||
|
if (ws) {
|
||||||
|
ws.close();
|
||||||
|
ws = null;
|
||||||
|
}
|
||||||
|
document.getElementById('stream-start-btn').classList.remove('hidden');
|
||||||
|
document.getElementById('stream-data').classList.add('hidden');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
899
templates/unit_detail.html
Normal file
@@ -0,0 +1,899 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Unit Detail - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mb-6">
|
||||||
|
<!-- Breadcrumb Navigation -->
|
||||||
|
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||||
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
|
<li class="inline-flex items-center">
|
||||||
|
<a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/roster" class="ml-1 text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
||||||
|
Fleet Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li aria-current="page">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 text-gray-700 dark:text-gray-300 font-medium" id="breadcrumb-unit">Unit</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
|
||||||
|
<button onclick="copyToClipboard(window.currentUnitId, this)" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1" title="Copy Unit ID">
|
||||||
|
<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 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
Edit Unit
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteUnit()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<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>
|
||||||
|
Delete Unit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading state -->
|
||||||
|
<div id="loadingState" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-12 text-center">
|
||||||
|
<div class="animate-pulse">
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4 mx-auto mb-4"></div>
|
||||||
|
<div class="h-4 bg-gray-200 dark:bg-gray-700 rounded w-1/2 mx-auto"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content (hidden until loaded) -->
|
||||||
|
<div id="mainContent" class="hidden space-y-6">
|
||||||
|
<!-- Status Card -->
|
||||||
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex justify-between items-start mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Status</h2>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<span id="statusIndicator" class="w-3 h-3 rounded-full"></span>
|
||||||
|
<span id="statusText" class="font-semibold"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Last Seen</span>
|
||||||
|
<p id="lastSeen" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Age</span>
|
||||||
|
<p id="age" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed</span>
|
||||||
|
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
|
||||||
|
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Location Map -->
|
||||||
|
<div id="mapCard" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 hidden">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Location</h2>
|
||||||
|
<div id="unit-map" style="height: 400px; width: 100%;" class="rounded-lg mb-4"></div>
|
||||||
|
<p id="locationText" class="text-sm text-gray-500 dark:text-gray-400"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- View Mode: Unit Information (Default) -->
|
||||||
|
<div id="viewMode" class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Unit Information</h2>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Basic Info Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Device Type</label>
|
||||||
|
<p id="viewDeviceType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Unit Type</label>
|
||||||
|
<p id="viewUnitType" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Project ID</label>
|
||||||
|
<p id="viewProjectId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Address</label>
|
||||||
|
<p id="viewAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Coordinates</label>
|
||||||
|
<p id="viewCoordinates" class="mt-1 text-gray-900 dark:text-white font-medium font-mono text-sm">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph Info -->
|
||||||
|
<div id="viewSeismographFields" class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Seismograph Information</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Calibrated</label>
|
||||||
|
<p id="viewLastCalibrated" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Calibration Due</label>
|
||||||
|
<p id="viewNextCalibrationDue" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Deployed With Modem</label>
|
||||||
|
<p id="viewDeployedWithModemId" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem Info -->
|
||||||
|
<div id="viewModemFields" class="hidden border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Modem Information</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">IP Address</label>
|
||||||
|
<p id="viewIpAddress" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Phone Number</label>
|
||||||
|
<p id="viewPhoneNumber" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Hardware Model</label>
|
||||||
|
<p id="viewHardwareModel" class="mt-1 text-gray-900 dark:text-white font-medium">--</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit History Timeline -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||||
|
<div id="historyTimeline" class="space-y-3">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Photos -->
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||||
|
<div class="flex justify-between items-start mb-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-2">
|
||||||
|
<!-- Take Photo Button (Camera) -->
|
||||||
|
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||||
|
<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 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="hidden sm:inline">Take Photo</span>
|
||||||
|
<span class="sm:hidden">Camera</span>
|
||||||
|
</label>
|
||||||
|
<!-- Choose from Library Button -->
|
||||||
|
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||||
|
<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 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>
|
||||||
|
<span class="hidden sm:inline">Choose Photo</span>
|
||||||
|
<span class="sm:hidden">Library</span>
|
||||||
|
</label>
|
||||||
|
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||||
|
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
|
||||||
|
</div>
|
||||||
|
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Edit Mode: Unit Information Form (Hidden by default) -->
|
||||||
|
<div id="editMode" class="hidden rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Edit Unit Information</h2>
|
||||||
|
<button onclick="cancelEdit()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="editForm" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Device Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Device Type</label>
|
||||||
|
<select name="device_type" id="deviceType" onchange="toggleDetailFields()"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
<option value="seismograph">Seismograph</option>
|
||||||
|
<option value="modem">Modem</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Unit Type -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
||||||
|
<input type="text" name="unit_type" id="unitType"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project ID -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||||
|
<input type="text" name="project_id" id="projectId"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Address -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
|
||||||
|
<input type="text" name="address" id="address" placeholder="123 Main St, City, State"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Coordinates -->
|
||||||
|
<div class="md:col-span-2">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
|
||||||
|
<input type="text" name="coordinates" id="coordinates" placeholder="34.0522,-118.2437"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange font-mono">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Seismograph Fields -->
|
||||||
|
<div id="seismographFields" class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Seismograph Information</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Last Calibrated</label>
|
||||||
|
<input type="date" name="last_calibrated" id="lastCalibrated"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Calibration Due</label>
|
||||||
|
<input type="date" name="next_calibration_due" id="nextCalibrationDue"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Deployed With Modem</label>
|
||||||
|
<input type="text" name="deployed_with_modem_id" id="deployedWithModemId" placeholder="Modem ID"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modem Fields -->
|
||||||
|
<div id="modemFields" class="hidden space-y-4 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Modem Information</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">IP Address</label>
|
||||||
|
<input type="text" name="ip_address" id="ipAddress" placeholder="192.168.1.100"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Phone Number</label>
|
||||||
|
<input type="text" name="phone_number" id="phoneNumber" placeholder="+1-555-0123"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Hardware Model</label>
|
||||||
|
<input type="text" name="hardware_model" id="hardwareModel" placeholder="e.g., Raven XTV"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Checkboxes -->
|
||||||
|
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="deployed" id="deployed" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="retired" id="retired" value="true"
|
||||||
|
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notes -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||||
|
<textarea name="note" id="note" rows="4"
|
||||||
|
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save/Cancel Buttons -->
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<button type="submit" class="flex-1 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg font-medium transition-colors">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="cancelEdit()" class="px-6 py-3 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg font-medium transition-colors">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const unitId = "{{ unit_id }}";
|
||||||
|
let currentUnit = null;
|
||||||
|
let currentSnapshot = null;
|
||||||
|
let unitMap = null;
|
||||||
|
let mapMarker = null;
|
||||||
|
|
||||||
|
// Load unit data on page load
|
||||||
|
async function loadUnitData() {
|
||||||
|
try {
|
||||||
|
// Fetch unit roster data
|
||||||
|
const rosterResponse = await fetch(`/api/roster/${unitId}`);
|
||||||
|
if (!rosterResponse.ok) {
|
||||||
|
throw new Error('Unit not found');
|
||||||
|
}
|
||||||
|
currentUnit = await rosterResponse.json();
|
||||||
|
|
||||||
|
// Fetch snapshot data for status info
|
||||||
|
const snapshotResponse = await fetch('/api/status-snapshot');
|
||||||
|
if (snapshotResponse.ok) {
|
||||||
|
currentSnapshot = await snapshotResponse.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate views
|
||||||
|
populateViewMode();
|
||||||
|
populateEditForm();
|
||||||
|
|
||||||
|
// Hide loading, show content
|
||||||
|
document.getElementById('loadingState').classList.add('hidden');
|
||||||
|
document.getElementById('mainContent').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Initialize map after content is visible
|
||||||
|
setTimeout(() => {
|
||||||
|
initUnitMap();
|
||||||
|
}, 100);
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error loading unit: ${error.message}`);
|
||||||
|
window.location.href = '/roster';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate view mode (read-only display)
|
||||||
|
function populateViewMode() {
|
||||||
|
// Update page title and store unit ID for copy function
|
||||||
|
window.currentUnitId = currentUnit.id;
|
||||||
|
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
|
||||||
|
document.getElementById('breadcrumb-unit').textContent = currentUnit.id;
|
||||||
|
|
||||||
|
// Get status info from snapshot
|
||||||
|
let unitStatus = null;
|
||||||
|
if (currentSnapshot && currentSnapshot.units) {
|
||||||
|
unitStatus = currentSnapshot.units[unitId];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status card
|
||||||
|
if (unitStatus) {
|
||||||
|
const statusColors = {
|
||||||
|
'OK': 'bg-green-500',
|
||||||
|
'Pending': 'bg-yellow-500',
|
||||||
|
'Missing': 'bg-red-500'
|
||||||
|
};
|
||||||
|
const statusTextColors = {
|
||||||
|
'OK': 'text-green-600 dark:text-green-400',
|
||||||
|
'Pending': 'text-yellow-600 dark:text-yellow-400',
|
||||||
|
'Missing': 'text-red-600 dark:text-red-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
|
||||||
|
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
|
||||||
|
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
|
||||||
|
|
||||||
|
// Format "Last Seen" with timezone-aware formatting
|
||||||
|
if (unitStatus.last && typeof formatFullTimestamp === 'function') {
|
||||||
|
document.getElementById('lastSeen').textContent = formatFullTimestamp(unitStatus.last);
|
||||||
|
} else {
|
||||||
|
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||||
|
} else {
|
||||||
|
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
||||||
|
document.getElementById('statusText').className = 'font-semibold text-gray-600 dark:text-gray-400';
|
||||||
|
document.getElementById('statusText').textContent = 'No status data';
|
||||||
|
document.getElementById('lastSeen').textContent = '--';
|
||||||
|
document.getElementById('age').textContent = '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
|
||||||
|
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
|
||||||
|
|
||||||
|
// Basic info
|
||||||
|
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
|
||||||
|
document.getElementById('viewUnitType').textContent = currentUnit.unit_type || '--';
|
||||||
|
document.getElementById('viewProjectId').textContent = currentUnit.project_id || '--';
|
||||||
|
document.getElementById('viewAddress').textContent = currentUnit.address || '--';
|
||||||
|
document.getElementById('viewCoordinates').textContent = currentUnit.coordinates || '--';
|
||||||
|
|
||||||
|
// Seismograph fields
|
||||||
|
document.getElementById('viewLastCalibrated').textContent = currentUnit.last_calibrated || '--';
|
||||||
|
document.getElementById('viewNextCalibrationDue').textContent = currentUnit.next_calibration_due || '--';
|
||||||
|
document.getElementById('viewDeployedWithModemId').textContent = currentUnit.deployed_with_modem_id || '--';
|
||||||
|
|
||||||
|
// Modem fields
|
||||||
|
document.getElementById('viewIpAddress').textContent = currentUnit.ip_address || '--';
|
||||||
|
document.getElementById('viewPhoneNumber').textContent = currentUnit.phone_number || '--';
|
||||||
|
document.getElementById('viewHardwareModel').textContent = currentUnit.hardware_model || '--';
|
||||||
|
|
||||||
|
// Notes
|
||||||
|
document.getElementById('viewNote').textContent = currentUnit.note || '--';
|
||||||
|
|
||||||
|
// Show/hide fields based on device type
|
||||||
|
if (currentUnit.device_type === 'modem') {
|
||||||
|
document.getElementById('viewSeismographFields').classList.add('hidden');
|
||||||
|
document.getElementById('viewModemFields').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('viewSeismographFields').classList.remove('hidden');
|
||||||
|
document.getElementById('viewModemFields').classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate edit form
|
||||||
|
function populateEditForm() {
|
||||||
|
document.getElementById('deviceType').value = currentUnit.device_type || 'seismograph';
|
||||||
|
document.getElementById('unitType').value = currentUnit.unit_type || '';
|
||||||
|
document.getElementById('projectId').value = currentUnit.project_id || '';
|
||||||
|
document.getElementById('address').value = currentUnit.address || '';
|
||||||
|
document.getElementById('coordinates').value = currentUnit.coordinates || '';
|
||||||
|
document.getElementById('deployed').checked = currentUnit.deployed;
|
||||||
|
document.getElementById('retired').checked = currentUnit.retired;
|
||||||
|
document.getElementById('note').value = currentUnit.note || '';
|
||||||
|
|
||||||
|
// Seismograph fields
|
||||||
|
document.getElementById('lastCalibrated').value = currentUnit.last_calibrated || '';
|
||||||
|
document.getElementById('nextCalibrationDue').value = currentUnit.next_calibration_due || '';
|
||||||
|
document.getElementById('deployedWithModemId').value = currentUnit.deployed_with_modem_id || '';
|
||||||
|
|
||||||
|
// Modem fields
|
||||||
|
document.getElementById('ipAddress').value = currentUnit.ip_address || '';
|
||||||
|
document.getElementById('phoneNumber').value = currentUnit.phone_number || '';
|
||||||
|
document.getElementById('hardwareModel').value = currentUnit.hardware_model || '';
|
||||||
|
|
||||||
|
// Show/hide fields based on device type
|
||||||
|
toggleDetailFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle device-specific fields
|
||||||
|
function toggleDetailFields() {
|
||||||
|
const deviceType = document.getElementById('deviceType').value;
|
||||||
|
const seismoFields = document.getElementById('seismographFields');
|
||||||
|
const modemFields = document.getElementById('modemFields');
|
||||||
|
|
||||||
|
if (deviceType === 'seismograph') {
|
||||||
|
seismoFields.classList.remove('hidden');
|
||||||
|
modemFields.classList.add('hidden');
|
||||||
|
} else {
|
||||||
|
seismoFields.classList.add('hidden');
|
||||||
|
modemFields.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter edit mode
|
||||||
|
function enterEditMode() {
|
||||||
|
document.getElementById('viewMode').classList.add('hidden');
|
||||||
|
document.getElementById('editMode').classList.remove('hidden');
|
||||||
|
document.getElementById('editButton').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel edit mode
|
||||||
|
function cancelEdit() {
|
||||||
|
document.getElementById('editMode').classList.add('hidden');
|
||||||
|
document.getElementById('viewMode').classList.remove('hidden');
|
||||||
|
document.getElementById('editButton').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Reset form to current values
|
||||||
|
populateEditForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle form submission
|
||||||
|
document.getElementById('editForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/edit/${unitId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Unit updated successfully!');
|
||||||
|
// Reload data and return to view mode
|
||||||
|
await loadUnitData();
|
||||||
|
cancelEdit();
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete unit
|
||||||
|
async function deleteUnit() {
|
||||||
|
if (!confirm(`Are you sure you want to PERMANENTLY delete unit ${unitId}?\n\nThis action cannot be undone!`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/${unitId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Unit deleted successfully');
|
||||||
|
window.location.href = '/roster';
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize unit location map (only called once)
|
||||||
|
function initUnitMap() {
|
||||||
|
if (!currentUnit.coordinates) {
|
||||||
|
document.getElementById('mapCard').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const coords = parseLocation(currentUnit.coordinates);
|
||||||
|
if (!coords) {
|
||||||
|
document.getElementById('mapCard').classList.add('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [lat, lon] = coords;
|
||||||
|
|
||||||
|
// Show the map card
|
||||||
|
document.getElementById('mapCard').classList.remove('hidden');
|
||||||
|
|
||||||
|
// Only initialize map if it doesn't exist
|
||||||
|
if (!unitMap) {
|
||||||
|
// Initialize map
|
||||||
|
unitMap = L.map('unit-map').setView([lat, lon], 13);
|
||||||
|
|
||||||
|
// Add OpenStreetMap tiles
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(unitMap);
|
||||||
|
|
||||||
|
// Force map to update its size
|
||||||
|
setTimeout(() => {
|
||||||
|
unitMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update marker (can be called multiple times)
|
||||||
|
updateMapMarker(lat, lon);
|
||||||
|
|
||||||
|
// Update location text
|
||||||
|
const locationParts = [];
|
||||||
|
if (currentUnit.address) {
|
||||||
|
locationParts.push(currentUnit.address);
|
||||||
|
}
|
||||||
|
locationParts.push(`Coordinates: ${lat.toFixed(6)}, ${lon.toFixed(6)}`);
|
||||||
|
document.getElementById('locationText').textContent = locationParts.join(' • ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update map marker with current status
|
||||||
|
function updateMapMarker(lat, lon) {
|
||||||
|
// Remove old marker if it exists
|
||||||
|
if (mapMarker) {
|
||||||
|
mapMarker.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get status color
|
||||||
|
let statusColor = 'gray';
|
||||||
|
let status = 'Unknown';
|
||||||
|
if (currentSnapshot && currentSnapshot.units && currentSnapshot.units[unitId]) {
|
||||||
|
const unitStatus = currentSnapshot.units[unitId];
|
||||||
|
status = unitStatus.status || 'Unknown';
|
||||||
|
statusColor = status === 'OK' ? 'green' : status === 'Pending' ? 'orange' : status === 'Missing' ? 'red' : 'gray';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new marker
|
||||||
|
mapMarker = L.circleMarker([lat, lon], {
|
||||||
|
radius: 10,
|
||||||
|
fillColor: statusColor,
|
||||||
|
color: '#fff',
|
||||||
|
weight: 3,
|
||||||
|
opacity: 1,
|
||||||
|
fillOpacity: 0.8
|
||||||
|
}).addTo(unitMap).bindPopup(`
|
||||||
|
<div class="p-2">
|
||||||
|
<h3 class="font-bold text-lg">${currentUnit.id}</h3>
|
||||||
|
<p class="text-sm">Status: <span style="color: ${statusColor}">${status}</span></p>
|
||||||
|
<p class="text-sm">Type: ${currentUnit.device_type}</p>
|
||||||
|
</div>
|
||||||
|
`).openPopup();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) {
|
||||||
|
return [lat, lon];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display photos
|
||||||
|
async function loadPhotos() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/unit/${unitId}/photos`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load photos');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const gallery = document.getElementById('photoGallery');
|
||||||
|
|
||||||
|
if (data.photos && data.photos.length > 0) {
|
||||||
|
gallery.innerHTML = '';
|
||||||
|
data.photo_urls.forEach((url, index) => {
|
||||||
|
const photoDiv = document.createElement('div');
|
||||||
|
photoDiv.className = 'relative group';
|
||||||
|
photoDiv.innerHTML = `
|
||||||
|
<img src="${url}" alt="Unit photo ${index + 1}"
|
||||||
|
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
|
onclick="window.open('${url}', '_blank')">
|
||||||
|
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
|
||||||
|
`;
|
||||||
|
gallery.appendChild(photoDiv);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading photos:', error);
|
||||||
|
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload photo with EXIF metadata extraction
|
||||||
|
async function uploadPhoto(file) {
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const statusDiv = document.getElementById('uploadStatus');
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
|
||||||
|
statusDiv.textContent = 'Uploading photo and extracting metadata...';
|
||||||
|
statusDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('photo', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
// Show success message with metadata info
|
||||||
|
let message = 'Photo uploaded successfully!';
|
||||||
|
if (result.metadata && result.metadata.coordinates) {
|
||||||
|
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
message += ' (Unit coordinates updated automatically)';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message += ' No GPS data found in photo.';
|
||||||
|
}
|
||||||
|
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
|
statusDiv.textContent = message;
|
||||||
|
|
||||||
|
// Reload photos and unit data
|
||||||
|
await loadPhotos();
|
||||||
|
if (result.coordinates_updated) {
|
||||||
|
await loadUnitData();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide status after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Reset both file inputs
|
||||||
|
document.getElementById('photoCameraUpload').value = '';
|
||||||
|
document.getElementById('photoLibraryUpload').value = '';
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading photo:', error);
|
||||||
|
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
statusDiv.textContent = `Error uploading photo: ${error.message}`;
|
||||||
|
|
||||||
|
// Hide error after 5 seconds
|
||||||
|
setTimeout(() => {
|
||||||
|
statusDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load and display unit history timeline
|
||||||
|
async function loadUnitHistory() {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/history/${unitId}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load history');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const timeline = document.getElementById('historyTimeline');
|
||||||
|
|
||||||
|
if (data.history && data.history.length > 0) {
|
||||||
|
timeline.innerHTML = '';
|
||||||
|
data.history.forEach(entry => {
|
||||||
|
const timelineEntry = createTimelineEntry(entry);
|
||||||
|
timeline.appendChild(timelineEntry);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
timeline.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading history:', error);
|
||||||
|
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a timeline entry element
|
||||||
|
function createTimelineEntry(entry) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50';
|
||||||
|
|
||||||
|
// Icon based on change type
|
||||||
|
const icons = {
|
||||||
|
'note_change': `<svg class="w-5 h-5 text-blue-500" 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>`,
|
||||||
|
'deployed_change': `<svg class="w-5 h-5 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>`,
|
||||||
|
'retired_change': `<svg class="w-5 h-5 text-orange-500" 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>`
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
// Format change description
|
||||||
|
let description = '';
|
||||||
|
if (entry.change_type === 'note_change') {
|
||||||
|
description = `<strong>Note changed</strong>`;
|
||||||
|
if (entry.old_value) {
|
||||||
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
|
||||||
|
}
|
||||||
|
if (entry.new_value) {
|
||||||
|
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
|
||||||
|
}
|
||||||
|
} else if (entry.change_type === 'deployed_change') {
|
||||||
|
description = `<strong>Status changed to ${entry.new_value}</strong>`;
|
||||||
|
} else if (entry.change_type === 'retired_change') {
|
||||||
|
description = `<strong>Marked as ${entry.new_value}</strong>`;
|
||||||
|
} else {
|
||||||
|
description = `<strong>${entry.field_name} changed</strong>`;
|
||||||
|
if (entry.old_value && entry.new_value) {
|
||||||
|
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value} → ${entry.new_value}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format timestamp
|
||||||
|
const timestamp = new Date(entry.changed_at).toLocaleString();
|
||||||
|
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
${icon}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="text-sm text-gray-900 dark:text-white">
|
||||||
|
${description}
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||||
|
${timestamp}
|
||||||
|
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
|
||||||
|
<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="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>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete a history entry
|
||||||
|
async function deleteHistoryEntry(historyId) {
|
||||||
|
if (!confirm('Are you sure you want to delete this history entry?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/roster/history/${historyId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
// Reload history
|
||||||
|
await loadUnitHistory();
|
||||||
|
} else {
|
||||||
|
const result = await response.json();
|
||||||
|
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load data when page loads
|
||||||
|
loadUnitData().then(() => {
|
||||||
|
loadPhotos();
|
||||||
|
loadUnitHistory();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||