Update main to 0.4 from dev
Update main to 0.4 from dev
This commit is contained in:
86
CHANGELOG.md
86
CHANGELOG.md
@@ -5,6 +5,90 @@ 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/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.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
|
## [0.3.2] - 2025-12-12
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -209,6 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- 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.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.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.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
|
||||||
|
|||||||
54
README.md
54
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.3.2
|
# Seismo Fleet Manager v0.4.0
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
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
|
## Features
|
||||||
@@ -19,6 +19,12 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
|
|||||||
- **Photo Management**: Upload and view photos for each unit
|
- **Photo Management**: Upload and view photos for each unit
|
||||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
||||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
- **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
|
## Roster Manager & Settings
|
||||||
|
|
||||||
@@ -26,10 +32,12 @@ Visit [`/settings`](http://localhost:8001/settings) to perform bulk roster opera
|
|||||||
|
|
||||||
- **CSV export/import**: Download the entire roster, merge updates, or replace all units in one transaction.
|
- **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.
|
- **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.
|
- **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.
|
- **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.
|
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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -180,6 +188,17 @@ Both migration scripts are idempotent—if the columns/tables already exist, the
|
|||||||
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
|
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
|
||||||
- **POST** `/api/settings/clear-ignored` - Reset ignore list
|
- **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
|
### CSV Import Format
|
||||||
Create a CSV file with the following columns (only `unit_id` is required, everything else is optional):
|
Create a CSV file with the following columns (only `unit_id` is required, everything else is optional):
|
||||||
|
|
||||||
@@ -368,7 +387,9 @@ seismo-fleet-manager/
|
|||||||
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
||||||
│ │ └── settings.py # Settings, preferences, and data management
|
│ │ └── settings.py # Settings, preferences, and data management
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── snapshot.py # Fleet status snapshot logic
|
│ │ ├── 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_device_types.py # SQLite migration for v0.2 schema
|
||||||
│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema
|
│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema
|
||||||
│ └── static/ # Static assets (CSS, etc.)
|
│ └── static/ # Static assets (CSS, etc.)
|
||||||
@@ -385,6 +406,11 @@ seismo-fleet-manager/
|
|||||||
│ ├── ignored_table.html
|
│ ├── ignored_table.html
|
||||||
│ └── unknown_emitters.html
|
│ └── unknown_emitters.html
|
||||||
├── data/ # SQLite database & photos (persisted)
|
├── 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
|
├── requirements.txt # Python dependencies
|
||||||
├── Dockerfile # Docker container definition
|
├── Dockerfile # Docker container definition
|
||||||
├── docker-compose.yml # Docker Compose configuration
|
├── docker-compose.yml # Docker Compose configuration
|
||||||
@@ -437,6 +463,19 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## 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
|
### v0.3.2 — 2025-12-12
|
||||||
- **Progressive Web App (PWA)**: Complete mobile optimization with offline support, installable as standalone app
|
- **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
|
- **Mobile-First UI**: Hamburger menu, bottom navigation bar, card-based roster view optimized for touch
|
||||||
@@ -486,7 +525,6 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
|
|||||||
- PostgreSQL support for larger deployments
|
- PostgreSQL support for larger deployments
|
||||||
- Advanced filtering and search
|
- Advanced filtering and search
|
||||||
- Export roster to various formats
|
- Export roster to various formats
|
||||||
- Automated backup and restore
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@@ -494,9 +532,13 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.3.2** — Progressive Web App with mobile optimization (2025-12-12)
|
**Current: 0.4.0** — Database management system with backup/restore and remote cloning (2025-12-16)
|
||||||
|
|
||||||
Previous: 0.3.1 — Dashboard alerts and status fixes (2025-12-12)
|
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.3.0 — Series 4 support, settings redesign, user preferences (2025-12-09)
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ from typing import List, Dict
|
|||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from backend.database import engine, Base, get_db
|
from backend.database import engine, Base, get_db
|
||||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
|
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
||||||
from backend.services.snapshot import emit_status_snapshot
|
from backend.services.snapshot import emit_status_snapshot
|
||||||
from backend.models import IgnoredUnit
|
from backend.models import IgnoredUnit
|
||||||
|
|
||||||
@@ -20,7 +20,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.3.2"
|
VERSION = "0.4.0"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -67,6 +67,7 @@ app.include_router(photos.router)
|
|||||||
app.include_router(roster_edit.router)
|
app.include_router(roster_edit.router)
|
||||||
app.include_router(dashboard.router)
|
app.include_router(dashboard.router)
|
||||||
app.include_router(dashboard_tabs.router)
|
app.include_router(dashboard_tabs.router)
|
||||||
|
app.include_router(activity.router)
|
||||||
|
|
||||||
from backend.routers import settings
|
from backend.routers import settings
|
||||||
app.include_router(settings.router)
|
app.include_router(settings.router)
|
||||||
|
|||||||
78
backend/migrate_add_unit_history.py
Normal file
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()
|
||||||
@@ -59,6 +59,24 @@ class IgnoredUnit(Base):
|
|||||||
ignored_at = Column(DateTime, default=datetime.utcnow)
|
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):
|
class UserPreferences(Base):
|
||||||
"""
|
"""
|
||||||
User preferences: persistent storage for application settings.
|
User preferences: persistent storage for application settings.
|
||||||
|
|||||||
146
backend/routers/activity.py
Normal file
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()
|
||||||
|
}
|
||||||
@@ -1,14 +1,152 @@
|
|||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import List
|
from typing import List, Optional
|
||||||
|
from datetime import datetime
|
||||||
import os
|
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"])
|
router = APIRouter(prefix="/api", tags=["photos"])
|
||||||
|
|
||||||
PHOTOS_BASE_DIR = Path("data/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")
|
@router.get("/unit/{unit_id}/photos")
|
||||||
def get_unit_photos(unit_id: str):
|
def get_unit_photos(unit_id: str):
|
||||||
"""
|
"""
|
||||||
@@ -51,6 +189,46 @@ def get_unit_photos(unit_id: str):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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}")
|
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||||
def get_photo(unit_id: str, filename: str):
|
def get_photo(unit_id: str, filename: str):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -5,11 +5,28 @@ import csv
|
|||||||
import io
|
import io
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, IgnoredUnit, Emitter
|
from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
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):
|
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||||
if not unit:
|
if not unit:
|
||||||
@@ -154,6 +171,11 @@ def edit_roster_unit(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
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
|
# Update all fields
|
||||||
unit.device_type = device_type
|
unit.device_type = device_type
|
||||||
unit.unit_type = unit_type
|
unit.unit_type = unit_type
|
||||||
@@ -176,6 +198,20 @@ def edit_roster_unit(
|
|||||||
unit.phone_number = phone_number if phone_number else None
|
unit.phone_number = phone_number if phone_number else None
|
||||||
unit.hardware_model = hardware_model if hardware_model 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()
|
db.commit()
|
||||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||||
|
|
||||||
@@ -183,8 +219,24 @@ def edit_roster_unit(
|
|||||||
@router.post("/set-deployed/{unit_id}")
|
@router.post("/set-deployed/{unit_id}")
|
||||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
unit = get_or_create_roster_unit(db, unit_id)
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_deployed = unit.deployed
|
||||||
unit.deployed = deployed
|
unit.deployed = deployed
|
||||||
unit.last_updated = datetime.utcnow()
|
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()
|
db.commit()
|
||||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||||
|
|
||||||
@@ -192,8 +244,24 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
|
|||||||
@router.post("/set-retired/{unit_id}")
|
@router.post("/set-retired/{unit_id}")
|
||||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||||
unit = get_or_create_roster_unit(db, unit_id)
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_retired = unit.retired
|
||||||
unit.retired = retired
|
unit.retired = retired
|
||||||
unit.last_updated = datetime.utcnow()
|
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()
|
db.commit()
|
||||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||||
|
|
||||||
@@ -235,8 +303,22 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
@router.post("/set-note/{unit_id}")
|
@router.post("/set-note/{unit_id}")
|
||||||
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||||
unit = get_or_create_roster_unit(db, unit_id)
|
unit = get_or_create_roster_unit(db, unit_id)
|
||||||
|
old_note = unit.note
|
||||||
unit.note = note
|
unit.note = note
|
||||||
unit.last_updated = datetime.utcnow()
|
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()
|
db.commit()
|
||||||
return {"message": "Updated", "id": unit_id, "note": note}
|
return {"message": "Updated", "id": unit_id, "note": note}
|
||||||
|
|
||||||
@@ -402,3 +484,46 @@ def list_ignored_units(db: Session = Depends(get_db)):
|
|||||||
for unit in ignored_units
|
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}
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse, FileResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
import shutil
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
|
from backend.services.database_backup import DatabaseBackupService
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -325,3 +328,144 @@ def update_preferences(
|
|||||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
"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)}")
|
||||||
|
|||||||
145
backend/services/backup_scheduler.py
Normal file
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
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
|
||||||
@@ -455,6 +455,54 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 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 ===== */
|
||||||
.pending-sync-badge {
|
.pending-sync-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ function toggleMenu() {
|
|||||||
backdrop.classList.remove('show');
|
backdrop.classList.remove('show');
|
||||||
hamburgerBtn?.classList.remove('menu-open');
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
} else {
|
} else {
|
||||||
// Open menu
|
// Open menu
|
||||||
sidebar.classList.add('open');
|
sidebar.classList.add('open');
|
||||||
backdrop.classList.add('show');
|
backdrop.classList.add('show');
|
||||||
hamburgerBtn?.classList.add('menu-open');
|
hamburgerBtn?.classList.add('menu-open');
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
|
document.body.classList.add('menu-open');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,6 +43,7 @@ function closeMenuFromBackdrop() {
|
|||||||
backdrop.classList.remove('show');
|
backdrop.classList.remove('show');
|
||||||
hamburgerBtn?.classList.remove('menu-open');
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,7 @@ function handleResize() {
|
|||||||
backdrop.classList.remove('show');
|
backdrop.classList.remove('show');
|
||||||
hamburgerBtn?.classList.remove('menu-open');
|
hamburgerBtn?.classList.remove('menu-open');
|
||||||
document.body.style.overflow = '';
|
document.body.style.overflow = '';
|
||||||
|
document.body.classList.remove('menu-open');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
10
data-dev/backups/snapshot_20251216_201738.db.meta.json
Normal file
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"
|
||||||
|
}
|
||||||
477
docs/DATABASE_MANAGEMENT.md
Normal file
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
|
||||||
@@ -5,3 +5,4 @@ pydantic==2.5.0
|
|||||||
python-multipart==0.0.6
|
python-multipart==0.0.6
|
||||||
jinja2==3.1.2
|
jinja2==3.1.2
|
||||||
aiofiles==23.2.1
|
aiofiles==23.2.1
|
||||||
|
Pillow==10.1.0
|
||||||
|
|||||||
149
scripts/clone_db_to_dev.py
Executable file
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()
|
||||||
@@ -69,14 +69,6 @@
|
|||||||
{% block extra_head %}{% endblock %}
|
{% block extra_head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
<!-- Hamburger Button (Mobile Only) -->
|
|
||||||
<button id="hamburgerBtn" class="hamburger-btn md:hidden" onclick="toggleMenu()" aria-label="Menu">
|
|
||||||
<div class="hamburger-icon">
|
|
||||||
<div class="hamburger-line"></div>
|
|
||||||
<div class="hamburger-line"></div>
|
|
||||||
<div class="hamburger-line"></div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Offline Indicator -->
|
<!-- Offline Indicator -->
|
||||||
<div id="offlineIndicator" class="offline-indicator">
|
<div id="offlineIndicator" class="offline-indicator">
|
||||||
@@ -172,6 +164,12 @@
|
|||||||
<!-- Bottom Navigation (Mobile Only) -->
|
<!-- Bottom Navigation (Mobile Only) -->
|
||||||
<nav class="bottom-nav">
|
<nav class="bottom-nav">
|
||||||
<div class="grid grid-cols-4 h-16">
|
<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='/'">
|
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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>
|
<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>
|
||||||
@@ -184,12 +182,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<span>Roster</span>
|
<span>Roster</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="bottom-nav-btn" data-href="/roster?action=add" onclick="window.location.href='/roster?action=add'">
|
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
|
||||||
</svg>
|
|
||||||
<span>Add Unit</span>
|
|
||||||
</button>
|
|
||||||
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
|
||||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<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="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>
|
||||||
@@ -368,10 +360,10 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Offline Database -->
|
<!-- Offline Database -->
|
||||||
<script src="/static/offline-db.js?v=0.3.2"></script>
|
<script src="/static/offline-db.js?v=0.4.0"></script>
|
||||||
|
|
||||||
<!-- Mobile JavaScript -->
|
<!-- Mobile JavaScript -->
|
||||||
<script src="/static/mobile.js?v=0.3.2"></script>
|
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -30,16 +30,21 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
|
|
||||||
<!-- Fleet Summary Card -->
|
<!-- Fleet Summary Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<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">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center gap-2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</path>
|
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">
|
||||||
</svg>
|
</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>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
<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>
|
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||||
@@ -92,90 +97,134 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Alerts Card -->
|
<!-- Recent Alerts Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<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">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center gap-2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</path>
|
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">
|
||||||
</svg>
|
</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>
|
||||||
<div id="alerts-list" class="space-y-3">
|
<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>
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Recent Photos Card -->
|
<!-- Recently Called In Units Card -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<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">
|
<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 Photos</h2>
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div class="flex items-center gap-2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
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 stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
</path>
|
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||||
</svg>
|
</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>
|
||||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
<div class="card-content" id="recent-callins-content">
|
||||||
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div id="recent-callins-list" class="space-y-2">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||||
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">
|
</div>
|
||||||
</path>
|
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||||
</svg>
|
Show all recent call-ins
|
||||||
<p class="text-sm">No recent photos</p>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Map -->
|
<!-- Fleet Map -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8">
|
<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">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||||
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span>
|
<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>
|
||||||
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Fleet Status Section with Tabs -->
|
<!-- Fleet Status Section with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<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">
|
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
|
||||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||||
|
|
||||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center">
|
<div class="flex items-center gap-2">
|
||||||
Full Roster
|
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
Full Roster
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
<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>
|
</svg>
|
||||||
</a>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Bar -->
|
<div class="card-content" id="fleet-status-content">
|
||||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
<!-- Tab Bar -->
|
||||||
<button
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
<button
|
||||||
hx-get="/dashboard/active"
|
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||||
hx-target="#fleet-table"
|
hx-get="/dashboard/active"
|
||||||
hx-swap="innerHTML">
|
hx-target="#fleet-table"
|
||||||
Active
|
hx-swap="innerHTML">
|
||||||
</button>
|
Active
|
||||||
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
class="px-4 py-2 text-sm font-medium tab-button"
|
class="px-4 py-2 text-sm font-medium tab-button"
|
||||||
hx-get="/dashboard/benched"
|
hx-get="/dashboard/benched"
|
||||||
hx-target="#fleet-table"
|
hx-target="#fleet-table"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
Benched
|
Benched
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab Content Target -->
|
<!-- Tab Content Target -->
|
||||||
<div id="fleet-table" class="space-y-2"
|
<div id="fleet-table" class="space-y-2"
|
||||||
hx-get="/dashboard/active"
|
hx-get="/dashboard/active"
|
||||||
hx-trigger="load"
|
hx-trigger="load"
|
||||||
hx-swap="innerHTML">
|
hx-swap="innerHTML">
|
||||||
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -195,10 +244,82 @@
|
|||||||
color: #b84a12 !important; /* seismo orange */
|
color: #b84a12 !important; /* seismo orange */
|
||||||
border-bottom: 2px solid #b84a12 !important;
|
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>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<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) {
|
function updateDashboard(event) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.response);
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
@@ -276,15 +397,24 @@ let fleetMap = null;
|
|||||||
let fleetMarkers = [];
|
let fleetMarkers = [];
|
||||||
let fleetMapInitialized = false;
|
let fleetMapInitialized = false;
|
||||||
|
|
||||||
|
// Make fleetMap accessible globally for toggleCard function
|
||||||
|
window.fleetMap = null;
|
||||||
|
|
||||||
function initFleetMap() {
|
function initFleetMap() {
|
||||||
// Initialize the map centered on the US (can adjust based on your deployment area)
|
// 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);
|
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||||
|
window.fleetMap = fleetMap;
|
||||||
|
|
||||||
// Add OpenStreetMap tiles
|
// Add OpenStreetMap tiles
|
||||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
attribution: '© OpenStreetMap contributors',
|
attribution: '© OpenStreetMap contributors',
|
||||||
maxZoom: 18
|
maxZoom: 18
|
||||||
}).addTo(fleetMap);
|
}).addTo(fleetMap);
|
||||||
|
|
||||||
|
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
fleetMap.invalidateSize();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateFleetMap(data) {
|
function updateFleetMap(data) {
|
||||||
@@ -336,9 +466,11 @@ function updateFleetMap(data) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fit map to show all markers only on first load
|
// Fit map to show all markers
|
||||||
if (bounds.length > 0 && !fleetMapInitialized) {
|
if (bounds.length > 0) {
|
||||||
fleetMap.fitBounds(bounds, { padding: [50, 50] });
|
// Use different padding for mobile vs desktop
|
||||||
|
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||||
|
fleetMap.fitBounds(bounds, { padding: padding });
|
||||||
fleetMapInitialized = true;
|
fleetMapInitialized = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -359,6 +491,130 @@ function parseLocation(location) {
|
|||||||
// TODO: Add geocoding support for address strings
|
// TODO: Add geocoding support for address strings
|
||||||
return null;
|
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>
|
</script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
<!-- Status Indicator -->
|
<!-- Status Indicator -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
{% if unit.status == 'OK' %}
|
{% if unit.status == 'OK' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
|
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
|
||||||
{% elif unit.status == 'Pending' %}
|
{% elif unit.status == 'Pending' %}
|
||||||
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
|
<span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
|
<span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="flex items-center space-x-3 flex-1">
|
<div class="flex items-center space-x-3 flex-1">
|
||||||
<!-- Status Indicator (grayed out for benched) -->
|
<!-- Status Indicator (grayed out for benched) -->
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<span class="w-3 h-3 rounded-full bg-gray-400" title="Benched"></span>
|
<span class="w-4 h-4 rounded-full bg-gray-400" title="Benched"></span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Unit Info -->
|
<!-- Unit Info -->
|
||||||
|
|||||||
@@ -229,32 +229,14 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Location (Tap to Navigate) -->
|
<!-- Location -->
|
||||||
{% if unit.address %}
|
{% if unit.address %}
|
||||||
<div class="text-sm mb-1">
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.address | urlencode }}"
|
📍 {{ unit.address }}
|
||||||
target="_blank"
|
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
class="flex items-center gap-1 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>
|
|
||||||
</div>
|
</div>
|
||||||
{% elif unit.coordinates %}
|
{% elif unit.coordinates %}
|
||||||
<div class="text-sm mb-1">
|
<div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||||
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.coordinates | urlencode }}"
|
📍 {{ unit.coordinates }}
|
||||||
target="_blank"
|
|
||||||
onclick="event.stopPropagation()"
|
|
||||||
class="flex items-center gap-1 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 underline">{{ unit.coordinates }}</span>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,13 @@
|
|||||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline-block mr-1" 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>
|
<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>
|
</svg>
|
||||||
Data Management
|
Roster Management
|
||||||
|
</button>
|
||||||
|
<button class="settings-tab" data-tab="database" onclick="showTab('database')">
|
||||||
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path>
|
||||||
|
</svg>
|
||||||
|
Database
|
||||||
</button>
|
</button>
|
||||||
<button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')">
|
<button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')">
|
||||||
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -214,30 +220,24 @@
|
|||||||
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Advanced Tab -->
|
|
||||||
<div id="advanced-tab" class="tab-content hidden">
|
|
||||||
<div class="space-y-6">
|
|
||||||
<!-- Warning Banner -->
|
|
||||||
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4">
|
|
||||||
<div class="flex">
|
|
||||||
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p>
|
|
||||||
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">These settings can affect system behavior. Change with caution.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- CSV Import - Replace Mode -->
|
<!-- CSV Import - Replace Mode -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 border-2 border-yellow-200 dark:border-yellow-800">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 border-2 border-yellow-200 dark:border-yellow-800">
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4 mb-4">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Replace Mode Warning</p>
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">This will DELETE all existing roster units before importing.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h2 class="text-xl font-semibold text-yellow-800 dark:text-yellow-300 mb-2">CSV Import - Replace Mode</h2>
|
<h2 class="text-xl font-semibold text-yellow-800 dark:text-yellow-300 mb-2">CSV Import - Replace Mode</h2>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
This will DELETE all existing roster units before importing.
|
Use this to completely replace your roster with a CSV file. All existing roster data will be deleted first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form id="importReplaceForm" class="space-y-4">
|
<form id="importReplaceForm" class="space-y-4">
|
||||||
@@ -257,6 +257,116 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Database Tab -->
|
||||||
|
<div id="database-tab" class="tab-content hidden">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Database Statistics -->
|
||||||
|
<div class="border-2 border-blue-300 dark:border-blue-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
||||||
|
<h3 class="font-semibold text-blue-600 dark:text-blue-400 text-lg mb-3">Database Statistics</h3>
|
||||||
|
<div id="dbStatsLoading" class="text-center py-4">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
<div id="dbStatsContent" class="hidden">
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Database Size</p>
|
||||||
|
<p id="dbSize" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Total Rows</p>
|
||||||
|
<p id="dbRows" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Last Modified</p>
|
||||||
|
<p id="dbModified" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Snapshots</p>
|
||||||
|
<p id="dbSnapshotCount" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="loadDatabaseStats()" class="mt-4 px-4 py-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors text-sm">
|
||||||
|
Refresh Stats
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Snapshot -->
|
||||||
|
<div class="border border-green-200 dark:border-green-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<h3 class="font-semibold text-green-600 dark:text-green-400">Create Database Snapshot</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||||
|
Create a full backup of the current database state
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="createSnapshot()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors whitespace-nowrap">
|
||||||
|
Create Snapshot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Snapshots List -->
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-slate-800">
|
||||||
|
<div class="flex justify-between items-center mb-4">
|
||||||
|
<h3 class="font-semibold text-gray-900 dark:text-white">Available Snapshots</h3>
|
||||||
|
<button onclick="loadSnapshots()" class="px-3 py-1 text-sm text-seismo-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded transition-colors">
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="snapshotsLoading" class="text-center py-4">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="snapshotsList" class="hidden space-y-2">
|
||||||
|
<!-- Snapshots will be inserted here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="snapshotsEmpty" class="hidden text-center py-8 text-gray-500 dark:text-gray-400">
|
||||||
|
<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="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
||||||
|
</svg>
|
||||||
|
<p class="mt-2">No snapshots found</p>
|
||||||
|
<p class="text-sm">Create your first snapshot above</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Snapshot -->
|
||||||
|
<div class="border border-purple-200 dark:border-purple-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
||||||
|
<h3 class="font-semibold text-purple-600 dark:text-purple-400 mb-2">Upload Snapshot</h3>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||||
|
Upload a database snapshot file from another server
|
||||||
|
</p>
|
||||||
|
<form id="uploadSnapshotForm" class="space-y-3">
|
||||||
|
<input type="file" accept=".db" id="snapshotFileInput" class="block w-full text-sm text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer bg-gray-50 dark:bg-slate-700">
|
||||||
|
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors">
|
||||||
|
Upload Snapshot
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="uploadResult" class="hidden mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Advanced Tab -->
|
||||||
|
<div id="advanced-tab" class="tab-content hidden">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Warning Banner -->
|
||||||
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p>
|
||||||
|
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">These settings can affect system behavior. Change with caution.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Status Thresholds -->
|
<!-- Status Thresholds -->
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
@@ -1004,5 +1114,263 @@ async function confirmClearIgnored() {
|
|||||||
alert('❌ Error: ' + error.message);
|
alert('❌ Error: ' + error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== DATABASE MANAGEMENT ==========
|
||||||
|
|
||||||
|
async function loadDatabaseStats() {
|
||||||
|
const loading = document.getElementById('dbStatsLoading');
|
||||||
|
const content = document.getElementById('dbStatsContent');
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.classList.remove('hidden');
|
||||||
|
content.classList.add('hidden');
|
||||||
|
|
||||||
|
const response = await fetch('/api/settings/database/stats');
|
||||||
|
const stats = await response.json();
|
||||||
|
|
||||||
|
// Update stats display
|
||||||
|
document.getElementById('dbSize').textContent = stats.size_mb + ' MB';
|
||||||
|
document.getElementById('dbRows').textContent = stats.total_rows.toLocaleString();
|
||||||
|
|
||||||
|
const lastMod = new Date(stats.last_modified);
|
||||||
|
document.getElementById('dbModified').textContent = lastMod.toLocaleDateString();
|
||||||
|
|
||||||
|
// Load snapshot count
|
||||||
|
const snapshotsResp = await fetch('/api/settings/database/snapshots');
|
||||||
|
const snapshotsData = await snapshotsResp.json();
|
||||||
|
document.getElementById('dbSnapshotCount').textContent = snapshotsData.count;
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
alert('Error loading database stats: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSnapshot() {
|
||||||
|
const description = prompt('Enter a description for this snapshot (optional):');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/database/snapshot', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ description: description || null })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Snapshot created successfully!\n\nFilename: ${result.snapshot.filename}\nSize: ${result.snapshot.size_mb} MB`);
|
||||||
|
loadSnapshots();
|
||||||
|
loadDatabaseStats();
|
||||||
|
} else {
|
||||||
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSnapshots() {
|
||||||
|
const loading = document.getElementById('snapshotsLoading');
|
||||||
|
const list = document.getElementById('snapshotsList');
|
||||||
|
const empty = document.getElementById('snapshotsEmpty');
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.classList.remove('hidden');
|
||||||
|
list.classList.add('hidden');
|
||||||
|
empty.classList.add('hidden');
|
||||||
|
|
||||||
|
const response = await fetch('/api/settings/database/snapshots');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.snapshots.length === 0) {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
empty.classList.remove('hidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
list.innerHTML = data.snapshots.map(snapshot => createSnapshotCard(snapshot)).join('');
|
||||||
|
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
list.classList.remove('hidden');
|
||||||
|
} catch (error) {
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
alert('Error loading snapshots: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSnapshotCard(snapshot) {
|
||||||
|
const createdDate = new Date(snapshot.created_at_iso);
|
||||||
|
const dateStr = createdDate.toLocaleString();
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
||||||
|
<div class="flex justify-between items-start">
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h4 class="font-medium text-gray-900 dark:text-white">${snapshot.filename}</h4>
|
||||||
|
<span class="text-xs px-2 py-1 rounded ${snapshot.type === 'manual' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'}">
|
||||||
|
${snapshot.type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">${snapshot.description}</p>
|
||||||
|
<div class="flex gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
<span>📅 ${dateStr}</span>
|
||||||
|
<span>💾 ${snapshot.size_mb} MB</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2 ml-4">
|
||||||
|
<button onclick="downloadSnapshot('${snapshot.filename}')"
|
||||||
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-blue-600 dark:text-blue-400"
|
||||||
|
title="Download">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="restoreSnapshot('${snapshot.filename}')"
|
||||||
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-green-600 dark:text-green-400"
|
||||||
|
title="Restore">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteSnapshot('${snapshot.filename}')"
|
||||||
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-red-600 dark:text-red-400"
|
||||||
|
title="Delete">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadSnapshot(filename) {
|
||||||
|
window.location.href = `/api/settings/database/snapshot/${filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreSnapshot(filename) {
|
||||||
|
const confirmMsg = `⚠️ RESTORE DATABASE WARNING ⚠️
|
||||||
|
|
||||||
|
This will REPLACE the current database with snapshot:
|
||||||
|
${filename}
|
||||||
|
|
||||||
|
A backup of the current database will be created automatically before restoring.
|
||||||
|
|
||||||
|
THIS ACTION WILL RESTART THE APPLICATION!
|
||||||
|
|
||||||
|
Continue?`;
|
||||||
|
|
||||||
|
if (!confirm(confirmMsg)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/database/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
filename: filename,
|
||||||
|
create_backup: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Database restored successfully!\n\nRestored from: ${result.restored_from}\nBackup created: ${result.backup_created}\n\nThe page will now reload.`);
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSnapshot(filename) {
|
||||||
|
if (!confirm(`Delete snapshot ${filename}?\n\nThis cannot be undone.`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/settings/database/snapshot/${filename}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert(`✅ Snapshot deleted: ${filename}`);
|
||||||
|
loadSnapshots();
|
||||||
|
loadDatabaseStats();
|
||||||
|
} else {
|
||||||
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('❌ Error: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload snapshot form handler
|
||||||
|
document.getElementById('uploadSnapshotForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const fileInput = document.getElementById('snapshotFileInput');
|
||||||
|
const resultDiv = document.getElementById('uploadResult');
|
||||||
|
|
||||||
|
if (!fileInput.files[0]) {
|
||||||
|
alert('Please select a file');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', fileInput.files[0]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/settings/database/upload-snapshot', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||||
|
resultDiv.innerHTML = `✅ Uploaded: ${result.snapshot.filename} (${result.snapshot.size_mb} MB)`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
|
||||||
|
fileInput.value = '';
|
||||||
|
loadSnapshots();
|
||||||
|
loadDatabaseStats();
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resultDiv.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
} else {
|
||||||
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
resultDiv.innerHTML = `❌ Error: ${result.detail || 'Unknown error'}`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||||
|
resultDiv.innerHTML = `❌ Error: ${error.message}`;
|
||||||
|
resultDiv.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load database stats and snapshots when database tab is shown
|
||||||
|
const originalShowTab = showTab;
|
||||||
|
showTab = function(tabName) {
|
||||||
|
originalShowTab(tabName);
|
||||||
|
if (tabName === 'database') {
|
||||||
|
loadDatabaseStats();
|
||||||
|
loadSnapshots();
|
||||||
|
}
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -177,6 +177,46 @@
|
|||||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -632,7 +672,228 @@ function parseLocation(location) {
|
|||||||
return null;
|
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
|
// Load data when page loads
|
||||||
loadUnitData();
|
loadUnitData().then(() => {
|
||||||
|
loadPhotos();
|
||||||
|
loadUnitHistory();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user