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/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.4.0] - 2025-12-16
|
||||
|
||||
### Added
|
||||
- **Database Management System**: Comprehensive backup and restore capabilities
|
||||
- **Manual Snapshots**: Create on-demand backups of the entire database with optional descriptions
|
||||
- **Restore from Snapshot**: Restore database from any snapshot with automatic safety backup
|
||||
- **Upload/Download Snapshots**: Transfer database snapshots to/from the server
|
||||
- **Database Tab**: New dedicated tab in Settings for all database management operations
|
||||
- **Database Statistics**: View database size, row counts by table, and last modified time
|
||||
- **Snapshot Metadata**: Each snapshot includes creation time, description, size, and type (manual/automatic)
|
||||
- **Safety Backups**: Automatic backup created before any restore operation
|
||||
- **Remote Database Cloning**: Dev tools for cloning production database to remote development servers
|
||||
- **Clone Script**: `scripts/clone_db_to_dev.py` for copying database over WAN
|
||||
- **Network Upload**: Upload snapshots via HTTP to remote servers
|
||||
- **Auto-restore**: Automatically restore uploaded database on target server
|
||||
- **Authentication Support**: Optional token-based authentication for secure transfers
|
||||
- **Automatic Backup Scheduler**: Background service for automated database backups
|
||||
- **Configurable Intervals**: Set backup frequency (default: 24 hours)
|
||||
- **Retention Management**: Automatically delete old backups (configurable keep count)
|
||||
- **Manual Trigger**: Force immediate backup via API
|
||||
- **Status Monitoring**: Check scheduler status and next scheduled run time
|
||||
- **Background Thread**: Non-blocking operation using Python threading
|
||||
- **Settings Reorganization**: Improved tab structure for better organization
|
||||
- Renamed "Data Management" tab to "Roster Management"
|
||||
- Moved CSV Replace Mode from Advanced tab to Roster Management tab
|
||||
- Created dedicated Database tab for all backup/restore operations
|
||||
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` guide covering:
|
||||
- Manual snapshot creation and restoration workflows
|
||||
- Download/upload procedures for off-site backups
|
||||
- Remote database cloning setup and usage
|
||||
- Automatic backup configuration and integration
|
||||
- API reference for all database endpoints
|
||||
- Best practices and troubleshooting guide
|
||||
|
||||
### Changed
|
||||
- **Settings Tab Organization**: Restructured for better logical grouping
|
||||
- **General**: Display preferences (timezone, theme, auto-refresh)
|
||||
- **Roster Management**: CSV operations and roster table (now includes Replace Mode)
|
||||
- **Database**: All backup/restore operations (NEW)
|
||||
- **Advanced**: Power user settings (calibration, thresholds)
|
||||
- **Danger Zone**: Destructive operations
|
||||
- CSV Replace Mode warnings enhanced and moved to Roster Management context
|
||||
|
||||
### Technical Details
|
||||
- **SQLite Backup API**: Uses native SQLite backup API for concurrent-safe snapshots
|
||||
- **Metadata Tracking**: JSON sidecar files store snapshot metadata alongside database files
|
||||
- **Atomic Operations**: Database restoration is atomic with automatic rollback on failure
|
||||
- **File Structure**: Snapshots stored in `./data/backups/` with timestamped filenames
|
||||
- **API Endpoints**: 7 new endpoints for database management operations
|
||||
- **Backup Service**: `backend/services/database_backup.py` - Core backup/restore logic
|
||||
- **Scheduler Service**: `backend/services/backup_scheduler.py` - Automatic backup automation
|
||||
- **Clone Utility**: `scripts/clone_db_to_dev.py` - Remote database synchronization tool
|
||||
|
||||
### Security Considerations
|
||||
- Snapshots contain full database data and should be secured appropriately
|
||||
- Remote cloning supports optional authentication tokens
|
||||
- Restore operations require safety backup creation by default
|
||||
- All destructive operations remain in Danger Zone with warnings
|
||||
|
||||
### Migration Notes
|
||||
No database migration required for v0.4.0. All new features use existing database structure and add new backup management capabilities without modifying the core schema.
|
||||
|
||||
## [0.3.3] - 2025-12-12
|
||||
|
||||
### Changed
|
||||
- **Mobile Navigation**: Moved hamburger menu button from floating top-right to bottom navigation bar
|
||||
- Bottom nav now shows: Menu (hamburger), Dashboard, Roster, Settings
|
||||
- Removed "Add Unit" from bottom nav (still accessible via sidebar menu)
|
||||
- Hamburger no longer floats over content on mobile
|
||||
- **Status Dot Visibility**: Increased status dot size from 12px to 16px (w-3/h-3 → w-4/h-4) in dashboard fleet overview for better at-a-glance visibility
|
||||
- Affects both Active and Benched tabs in dashboard
|
||||
- Makes status colors (green/yellow/red) easier to spot during quick scroll
|
||||
|
||||
### Fixed
|
||||
- **Location Navigation**: Moved tap-to-navigate functionality from roster card view to unit detail modal only
|
||||
- Roster cards now show simple location text with pin emoji
|
||||
- Navigation links (opening Maps app) only appear in the modal when tapping a unit
|
||||
- Reduces visual clutter and accidental navigation triggers
|
||||
|
||||
### Technical Details
|
||||
- Bottom navigation remains at 4 buttons, first button now triggers sidebar menu
|
||||
- Removed standalone hamburger button element and associated CSS
|
||||
- Modal already had navigation links, no changes needed there
|
||||
|
||||
## [0.3.2] - 2025-12-12
|
||||
|
||||
### Added
|
||||
@@ -209,6 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
- Photo management per unit
|
||||
- Automated status categorization (OK/Pending/Missing)
|
||||
|
||||
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
|
||||
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
|
||||
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
|
||||
[0.3.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.0...v0.3.1
|
||||
[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
|
||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||
- **Database Management**: Comprehensive backup and restore system
|
||||
- **Manual Snapshots**: Create on-demand backups with descriptions
|
||||
- **Restore from Snapshot**: Restore database with automatic safety backups
|
||||
- **Upload/Download**: Transfer database snapshots for off-site storage
|
||||
- **Remote Cloning**: Copy production database to remote dev servers over WAN
|
||||
- **Automatic Backups**: Scheduled background backups with configurable retention
|
||||
|
||||
## Roster Manager & Settings
|
||||
|
||||
@@ -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.
|
||||
- **Live roster table**: Fetch every unit via HTMX, edit metadata, toggle deployed/retired states, move emitters to the ignore list, or delete records in-place.
|
||||
- **Database backups**: Create snapshots, restore from backups, upload/download database files, view database statistics.
|
||||
- **Remote cloning**: Clone production database to remote development servers over the network (see `scripts/clone_db_to_dev.py`).
|
||||
- **Stats at a glance**: View counts for the roster, emitters, and ignored units to confirm import/cleanup operations worked.
|
||||
- **Danger zone controls**: Clear specific tables or wipe all fleet data when resetting a lab/demo environment.
|
||||
|
||||
All UI actions call `GET/POST /api/settings/*` endpoints so you can automate the same workflows from scripts.
|
||||
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
|
||||
|
||||
@@ -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-ignored` - Reset ignore list
|
||||
|
||||
### Database Management
|
||||
- **GET** `/api/settings/database/stats` - Database size, row counts, and last modified time
|
||||
- **POST** `/api/settings/database/snapshot` - Create manual database snapshot with optional description
|
||||
- **GET** `/api/settings/database/snapshots` - List all available snapshots with metadata
|
||||
- **GET** `/api/settings/database/snapshot/{filename}` - Download a specific snapshot file
|
||||
- **DELETE** `/api/settings/database/snapshot/{filename}` - Delete a snapshot
|
||||
- **POST** `/api/settings/database/restore` - Restore database from snapshot (creates safety backup)
|
||||
- **POST** `/api/settings/database/upload-snapshot` - Upload snapshot file to server
|
||||
|
||||
See [docs/DATABASE_MANAGEMENT.md](docs/DATABASE_MANAGEMENT.md) for detailed documentation and examples.
|
||||
|
||||
### CSV Import Format
|
||||
Create a CSV file with the following columns (only `unit_id` is required, everything else is optional):
|
||||
|
||||
@@ -368,7 +387,9 @@ seismo-fleet-manager/
|
||||
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
||||
│ │ └── settings.py # Settings, preferences, and data management
|
||||
│ ├── 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_user_preferences.py # SQLite migration for v0.3 schema
|
||||
│ └── static/ # Static assets (CSS, etc.)
|
||||
@@ -385,6 +406,11 @@ seismo-fleet-manager/
|
||||
│ ├── ignored_table.html
|
||||
│ └── unknown_emitters.html
|
||||
├── data/ # SQLite database & photos (persisted)
|
||||
│ └── backups/ # Database snapshots directory
|
||||
├── scripts/
|
||||
│ └── clone_db_to_dev.py # Remote database cloning utility
|
||||
├── docs/
|
||||
│ └── DATABASE_MANAGEMENT.md # Database backup/restore guide
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker container definition
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
@@ -437,6 +463,19 @@ docker compose down -v
|
||||
|
||||
## Release Highlights
|
||||
|
||||
### v0.4.0 — 2025-12-16
|
||||
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
|
||||
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
|
||||
- **Automatic Backup Scheduler**: Background service for scheduled backups with configurable retention management
|
||||
- **Database Tab**: New dedicated tab in Settings for all database operations with real-time statistics
|
||||
- **Settings Reorganization**: Improved tab structure - renamed "Data Management" to "Roster Management", moved CSV Replace Mode, created Database tab
|
||||
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` with complete guide to backup/restore workflows, API reference, and best practices
|
||||
|
||||
### v0.3.3 — 2025-12-12
|
||||
- **Improved Mobile Navigation**: Hamburger menu moved to bottom nav bar (no more floating button covering content)
|
||||
- **Better Status Visibility**: Larger status dots (16px) in dashboard fleet overview for easier at-a-glance status checks
|
||||
- **Cleaner Roster Cards**: Location navigation links moved to detail modal only, reducing clutter in card view
|
||||
|
||||
### v0.3.2 — 2025-12-12
|
||||
- **Progressive Web App (PWA)**: Complete mobile optimization with offline support, installable as standalone app
|
||||
- **Mobile-First UI**: Hamburger menu, bottom navigation bar, card-based roster view optimized for touch
|
||||
@@ -486,7 +525,6 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
|
||||
- PostgreSQL support for larger deployments
|
||||
- Advanced filtering and search
|
||||
- Export roster to various formats
|
||||
- Automated backup and restore
|
||||
|
||||
## License
|
||||
|
||||
@@ -494,9 +532,13 @@ MIT
|
||||
|
||||
## 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)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from typing import List, Dict
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.database import engine, Base, get_db
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
from backend.models import IgnoredUnit
|
||||
|
||||
@@ -20,7 +20,7 @@ Base.metadata.create_all(bind=engine)
|
||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||
|
||||
# Initialize FastAPI app
|
||||
VERSION = "0.3.2"
|
||||
VERSION = "0.4.0"
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
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(dashboard.router)
|
||||
app.include_router(dashboard_tabs.router)
|
||||
app.include_router(activity.router)
|
||||
|
||||
from backend.routers import settings
|
||||
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)
|
||||
|
||||
|
||||
class UnitHistory(Base):
|
||||
"""
|
||||
Unit history: complete timeline of changes to each unit.
|
||||
Tracks note changes, status changes, deployment/benched events, and more.
|
||||
"""
|
||||
__tablename__ = "unit_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
|
||||
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
|
||||
field_name = Column(String, nullable=True) # Which field changed
|
||||
old_value = Column(Text, nullable=True) # Previous value
|
||||
new_value = Column(Text, nullable=True) # New value
|
||||
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
|
||||
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
|
||||
notes = Column(Text, nullable=True) # Optional reason/context for the change
|
||||
|
||||
|
||||
class UserPreferences(Base):
|
||||
"""
|
||||
User preferences: persistent storage for application settings.
|
||||
|
||||
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.responses import FileResponse
|
||||
from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
import shutil
|
||||
from PIL import Image
|
||||
from PIL.ExifTags import TAGS, GPSTAGS
|
||||
from sqlalchemy.orm import Session
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["photos"])
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
def extract_exif_data(image_path: Path) -> dict:
|
||||
"""
|
||||
Extract EXIF metadata from an image file.
|
||||
Returns dict with timestamp, GPS coordinates, and other metadata.
|
||||
"""
|
||||
try:
|
||||
image = Image.open(image_path)
|
||||
exif_data = image._getexif()
|
||||
|
||||
if not exif_data:
|
||||
return {}
|
||||
|
||||
metadata = {}
|
||||
|
||||
# Extract standard EXIF tags
|
||||
for tag_id, value in exif_data.items():
|
||||
tag = TAGS.get(tag_id, tag_id)
|
||||
|
||||
# Extract datetime
|
||||
if tag == "DateTime" or tag == "DateTimeOriginal":
|
||||
try:
|
||||
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
|
||||
except:
|
||||
pass
|
||||
|
||||
# Extract GPS data
|
||||
if tag == "GPSInfo":
|
||||
gps_data = {}
|
||||
for gps_tag_id in value:
|
||||
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
|
||||
gps_data[gps_tag] = value[gps_tag_id]
|
||||
|
||||
# Convert GPS data to decimal degrees
|
||||
lat = gps_data.get("GPSLatitude")
|
||||
lat_ref = gps_data.get("GPSLatitudeRef")
|
||||
lon = gps_data.get("GPSLongitude")
|
||||
lon_ref = gps_data.get("GPSLongitudeRef")
|
||||
|
||||
if lat and lon and lat_ref and lon_ref:
|
||||
# Convert to decimal degrees
|
||||
lat_decimal = convert_to_degrees(lat)
|
||||
if lat_ref == "S":
|
||||
lat_decimal = -lat_decimal
|
||||
|
||||
lon_decimal = convert_to_degrees(lon)
|
||||
if lon_ref == "W":
|
||||
lon_decimal = -lon_decimal
|
||||
|
||||
metadata["latitude"] = lat_decimal
|
||||
metadata["longitude"] = lon_decimal
|
||||
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
|
||||
|
||||
return metadata
|
||||
except Exception as e:
|
||||
print(f"Error extracting EXIF data: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def convert_to_degrees(value):
|
||||
"""
|
||||
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
|
||||
"""
|
||||
d, m, s = value
|
||||
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
|
||||
|
||||
|
||||
@router.post("/unit/{unit_id}/upload-photo")
|
||||
async def upload_photo(
|
||||
unit_id: str,
|
||||
photo: UploadFile = File(...),
|
||||
auto_populate_coords: bool = True,
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Upload a photo for a unit and extract EXIF metadata.
|
||||
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
|
||||
"""
|
||||
# Validate file type
|
||||
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
file_ext = Path(photo.filename).suffix.lower()
|
||||
|
||||
if file_ext not in allowed_extensions:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
|
||||
)
|
||||
|
||||
# Create photos directory for this unit
|
||||
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||
unit_photo_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate filename with timestamp to avoid collisions
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
filename = f"{timestamp}_{photo.filename}"
|
||||
file_path = unit_photo_dir / filename
|
||||
|
||||
# Save the file
|
||||
try:
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(photo.file, buffer)
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
|
||||
|
||||
# Extract EXIF metadata
|
||||
metadata = extract_exif_data(file_path)
|
||||
|
||||
# Update unit coordinates if GPS data exists and auto_populate_coords is True
|
||||
coordinates_updated = False
|
||||
if auto_populate_coords and "coordinates" in metadata:
|
||||
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
|
||||
if roster_unit:
|
||||
roster_unit.coordinates = metadata["coordinates"]
|
||||
roster_unit.last_updated = datetime.utcnow()
|
||||
db.commit()
|
||||
coordinates_updated = True
|
||||
|
||||
return JSONResponse(content={
|
||||
"success": True,
|
||||
"filename": filename,
|
||||
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
|
||||
"metadata": {
|
||||
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
|
||||
"latitude": metadata.get("latitude"),
|
||||
"longitude": metadata.get("longitude"),
|
||||
"coordinates": metadata.get("coordinates")
|
||||
},
|
||||
"coordinates_updated": coordinates_updated
|
||||
})
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/photos")
|
||||
def get_unit_photos(unit_id: str):
|
||||
"""
|
||||
@@ -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}")
|
||||
def get_photo(unit_id: str, filename: str):
|
||||
"""
|
||||
|
||||
@@ -5,11 +5,28 @@ import csv
|
||||
import io
|
||||
|
||||
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"])
|
||||
|
||||
|
||||
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
|
||||
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
|
||||
"""Helper function to record a change in unit history"""
|
||||
history_entry = UnitHistory(
|
||||
unit_id=unit_id,
|
||||
change_type=change_type,
|
||||
field_name=field_name,
|
||||
old_value=old_value,
|
||||
new_value=new_value,
|
||||
changed_at=datetime.utcnow(),
|
||||
source=source,
|
||||
notes=notes
|
||||
)
|
||||
db.add(history_entry)
|
||||
# Note: caller is responsible for db.commit()
|
||||
|
||||
|
||||
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
@@ -154,6 +171,11 @@ def edit_roster_unit(
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
|
||||
|
||||
# Track changes for history
|
||||
old_note = unit.note
|
||||
old_deployed = unit.deployed
|
||||
old_retired = unit.retired
|
||||
|
||||
# Update all fields
|
||||
unit.device_type = device_type
|
||||
unit.unit_type = unit_type
|
||||
@@ -176,6 +198,20 @@ def edit_roster_unit(
|
||||
unit.phone_number = phone_number if phone_number else None
|
||||
unit.hardware_model = hardware_model if hardware_model else None
|
||||
|
||||
# Record history entries for changed fields
|
||||
if old_note != note:
|
||||
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
|
||||
|
||||
if old_deployed != deployed:
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
|
||||
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
|
||||
|
||||
db.commit()
|
||||
return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
|
||||
|
||||
@@ -183,8 +219,24 @@ def edit_roster_unit(
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_deployed = unit.deployed
|
||||
unit.deployed = deployed
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for deployed status change
|
||||
if old_deployed != deployed:
|
||||
status_text = "deployed" if deployed else "benched"
|
||||
old_status_text = "deployed" if old_deployed else "benched"
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="deployed_change",
|
||||
field_name="deployed",
|
||||
old_value=old_status_text,
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||
|
||||
@@ -192,8 +244,24 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
|
||||
@router.post("/set-retired/{unit_id}")
|
||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_retired = unit.retired
|
||||
unit.retired = retired
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for retired status change
|
||||
if old_retired != retired:
|
||||
status_text = "retired" if retired else "active"
|
||||
old_status_text = "retired" if old_retired else "active"
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="retired_change",
|
||||
field_name="retired",
|
||||
old_value=old_status_text,
|
||||
new_value=status_text,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||
|
||||
@@ -235,8 +303,22 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||
@router.post("/set-note/{unit_id}")
|
||||
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
old_note = unit.note
|
||||
unit.note = note
|
||||
unit.last_updated = datetime.utcnow()
|
||||
|
||||
# Record history entry for note change
|
||||
if old_note != note:
|
||||
record_history(
|
||||
db=db,
|
||||
unit_id=unit_id,
|
||||
change_type="note_change",
|
||||
field_name="note",
|
||||
old_value=old_note,
|
||||
new_value=note,
|
||||
source="manual"
|
||||
)
|
||||
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "note": note}
|
||||
|
||||
@@ -402,3 +484,46 @@ def list_ignored_units(db: Session = Depends(get_db)):
|
||||
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.responses import StreamingResponse
|
||||
from fastapi.responses import StreamingResponse, FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, date
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
import csv
|
||||
import io
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||
from backend.services.database_backup import DatabaseBackupService
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
@@ -325,3 +328,144 @@ def update_preferences(
|
||||
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||
}
|
||||
|
||||
|
||||
# Database Management Endpoints
|
||||
|
||||
backup_service = DatabaseBackupService()
|
||||
|
||||
|
||||
@router.get("/database/stats")
|
||||
def get_database_stats():
|
||||
"""Get current database statistics"""
|
||||
try:
|
||||
stats = backup_service.get_database_stats()
|
||||
return stats
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/database/snapshot")
|
||||
def create_database_snapshot(description: Optional[str] = None):
|
||||
"""Create a full database snapshot"""
|
||||
try:
|
||||
snapshot = backup_service.create_snapshot(description=description)
|
||||
return {
|
||||
"message": "Snapshot created successfully",
|
||||
"snapshot": snapshot
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/database/snapshots")
|
||||
def list_database_snapshots():
|
||||
"""List all available database snapshots"""
|
||||
try:
|
||||
snapshots = backup_service.list_snapshots()
|
||||
return {
|
||||
"snapshots": snapshots,
|
||||
"count": len(snapshots)
|
||||
}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/database/snapshot/{filename}")
|
||||
def download_snapshot(filename: str):
|
||||
"""Download a specific snapshot file"""
|
||||
try:
|
||||
snapshot_path = backup_service.download_snapshot(filename)
|
||||
return FileResponse(
|
||||
path=str(snapshot_path),
|
||||
filename=filename,
|
||||
media_type="application/x-sqlite3"
|
||||
)
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
|
||||
|
||||
|
||||
@router.delete("/database/snapshot/{filename}")
|
||||
def delete_database_snapshot(filename: str):
|
||||
"""Delete a specific snapshot"""
|
||||
try:
|
||||
backup_service.delete_snapshot(filename)
|
||||
return {
|
||||
"message": f"Snapshot {filename} deleted successfully",
|
||||
"filename": filename
|
||||
}
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
|
||||
|
||||
|
||||
class RestoreRequest(BaseModel):
|
||||
"""Schema for restore request"""
|
||||
filename: str
|
||||
create_backup: bool = True
|
||||
|
||||
|
||||
@router.post("/database/restore")
|
||||
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
|
||||
"""Restore database from a snapshot"""
|
||||
try:
|
||||
# Close the database connection before restoring
|
||||
db.close()
|
||||
|
||||
result = backup_service.restore_snapshot(
|
||||
filename=request.filename,
|
||||
create_backup_before_restore=request.create_backup
|
||||
)
|
||||
|
||||
return result
|
||||
except FileNotFoundError:
|
||||
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
|
||||
|
||||
|
||||
@router.post("/database/upload-snapshot")
|
||||
async def upload_snapshot(file: UploadFile = File(...)):
|
||||
"""Upload a snapshot file to the backups directory"""
|
||||
if not file.filename.endswith('.db'):
|
||||
raise HTTPException(status_code=400, detail="File must be a .db file")
|
||||
|
||||
try:
|
||||
# Save uploaded file to backups directory
|
||||
backups_dir = Path("./data/backups")
|
||||
backups_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
|
||||
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
|
||||
file_path = backups_dir / uploaded_filename
|
||||
|
||||
# Save file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Create metadata
|
||||
metadata = {
|
||||
"filename": uploaded_filename,
|
||||
"created_at": timestamp,
|
||||
"created_at_iso": datetime.utcnow().isoformat(),
|
||||
"description": f"Uploaded: {file.filename}",
|
||||
"size_bytes": file_path.stat().st_size,
|
||||
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
|
||||
"type": "uploaded"
|
||||
}
|
||||
|
||||
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
|
||||
import json
|
||||
with open(metadata_path, 'w') as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
|
||||
return {
|
||||
"message": "Snapshot uploaded successfully",
|
||||
"snapshot": metadata
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
|
||||
|
||||
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 {
|
||||
display: inline-flex;
|
||||
|
||||
@@ -20,12 +20,14 @@ function toggleMenu() {
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('menu-open');
|
||||
} else {
|
||||
// Open menu
|
||||
sidebar.classList.add('open');
|
||||
backdrop.classList.add('show');
|
||||
hamburgerBtn?.classList.add('menu-open');
|
||||
document.body.style.overflow = 'hidden';
|
||||
document.body.classList.add('menu-open');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +43,7 @@ function closeMenuFromBackdrop() {
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
document.body.style.overflow = '';
|
||||
document.body.classList.remove('menu-open');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ function handleResize() {
|
||||
backdrop.classList.remove('show');
|
||||
hamburgerBtn?.classList.remove('menu-open');
|
||||
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
|
||||
jinja2==3.1.2
|
||||
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 %}
|
||||
</head>
|
||||
<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 -->
|
||||
<div id="offlineIndicator" class="offline-indicator">
|
||||
@@ -172,6 +164,12 @@
|
||||
<!-- Bottom Navigation (Mobile Only) -->
|
||||
<nav class="bottom-nav">
|
||||
<div class="grid grid-cols-4 h-16">
|
||||
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
<span>Menu</span>
|
||||
</button>
|
||||
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
|
||||
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
|
||||
@@ -184,12 +182,6 @@
|
||||
</svg>
|
||||
<span>Roster</span>
|
||||
</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'">
|
||||
<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>
|
||||
@@ -368,10 +360,10 @@
|
||||
</script>
|
||||
|
||||
<!-- 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 -->
|
||||
<script src="/static/mobile.js?v=0.3.2"></script>
|
||||
<script src="/static/mobile.js?v=0.4.0"></script>
|
||||
|
||||
{% block extra_scripts %}{% endblock %}
|
||||
</body>
|
||||
|
||||
@@ -30,16 +30,21 @@
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-summary-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="space-y-3 card-content" id="fleet-summary-content">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
|
||||
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
|
||||
@@ -92,90 +97,134 @@
|
||||
</div>
|
||||
|
||||
<!-- Recent Alerts Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-alerts-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3">
|
||||
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Photos Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="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>
|
||||
<!-- Recently Called In Units Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
|
||||
</path>
|
||||
</svg>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-callins-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" 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>
|
||||
<p class="text-sm">No recent photos</p>
|
||||
<div class="card-content" id="recent-callins-content">
|
||||
<div id="recent-callins-list" class="space-y-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
|
||||
</div>
|
||||
<button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
|
||||
Show all recent call-ins
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Fleet Map -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
|
||||
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
|
||||
<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 id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center">
|
||||
Full Roster
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
|
||||
Full Roster
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||
hx-get="/dashboard/active"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Active
|
||||
</button>
|
||||
<div class="card-content" id="fleet-status-content">
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||
hx-get="/dashboard/active"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Active
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button"
|
||||
hx-get="/dashboard/benched"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Benched
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button"
|
||||
hx-get="/dashboard/benched"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Benched
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Target -->
|
||||
<div id="fleet-table" class="space-y-2"
|
||||
hx-get="/dashboard/active"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||
<!-- Tab Content Target -->
|
||||
<div id="fleet-table" class="space-y-2"
|
||||
hx-get="/dashboard/active"
|
||||
hx-trigger="load"
|
||||
hx-swap="innerHTML">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -195,10 +244,82 @@
|
||||
color: #b84a12 !important; /* seismo orange */
|
||||
border-bottom: 2px solid #b84a12 !important;
|
||||
}
|
||||
|
||||
/* Collapsible cards (mobile only) */
|
||||
@media (max-width: 767px) {
|
||||
.card-content.collapsed {
|
||||
display: none;
|
||||
}
|
||||
.chevron.collapsed {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<script>
|
||||
// Toggle card collapse/expand (mobile only)
|
||||
function toggleCard(cardName) {
|
||||
// Only work on mobile
|
||||
if (window.innerWidth >= 768) return;
|
||||
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Toggle collapsed state
|
||||
const isCollapsed = content.classList.contains('collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
content.classList.remove('collapsed');
|
||||
chevron.classList.remove('collapsed');
|
||||
|
||||
// If expanding the fleet map, invalidate size after animation
|
||||
if (cardName === 'fleet-map' && window.fleetMap) {
|
||||
setTimeout(() => {
|
||||
window.fleetMap.invalidateSize();
|
||||
}, 300);
|
||||
}
|
||||
} else {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
}
|
||||
|
||||
// Save state to localStorage
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
cardStates[cardName] = !isCollapsed;
|
||||
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
|
||||
}
|
||||
|
||||
// Restore card states from localStorage on page load
|
||||
function restoreCardStates() {
|
||||
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
|
||||
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
|
||||
|
||||
cardNames.forEach(cardName => {
|
||||
const content = document.getElementById(`${cardName}-content`);
|
||||
const chevron = document.getElementById(`${cardName}-chevron`);
|
||||
|
||||
if (!content || !chevron) return;
|
||||
|
||||
// Default to expanded (true) if no saved state
|
||||
const isCollapsed = cardStates[cardName] === false;
|
||||
|
||||
if (isCollapsed) {
|
||||
content.classList.add('collapsed');
|
||||
chevron.classList.add('collapsed');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Restore states when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', restoreCardStates);
|
||||
} else {
|
||||
restoreCardStates();
|
||||
}
|
||||
|
||||
function updateDashboard(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.response);
|
||||
@@ -276,15 +397,24 @@ let fleetMap = null;
|
||||
let fleetMarkers = [];
|
||||
let fleetMapInitialized = false;
|
||||
|
||||
// Make fleetMap accessible globally for toggleCard function
|
||||
window.fleetMap = null;
|
||||
|
||||
function initFleetMap() {
|
||||
// Initialize the map centered on the US (can adjust based on your deployment area)
|
||||
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
|
||||
window.fleetMap = fleetMap;
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(fleetMap);
|
||||
|
||||
// Force map to recalculate size after a brief delay to ensure container is fully rendered
|
||||
setTimeout(() => {
|
||||
fleetMap.invalidateSize();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function updateFleetMap(data) {
|
||||
@@ -336,9 +466,11 @@ function updateFleetMap(data) {
|
||||
}
|
||||
});
|
||||
|
||||
// Fit map to show all markers only on first load
|
||||
if (bounds.length > 0 && !fleetMapInitialized) {
|
||||
fleetMap.fitBounds(bounds, { padding: [50, 50] });
|
||||
// Fit map to show all markers
|
||||
if (bounds.length > 0) {
|
||||
// Use different padding for mobile vs desktop
|
||||
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
|
||||
fleetMap.fitBounds(bounds, { padding: padding });
|
||||
fleetMapInitialized = true;
|
||||
}
|
||||
}
|
||||
@@ -359,6 +491,130 @@ function parseLocation(location) {
|
||||
// TODO: Add geocoding support for address strings
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load and display recent photos
|
||||
async function loadRecentPhotos() {
|
||||
try {
|
||||
const response = await fetch('/api/recent-photos?limit=12');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent photos');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const gallery = document.getElementById('recentPhotosGallery');
|
||||
|
||||
if (data.photos && data.photos.length > 0) {
|
||||
gallery.innerHTML = '';
|
||||
data.photos.forEach(photo => {
|
||||
const photoDiv = document.createElement('div');
|
||||
photoDiv.className = 'relative group';
|
||||
photoDiv.innerHTML = `
|
||||
<a href="/unit/${photo.unit_id}" class="block">
|
||||
<img src="${photo.path}" alt="${photo.unit_id}"
|
||||
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
|
||||
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
|
||||
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
|
||||
</div>
|
||||
</a>
|
||||
`;
|
||||
gallery.appendChild(photoDiv);
|
||||
});
|
||||
} else {
|
||||
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading recent photos:', error);
|
||||
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load recent photos on page load and refresh every 30 seconds
|
||||
loadRecentPhotos();
|
||||
setInterval(loadRecentPhotos, 30000);
|
||||
|
||||
// Load and display recent call-ins
|
||||
let showingAllCallins = false;
|
||||
const DEFAULT_CALLINS_DISPLAY = 5;
|
||||
|
||||
async function loadRecentCallins() {
|
||||
try {
|
||||
const response = await fetch('/api/recent-callins?hours=6');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load recent call-ins');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const callinsList = document.getElementById('recent-callins-list');
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
|
||||
if (data.call_ins && data.call_ins.length > 0) {
|
||||
// Determine how many to show
|
||||
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
|
||||
const callinsToDisplay = data.call_ins.slice(0, displayCount);
|
||||
|
||||
// Build HTML for call-ins list
|
||||
let html = '';
|
||||
callinsToDisplay.forEach(callin => {
|
||||
// Status color
|
||||
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
|
||||
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
|
||||
|
||||
// Build location/note line
|
||||
let subtitle = '';
|
||||
if (callin.location) {
|
||||
subtitle = callin.location;
|
||||
} else if (callin.note) {
|
||||
subtitle = callin.note;
|
||||
}
|
||||
|
||||
html += `
|
||||
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
|
||||
<div>
|
||||
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
|
||||
${callin.unit_id}
|
||||
</a>
|
||||
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
|
||||
</div>`;
|
||||
});
|
||||
|
||||
callinsList.innerHTML = html;
|
||||
|
||||
// Show/hide the "Show all" button
|
||||
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
|
||||
showAllButton.classList.remove('hidden');
|
||||
showAllButton.textContent = showingAllCallins
|
||||
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
|
||||
: `Show all (${data.call_ins.length})`;
|
||||
} else {
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} else {
|
||||
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
|
||||
showAllButton.classList.add('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading recent call-ins:', error);
|
||||
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle show all/show fewer
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const showAllButton = document.getElementById('show-all-callins');
|
||||
showAllButton.addEventListener('click', function() {
|
||||
showingAllCallins = !showingAllCallins;
|
||||
loadRecentCallins();
|
||||
});
|
||||
});
|
||||
|
||||
// Load recent call-ins on page load and refresh every 30 seconds
|
||||
loadRecentCallins();
|
||||
setInterval(loadRecentCallins, 30000);
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -6,11 +6,11 @@
|
||||
<!-- Status Indicator -->
|
||||
<div class="flex-shrink-0">
|
||||
{% 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' %}
|
||||
<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 %}
|
||||
<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 %}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<div class="flex items-center space-x-3 flex-1">
|
||||
<!-- Status Indicator (grayed out for benched) -->
|
||||
<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>
|
||||
|
||||
<!-- Unit Info -->
|
||||
|
||||
@@ -229,32 +229,14 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Location (Tap to Navigate) -->
|
||||
<!-- Location -->
|
||||
{% if unit.address %}
|
||||
<div class="text-sm mb-1">
|
||||
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.address | urlencode }}"
|
||||
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 class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.address }}
|
||||
</div>
|
||||
{% elif unit.coordinates %}
|
||||
<div class="text-sm mb-1">
|
||||
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.coordinates | urlencode }}"
|
||||
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 class="text-sm text-gray-600 dark:text-gray-400 mb-1">
|
||||
📍 {{ unit.coordinates }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
|
||||
@@ -20,7 +20,13 @@
|
||||
<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>
|
||||
</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 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">
|
||||
@@ -214,30 +220,24 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
|
||||
</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 -->
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<form id="importReplaceForm" class="space-y-4">
|
||||
@@ -257,6 +257,116 @@
|
||||
</button>
|
||||
</form>
|
||||
</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 -->
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -177,6 +177,46 @@
|
||||
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
|
||||
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
|
||||
</div>
|
||||
|
||||
<!-- Unit History Timeline -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
|
||||
<div id="historyTimeline" class="space-y-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photos -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
|
||||
<div class="flex flex-col sm:flex-row gap-2">
|
||||
<!-- Take Photo Button (Camera) -->
|
||||
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Take Photo</span>
|
||||
<span class="sm:hidden">Camera</span>
|
||||
</label>
|
||||
<!-- Choose from Library Button -->
|
||||
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<span class="hidden sm:inline">Choose Photo</span>
|
||||
<span class="sm:hidden">Library</span>
|
||||
</label>
|
||||
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
|
||||
</div>
|
||||
</div>
|
||||
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
|
||||
</div>
|
||||
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -632,7 +672,228 @@ function parseLocation(location) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Load and display photos
|
||||
async function loadPhotos() {
|
||||
try {
|
||||
const response = await fetch(`/api/unit/${unitId}/photos`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load photos');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const gallery = document.getElementById('photoGallery');
|
||||
|
||||
if (data.photos && data.photos.length > 0) {
|
||||
gallery.innerHTML = '';
|
||||
data.photo_urls.forEach((url, index) => {
|
||||
const photoDiv = document.createElement('div');
|
||||
photoDiv.className = 'relative group';
|
||||
photoDiv.innerHTML = `
|
||||
<img src="${url}" alt="Unit photo ${index + 1}"
|
||||
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
|
||||
onclick="window.open('${url}', '_blank')">
|
||||
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
|
||||
`;
|
||||
gallery.appendChild(photoDiv);
|
||||
});
|
||||
} else {
|
||||
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading photos:', error);
|
||||
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Upload photo with EXIF metadata extraction
|
||||
async function uploadPhoto(file) {
|
||||
if (!file) return;
|
||||
|
||||
const statusDiv = document.getElementById('uploadStatus');
|
||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
|
||||
statusDiv.textContent = 'Uploading photo and extracting metadata...';
|
||||
statusDiv.classList.remove('hidden');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('photo', file);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Upload failed');
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
// Show success message with metadata info
|
||||
let message = 'Photo uploaded successfully!';
|
||||
if (result.metadata && result.metadata.coordinates) {
|
||||
message += ` GPS location detected: ${result.metadata.coordinates}`;
|
||||
if (result.coordinates_updated) {
|
||||
message += ' (Unit coordinates updated automatically)';
|
||||
}
|
||||
} else {
|
||||
message += ' No GPS data found in photo.';
|
||||
}
|
||||
|
||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||
statusDiv.textContent = message;
|
||||
|
||||
// Reload photos and unit data
|
||||
await loadPhotos();
|
||||
if (result.coordinates_updated) {
|
||||
await loadUnitData();
|
||||
}
|
||||
|
||||
// Hide status after 5 seconds
|
||||
setTimeout(() => {
|
||||
statusDiv.classList.add('hidden');
|
||||
}, 5000);
|
||||
|
||||
// Reset both file inputs
|
||||
document.getElementById('photoCameraUpload').value = '';
|
||||
document.getElementById('photoLibraryUpload').value = '';
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error uploading photo:', error);
|
||||
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||
statusDiv.textContent = `Error uploading photo: ${error.message}`;
|
||||
|
||||
// Hide error after 5 seconds
|
||||
setTimeout(() => {
|
||||
statusDiv.classList.add('hidden');
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
// Load and display unit history timeline
|
||||
async function loadUnitHistory() {
|
||||
try {
|
||||
const response = await fetch(`/api/roster/history/${unitId}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load history');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const timeline = document.getElementById('historyTimeline');
|
||||
|
||||
if (data.history && data.history.length > 0) {
|
||||
timeline.innerHTML = '';
|
||||
data.history.forEach(entry => {
|
||||
const timelineEntry = createTimelineEntry(entry);
|
||||
timeline.appendChild(timelineEntry);
|
||||
});
|
||||
} else {
|
||||
timeline.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading history:', error);
|
||||
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Create a timeline entry element
|
||||
function createTimelineEntry(entry) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50';
|
||||
|
||||
// Icon based on change type
|
||||
const icons = {
|
||||
'note_change': `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
||||
</svg>`,
|
||||
'deployed_change': `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`,
|
||||
'retired_change': `<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`
|
||||
};
|
||||
|
||||
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>`;
|
||||
|
||||
// Format change description
|
||||
let description = '';
|
||||
if (entry.change_type === 'note_change') {
|
||||
description = `<strong>Note changed</strong>`;
|
||||
if (entry.old_value) {
|
||||
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
|
||||
}
|
||||
if (entry.new_value) {
|
||||
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
|
||||
}
|
||||
} else if (entry.change_type === 'deployed_change') {
|
||||
description = `<strong>Status changed to ${entry.new_value}</strong>`;
|
||||
} else if (entry.change_type === 'retired_change') {
|
||||
description = `<strong>Marked as ${entry.new_value}</strong>`;
|
||||
} else {
|
||||
description = `<strong>${entry.field_name} changed</strong>`;
|
||||
if (entry.old_value && entry.new_value) {
|
||||
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value} → ${entry.new_value}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Format timestamp
|
||||
const timestamp = new Date(entry.changed_at).toLocaleString();
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="flex-shrink-0">
|
||||
${icon}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-gray-900 dark:text-white">
|
||||
${description}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
${timestamp}
|
||||
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-shrink-0">
|
||||
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
return div;
|
||||
}
|
||||
|
||||
// Delete a history entry
|
||||
async function deleteHistoryEntry(historyId) {
|
||||
if (!confirm('Are you sure you want to delete this history entry?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/roster/history/${historyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Reload history
|
||||
await loadUnitHistory();
|
||||
} else {
|
||||
const result = await response.json();
|
||||
alert(`Error: ${result.detail || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Load data when page loads
|
||||
loadUnitData();
|
||||
loadUnitData().then(() => {
|
||||
loadPhotos();
|
||||
loadUnitHistory();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user