Update main to 0.4 from dev

Update main to 0.4 from dev
This commit is contained in:
serversdwn
2025-12-16 20:41:19 +00:00
committed by GitHub
25 changed files with 2858 additions and 146 deletions

View File

@@ -5,6 +5,90 @@ All notable changes to Seismo Fleet Manager will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.4.0] - 2025-12-16
### Added
- **Database Management System**: Comprehensive backup and restore capabilities
- **Manual Snapshots**: Create on-demand backups of the entire database with optional descriptions
- **Restore from Snapshot**: Restore database from any snapshot with automatic safety backup
- **Upload/Download Snapshots**: Transfer database snapshots to/from the server
- **Database Tab**: New dedicated tab in Settings for all database management operations
- **Database Statistics**: View database size, row counts by table, and last modified time
- **Snapshot Metadata**: Each snapshot includes creation time, description, size, and type (manual/automatic)
- **Safety Backups**: Automatic backup created before any restore operation
- **Remote Database Cloning**: Dev tools for cloning production database to remote development servers
- **Clone Script**: `scripts/clone_db_to_dev.py` for copying database over WAN
- **Network Upload**: Upload snapshots via HTTP to remote servers
- **Auto-restore**: Automatically restore uploaded database on target server
- **Authentication Support**: Optional token-based authentication for secure transfers
- **Automatic Backup Scheduler**: Background service for automated database backups
- **Configurable Intervals**: Set backup frequency (default: 24 hours)
- **Retention Management**: Automatically delete old backups (configurable keep count)
- **Manual Trigger**: Force immediate backup via API
- **Status Monitoring**: Check scheduler status and next scheduled run time
- **Background Thread**: Non-blocking operation using Python threading
- **Settings Reorganization**: Improved tab structure for better organization
- Renamed "Data Management" tab to "Roster Management"
- Moved CSV Replace Mode from Advanced tab to Roster Management tab
- Created dedicated Database tab for all backup/restore operations
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` guide covering:
- Manual snapshot creation and restoration workflows
- Download/upload procedures for off-site backups
- Remote database cloning setup and usage
- Automatic backup configuration and integration
- API reference for all database endpoints
- Best practices and troubleshooting guide
### Changed
- **Settings Tab Organization**: Restructured for better logical grouping
- **General**: Display preferences (timezone, theme, auto-refresh)
- **Roster Management**: CSV operations and roster table (now includes Replace Mode)
- **Database**: All backup/restore operations (NEW)
- **Advanced**: Power user settings (calibration, thresholds)
- **Danger Zone**: Destructive operations
- CSV Replace Mode warnings enhanced and moved to Roster Management context
### Technical Details
- **SQLite Backup API**: Uses native SQLite backup API for concurrent-safe snapshots
- **Metadata Tracking**: JSON sidecar files store snapshot metadata alongside database files
- **Atomic Operations**: Database restoration is atomic with automatic rollback on failure
- **File Structure**: Snapshots stored in `./data/backups/` with timestamped filenames
- **API Endpoints**: 7 new endpoints for database management operations
- **Backup Service**: `backend/services/database_backup.py` - Core backup/restore logic
- **Scheduler Service**: `backend/services/backup_scheduler.py` - Automatic backup automation
- **Clone Utility**: `scripts/clone_db_to_dev.py` - Remote database synchronization tool
### Security Considerations
- Snapshots contain full database data and should be secured appropriately
- Remote cloning supports optional authentication tokens
- Restore operations require safety backup creation by default
- All destructive operations remain in Danger Zone with warnings
### Migration Notes
No database migration required for v0.4.0. All new features use existing database structure and add new backup management capabilities without modifying the core schema.
## [0.3.3] - 2025-12-12
### Changed
- **Mobile Navigation**: Moved hamburger menu button from floating top-right to bottom navigation bar
- Bottom nav now shows: Menu (hamburger), Dashboard, Roster, Settings
- Removed "Add Unit" from bottom nav (still accessible via sidebar menu)
- Hamburger no longer floats over content on mobile
- **Status Dot Visibility**: Increased status dot size from 12px to 16px (w-3/h-3 → w-4/h-4) in dashboard fleet overview for better at-a-glance visibility
- Affects both Active and Benched tabs in dashboard
- Makes status colors (green/yellow/red) easier to spot during quick scroll
### Fixed
- **Location Navigation**: Moved tap-to-navigate functionality from roster card view to unit detail modal only
- Roster cards now show simple location text with pin emoji
- Navigation links (opening Maps app) only appear in the modal when tapping a unit
- Reduces visual clutter and accidental navigation triggers
### Technical Details
- Bottom navigation remains at 4 buttons, first button now triggers sidebar menu
- Removed standalone hamburger button element and associated CSS
- Modal already had navigation links, no changes needed there
## [0.3.2] - 2025-12-12 ## [0.3.2] - 2025-12-12
### Added ### Added
@@ -209,6 +293,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Photo management per unit - Photo management per unit
- Automated status categorization (OK/Pending/Missing) - Automated status categorization (OK/Pending/Missing)
[0.4.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.3...v0.4.0
[0.3.3]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2 [0.3.2]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.1...v0.3.2
[0.3.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.0...v0.3.1 [0.3.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.3.0...v0.3.1
[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0 [0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0

View File

@@ -1,4 +1,4 @@
# Seismo Fleet Manager v0.3.2 # Seismo Fleet Manager v0.4.0
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
@@ -19,6 +19,12 @@ Backend API and HTMX-powered web interface for managing a mixed fleet of seismog
- **Photo Management**: Upload and view photos for each unit - **Photo Management**: Upload and view photos for each unit
- **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile - **Interactive Maps**: Leaflet-based maps showing unit locations with tap-to-navigate for mobile
- **SQLite Storage**: Lightweight, file-based database for easy deployment - **SQLite Storage**: Lightweight, file-based database for easy deployment
- **Database Management**: Comprehensive backup and restore system
- **Manual Snapshots**: Create on-demand backups with descriptions
- **Restore from Snapshot**: Restore database with automatic safety backups
- **Upload/Download**: Transfer database snapshots for off-site storage
- **Remote Cloning**: Copy production database to remote dev servers over WAN
- **Automatic Backups**: Scheduled background backups with configurable retention
## Roster Manager & Settings ## Roster Manager & Settings
@@ -26,10 +32,12 @@ Visit [`/settings`](http://localhost:8001/settings) to perform bulk roster opera
- **CSV export/import**: Download the entire roster, merge updates, or replace all units in one transaction. - **CSV export/import**: Download the entire roster, merge updates, or replace all units in one transaction.
- **Live roster table**: Fetch every unit via HTMX, edit metadata, toggle deployed/retired states, move emitters to the ignore list, or delete records in-place. - **Live roster table**: Fetch every unit via HTMX, edit metadata, toggle deployed/retired states, move emitters to the ignore list, or delete records in-place.
- **Database backups**: Create snapshots, restore from backups, upload/download database files, view database statistics.
- **Remote cloning**: Clone production database to remote development servers over the network (see `scripts/clone_db_to_dev.py`).
- **Stats at a glance**: View counts for the roster, emitters, and ignored units to confirm import/cleanup operations worked. - **Stats at a glance**: View counts for the roster, emitters, and ignored units to confirm import/cleanup operations worked.
- **Danger zone controls**: Clear specific tables or wipe all fleet data when resetting a lab/demo environment. - **Danger zone controls**: Clear specific tables or wipe all fleet data when resetting a lab/demo environment.
All UI actions call `GET/POST /api/settings/*` endpoints so you can automate the same workflows from scripts. All UI actions call `GET/POST /api/settings/*` endpoints so you can automate the same workflows from scripts. See [docs/DATABASE_MANAGEMENT.md](docs/DATABASE_MANAGEMENT.md) for comprehensive database backup and restore documentation.
## Tech Stack ## Tech Stack
@@ -180,6 +188,17 @@ Both migration scripts are idempotent—if the columns/tables already exist, the
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters - **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
- **POST** `/api/settings/clear-ignored` - Reset ignore list - **POST** `/api/settings/clear-ignored` - Reset ignore list
### Database Management
- **GET** `/api/settings/database/stats` - Database size, row counts, and last modified time
- **POST** `/api/settings/database/snapshot` - Create manual database snapshot with optional description
- **GET** `/api/settings/database/snapshots` - List all available snapshots with metadata
- **GET** `/api/settings/database/snapshot/{filename}` - Download a specific snapshot file
- **DELETE** `/api/settings/database/snapshot/{filename}` - Delete a snapshot
- **POST** `/api/settings/database/restore` - Restore database from snapshot (creates safety backup)
- **POST** `/api/settings/database/upload-snapshot` - Upload snapshot file to server
See [docs/DATABASE_MANAGEMENT.md](docs/DATABASE_MANAGEMENT.md) for detailed documentation and examples.
### CSV Import Format ### CSV Import Format
Create a CSV file with the following columns (only `unit_id` is required, everything else is optional): Create a CSV file with the following columns (only `unit_id` is required, everything else is optional):
@@ -368,7 +387,9 @@ seismo-fleet-manager/
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints │ │ ├── dashboard_tabs.py # Dashboard tab endpoints
│ │ └── settings.py # Settings, preferences, and data management │ │ └── settings.py # Settings, preferences, and data management
│ ├── services/ │ ├── services/
│ │ ── snapshot.py # Fleet status snapshot logic │ │ ── snapshot.py # Fleet status snapshot logic
│ │ ├── database_backup.py # Database backup and restore service
│ │ └── backup_scheduler.py # Automatic backup scheduler
│ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema │ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema
│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema │ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema
│ └── static/ # Static assets (CSS, etc.) │ └── static/ # Static assets (CSS, etc.)
@@ -385,6 +406,11 @@ seismo-fleet-manager/
│ ├── ignored_table.html │ ├── ignored_table.html
│ └── unknown_emitters.html │ └── unknown_emitters.html
├── data/ # SQLite database & photos (persisted) ├── data/ # SQLite database & photos (persisted)
│ └── backups/ # Database snapshots directory
├── scripts/
│ └── clone_db_to_dev.py # Remote database cloning utility
├── docs/
│ └── DATABASE_MANAGEMENT.md # Database backup/restore guide
├── requirements.txt # Python dependencies ├── requirements.txt # Python dependencies
├── Dockerfile # Docker container definition ├── Dockerfile # Docker container definition
├── docker-compose.yml # Docker Compose configuration ├── docker-compose.yml # Docker Compose configuration
@@ -437,6 +463,19 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.4.0 — 2025-12-16
- **Database Management System**: Complete backup and restore functionality with manual snapshots, restore operations, and upload/download capabilities
- **Remote Database Cloning**: New `clone_db_to_dev.py` script for copying production database to remote dev servers over WAN
- **Automatic Backup Scheduler**: Background service for scheduled backups with configurable retention management
- **Database Tab**: New dedicated tab in Settings for all database operations with real-time statistics
- **Settings Reorganization**: Improved tab structure - renamed "Data Management" to "Roster Management", moved CSV Replace Mode, created Database tab
- **Comprehensive Documentation**: New `docs/DATABASE_MANAGEMENT.md` with complete guide to backup/restore workflows, API reference, and best practices
### v0.3.3 — 2025-12-12
- **Improved Mobile Navigation**: Hamburger menu moved to bottom nav bar (no more floating button covering content)
- **Better Status Visibility**: Larger status dots (16px) in dashboard fleet overview for easier at-a-glance status checks
- **Cleaner Roster Cards**: Location navigation links moved to detail modal only, reducing clutter in card view
### v0.3.2 — 2025-12-12 ### v0.3.2 — 2025-12-12
- **Progressive Web App (PWA)**: Complete mobile optimization with offline support, installable as standalone app - **Progressive Web App (PWA)**: Complete mobile optimization with offline support, installable as standalone app
- **Mobile-First UI**: Hamburger menu, bottom navigation bar, card-based roster view optimized for touch - **Mobile-First UI**: Hamburger menu, bottom navigation bar, card-based roster view optimized for touch
@@ -486,7 +525,6 @@ See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
- PostgreSQL support for larger deployments - PostgreSQL support for larger deployments
- Advanced filtering and search - Advanced filtering and search
- Export roster to various formats - Export roster to various formats
- Automated backup and restore
## License ## License
@@ -494,9 +532,13 @@ MIT
## Version ## Version
**Current: 0.3.2**Progressive Web App with mobile optimization (2025-12-12) **Current: 0.4.0**Database management system with backup/restore and remote cloning (2025-12-16)
Previous: 0.3.1Dashboard alerts and status fixes (2025-12-12) Previous: 0.3.3Mobile navigation improvements and better status visibility (2025-12-12)
0.3.2 — Progressive Web App with mobile optimization (2025-12-12)
0.3.1 — Dashboard alerts and status fixes (2025-12-12)
0.3.0 — Series 4 support, settings redesign, user preferences (2025-12-09) 0.3.0 — Series 4 support, settings redesign, user preferences (2025-12-09)

View File

@@ -9,7 +9,7 @@ from typing import List, Dict
from pydantic import BaseModel from pydantic import BaseModel
from backend.database import engine, Base, get_db from backend.database import engine, Base, get_db
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs, activity
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
from backend.models import IgnoredUnit from backend.models import IgnoredUnit
@@ -20,7 +20,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.3.2" VERSION = "0.4.0"
app = FastAPI( app = FastAPI(
title="Seismo Fleet Manager", title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status", description="Backend API for managing seismograph fleet status",
@@ -67,6 +67,7 @@ app.include_router(photos.router)
app.include_router(roster_edit.router) app.include_router(roster_edit.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(dashboard_tabs.router) app.include_router(dashboard_tabs.router)
app.include_router(activity.router)
from backend.routers import settings from backend.routers import settings
app.include_router(settings.router) app.include_router(settings.router)

View 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()

View File

@@ -59,6 +59,24 @@ class IgnoredUnit(Base):
ignored_at = Column(DateTime, default=datetime.utcnow) ignored_at = Column(DateTime, default=datetime.utcnow)
class UnitHistory(Base):
"""
Unit history: complete timeline of changes to each unit.
Tracks note changes, status changes, deployment/benched events, and more.
"""
__tablename__ = "unit_history"
id = Column(Integer, primary_key=True, autoincrement=True)
unit_id = Column(String, nullable=False, index=True) # FK to RosterUnit.id
change_type = Column(String, nullable=False) # note_change, deployed_change, retired_change, etc.
field_name = Column(String, nullable=True) # Which field changed
old_value = Column(Text, nullable=True) # Previous value
new_value = Column(Text, nullable=True) # New value
changed_at = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
source = Column(String, default="manual") # manual, csv_import, telemetry, offline_sync
notes = Column(Text, nullable=True) # Optional reason/context for the change
class UserPreferences(Base): class UserPreferences(Base):
""" """
User preferences: persistent storage for application settings. User preferences: persistent storage for application settings.

146
backend/routers/activity.py Normal file
View 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()
}

View File

@@ -1,14 +1,152 @@
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException, UploadFile, File, Depends
from fastapi.responses import FileResponse from fastapi.responses import FileResponse, JSONResponse
from pathlib import Path from pathlib import Path
from typing import List from typing import List, Optional
from datetime import datetime
import os import os
import shutil
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.models import RosterUnit
router = APIRouter(prefix="/api", tags=["photos"]) router = APIRouter(prefix="/api", tags=["photos"])
PHOTOS_BASE_DIR = Path("data/photos") PHOTOS_BASE_DIR = Path("data/photos")
def extract_exif_data(image_path: Path) -> dict:
"""
Extract EXIF metadata from an image file.
Returns dict with timestamp, GPS coordinates, and other metadata.
"""
try:
image = Image.open(image_path)
exif_data = image._getexif()
if not exif_data:
return {}
metadata = {}
# Extract standard EXIF tags
for tag_id, value in exif_data.items():
tag = TAGS.get(tag_id, tag_id)
# Extract datetime
if tag == "DateTime" or tag == "DateTimeOriginal":
try:
metadata["timestamp"] = datetime.strptime(str(value), "%Y:%m:%d %H:%M:%S")
except:
pass
# Extract GPS data
if tag == "GPSInfo":
gps_data = {}
for gps_tag_id in value:
gps_tag = GPSTAGS.get(gps_tag_id, gps_tag_id)
gps_data[gps_tag] = value[gps_tag_id]
# Convert GPS data to decimal degrees
lat = gps_data.get("GPSLatitude")
lat_ref = gps_data.get("GPSLatitudeRef")
lon = gps_data.get("GPSLongitude")
lon_ref = gps_data.get("GPSLongitudeRef")
if lat and lon and lat_ref and lon_ref:
# Convert to decimal degrees
lat_decimal = convert_to_degrees(lat)
if lat_ref == "S":
lat_decimal = -lat_decimal
lon_decimal = convert_to_degrees(lon)
if lon_ref == "W":
lon_decimal = -lon_decimal
metadata["latitude"] = lat_decimal
metadata["longitude"] = lon_decimal
metadata["coordinates"] = f"{lat_decimal},{lon_decimal}"
return metadata
except Exception as e:
print(f"Error extracting EXIF data: {e}")
return {}
def convert_to_degrees(value):
"""
Convert GPS coordinates from degrees/minutes/seconds to decimal degrees.
"""
d, m, s = value
return float(d) + (float(m) / 60.0) + (float(s) / 3600.0)
@router.post("/unit/{unit_id}/upload-photo")
async def upload_photo(
unit_id: str,
photo: UploadFile = File(...),
auto_populate_coords: bool = True,
db: Session = Depends(get_db)
):
"""
Upload a photo for a unit and extract EXIF metadata.
If GPS data exists and auto_populate_coords is True, update the unit's coordinates.
"""
# Validate file type
allowed_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
file_ext = Path(photo.filename).suffix.lower()
if file_ext not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed: {', '.join(allowed_extensions)}"
)
# Create photos directory for this unit
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
unit_photo_dir.mkdir(parents=True, exist_ok=True)
# Generate filename with timestamp to avoid collisions
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{timestamp}_{photo.filename}"
file_path = unit_photo_dir / filename
# Save the file
try:
with open(file_path, "wb") as buffer:
shutil.copyfileobj(photo.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to save photo: {str(e)}")
# Extract EXIF metadata
metadata = extract_exif_data(file_path)
# Update unit coordinates if GPS data exists and auto_populate_coords is True
coordinates_updated = False
if auto_populate_coords and "coordinates" in metadata:
roster_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if roster_unit:
roster_unit.coordinates = metadata["coordinates"]
roster_unit.last_updated = datetime.utcnow()
db.commit()
coordinates_updated = True
return JSONResponse(content={
"success": True,
"filename": filename,
"file_path": f"/api/unit/{unit_id}/photo/{filename}",
"metadata": {
"timestamp": metadata.get("timestamp").isoformat() if metadata.get("timestamp") else None,
"latitude": metadata.get("latitude"),
"longitude": metadata.get("longitude"),
"coordinates": metadata.get("coordinates")
},
"coordinates_updated": coordinates_updated
})
@router.get("/unit/{unit_id}/photos") @router.get("/unit/{unit_id}/photos")
def get_unit_photos(unit_id: str): def get_unit_photos(unit_id: str):
""" """
@@ -51,6 +189,46 @@ def get_unit_photos(unit_id: str):
} }
@router.get("/recent-photos")
def get_recent_photos(limit: int = 12):
"""
Get the most recently uploaded photos across all units.
Returns photos sorted by modification time (newest first).
"""
if not PHOTOS_BASE_DIR.exists():
return {"photos": []}
all_photos = []
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
# Scan all unit directories
for unit_dir in PHOTOS_BASE_DIR.iterdir():
if not unit_dir.is_dir():
continue
unit_id = unit_dir.name
# Get all photos in this unit's directory
for file_path in unit_dir.iterdir():
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
all_photos.append({
"unit_id": unit_id,
"filename": file_path.name,
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
"modified": file_path.stat().st_mtime,
"modified_iso": datetime.fromtimestamp(file_path.stat().st_mtime).isoformat()
})
# Sort by modification time (most recent first) and limit
all_photos.sort(key=lambda x: x["modified"], reverse=True)
recent_photos = all_photos[:limit]
return {
"photos": recent_photos,
"total": len(all_photos)
}
@router.get("/unit/{unit_id}/photo/{filename}") @router.get("/unit/{unit_id}/photo/{filename}")
def get_photo(unit_id: str, filename: str): def get_photo(unit_id: str, filename: str):
""" """

View File

@@ -5,11 +5,28 @@ import csv
import io import io
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, IgnoredUnit, Emitter from backend.models import RosterUnit, IgnoredUnit, Emitter, UnitHistory
router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
def record_history(db: Session, unit_id: str, change_type: str, field_name: str = None,
old_value: str = None, new_value: str = None, source: str = "manual", notes: str = None):
"""Helper function to record a change in unit history"""
history_entry = UnitHistory(
unit_id=unit_id,
change_type=change_type,
field_name=field_name,
old_value=old_value,
new_value=new_value,
changed_at=datetime.utcnow(),
source=source,
notes=notes
)
db.add(history_entry)
# Note: caller is responsible for db.commit()
def get_or_create_roster_unit(db: Session, unit_id: str): def get_or_create_roster_unit(db: Session, unit_id: str):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit: if not unit:
@@ -154,6 +171,11 @@ def edit_roster_unit(
except ValueError: except ValueError:
raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD")
# Track changes for history
old_note = unit.note
old_deployed = unit.deployed
old_retired = unit.retired
# Update all fields # Update all fields
unit.device_type = device_type unit.device_type = device_type
unit.unit_type = unit_type unit.unit_type = unit_type
@@ -176,6 +198,20 @@ def edit_roster_unit(
unit.phone_number = phone_number if phone_number else None unit.phone_number = phone_number if phone_number else None
unit.hardware_model = hardware_model if hardware_model else None unit.hardware_model = hardware_model if hardware_model else None
# Record history entries for changed fields
if old_note != note:
record_history(db, unit_id, "note_change", "note", old_note, note, "manual")
if old_deployed != deployed:
status_text = "deployed" if deployed else "benched"
old_status_text = "deployed" if old_deployed else "benched"
record_history(db, unit_id, "deployed_change", "deployed", old_status_text, status_text, "manual")
if old_retired != retired:
status_text = "retired" if retired else "active"
old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
db.commit() db.commit()
return {"message": "Unit updated", "id": unit_id, "device_type": device_type} return {"message": "Unit updated", "id": unit_id, "device_type": device_type}
@@ -183,8 +219,24 @@ def edit_roster_unit(
@router.post("/set-deployed/{unit_id}") @router.post("/set-deployed/{unit_id}")
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)): def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id) unit = get_or_create_roster_unit(db, unit_id)
old_deployed = unit.deployed
unit.deployed = deployed unit.deployed = deployed
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Record history entry for deployed status change
if old_deployed != deployed:
status_text = "deployed" if deployed else "benched"
old_status_text = "deployed" if old_deployed else "benched"
record_history(
db=db,
unit_id=unit_id,
change_type="deployed_change",
field_name="deployed",
old_value=old_status_text,
new_value=status_text,
source="manual"
)
db.commit() db.commit()
return {"message": "Updated", "id": unit_id, "deployed": deployed} return {"message": "Updated", "id": unit_id, "deployed": deployed}
@@ -192,8 +244,24 @@ def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends
@router.post("/set-retired/{unit_id}") @router.post("/set-retired/{unit_id}")
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)): def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id) unit = get_or_create_roster_unit(db, unit_id)
old_retired = unit.retired
unit.retired = retired unit.retired = retired
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Record history entry for retired status change
if old_retired != retired:
status_text = "retired" if retired else "active"
old_status_text = "retired" if old_retired else "active"
record_history(
db=db,
unit_id=unit_id,
change_type="retired_change",
field_name="retired",
old_value=old_status_text,
new_value=status_text,
source="manual"
)
db.commit() db.commit()
return {"message": "Updated", "id": unit_id, "retired": retired} return {"message": "Updated", "id": unit_id, "retired": retired}
@@ -235,8 +303,22 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
@router.post("/set-note/{unit_id}") @router.post("/set-note/{unit_id}")
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)): def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id) unit = get_or_create_roster_unit(db, unit_id)
old_note = unit.note
unit.note = note unit.note = note
unit.last_updated = datetime.utcnow() unit.last_updated = datetime.utcnow()
# Record history entry for note change
if old_note != note:
record_history(
db=db,
unit_id=unit_id,
change_type="note_change",
field_name="note",
old_value=old_note,
new_value=note,
source="manual"
)
db.commit() db.commit()
return {"message": "Updated", "id": unit_id, "note": note} return {"message": "Updated", "id": unit_id, "note": note}
@@ -402,3 +484,46 @@ def list_ignored_units(db: Session = Depends(get_db)):
for unit in ignored_units for unit in ignored_units
] ]
} }
@router.get("/history/{unit_id}")
def get_unit_history(unit_id: str, db: Session = Depends(get_db)):
"""
Get complete history timeline for a unit.
Returns all historical changes ordered by most recent first.
"""
history_entries = db.query(UnitHistory).filter(
UnitHistory.unit_id == unit_id
).order_by(UnitHistory.changed_at.desc()).all()
return {
"unit_id": unit_id,
"history": [
{
"id": entry.id,
"change_type": entry.change_type,
"field_name": entry.field_name,
"old_value": entry.old_value,
"new_value": entry.new_value,
"changed_at": entry.changed_at.isoformat(),
"source": entry.source,
"notes": entry.notes
}
for entry in history_entries
]
}
@router.delete("/history/{history_id}")
def delete_history_entry(history_id: int, db: Session = Depends(get_db)):
"""
Delete a specific history entry by ID.
Allows manual cleanup of old history entries.
"""
history_entry = db.query(UnitHistory).filter(UnitHistory.id == history_id).first()
if not history_entry:
raise HTTPException(status_code=404, detail="History entry not found")
db.delete(history_entry)
db.commit()
return {"message": "History entry deleted", "id": history_id}

View File

@@ -1,14 +1,17 @@
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse, FileResponse
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime, date from datetime import datetime, date
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import csv import csv
import io import io
import shutil
from pathlib import Path
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
from backend.services.database_backup import DatabaseBackupService
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -325,3 +328,144 @@ def update_preferences(
"status_pending_threshold_hours": prefs.status_pending_threshold_hours, "status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None "updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
} }
# Database Management Endpoints
backup_service = DatabaseBackupService()
@router.get("/database/stats")
def get_database_stats():
"""Get current database statistics"""
try:
stats = backup_service.get_database_stats()
return stats
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to get database stats: {str(e)}")
@router.post("/database/snapshot")
def create_database_snapshot(description: Optional[str] = None):
"""Create a full database snapshot"""
try:
snapshot = backup_service.create_snapshot(description=description)
return {
"message": "Snapshot created successfully",
"snapshot": snapshot
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Snapshot creation failed: {str(e)}")
@router.get("/database/snapshots")
def list_database_snapshots():
"""List all available database snapshots"""
try:
snapshots = backup_service.list_snapshots()
return {
"snapshots": snapshots,
"count": len(snapshots)
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to list snapshots: {str(e)}")
@router.get("/database/snapshot/{filename}")
def download_snapshot(filename: str):
"""Download a specific snapshot file"""
try:
snapshot_path = backup_service.download_snapshot(filename)
return FileResponse(
path=str(snapshot_path),
filename=filename,
media_type="application/x-sqlite3"
)
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Download failed: {str(e)}")
@router.delete("/database/snapshot/{filename}")
def delete_database_snapshot(filename: str):
"""Delete a specific snapshot"""
try:
backup_service.delete_snapshot(filename)
return {
"message": f"Snapshot {filename} deleted successfully",
"filename": filename
}
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Snapshot {filename} not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}")
class RestoreRequest(BaseModel):
"""Schema for restore request"""
filename: str
create_backup: bool = True
@router.post("/database/restore")
def restore_database(request: RestoreRequest, db: Session = Depends(get_db)):
"""Restore database from a snapshot"""
try:
# Close the database connection before restoring
db.close()
result = backup_service.restore_snapshot(
filename=request.filename,
create_backup_before_restore=request.create_backup
)
return result
except FileNotFoundError:
raise HTTPException(status_code=404, detail=f"Snapshot {request.filename} not found")
except Exception as e:
raise HTTPException(status_code=500, detail=f"Restore failed: {str(e)}")
@router.post("/database/upload-snapshot")
async def upload_snapshot(file: UploadFile = File(...)):
"""Upload a snapshot file to the backups directory"""
if not file.filename.endswith('.db'):
raise HTTPException(status_code=400, detail="File must be a .db file")
try:
# Save uploaded file to backups directory
backups_dir = Path("./data/backups")
backups_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S")
uploaded_filename = f"snapshot_uploaded_{timestamp}.db"
file_path = backups_dir / uploaded_filename
# Save file
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer)
# Create metadata
metadata = {
"filename": uploaded_filename,
"created_at": timestamp,
"created_at_iso": datetime.utcnow().isoformat(),
"description": f"Uploaded: {file.filename}",
"size_bytes": file_path.stat().st_size,
"size_mb": round(file_path.stat().st_size / (1024 * 1024), 2),
"type": "uploaded"
}
metadata_path = backups_dir / f"{uploaded_filename}.meta.json"
import json
with open(metadata_path, 'w') as f:
json.dump(metadata, f, indent=2)
return {
"message": "Snapshot uploaded successfully",
"snapshot": metadata
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")

View 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

View 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

View File

@@ -455,6 +455,54 @@
} }
} }
/* ===== MAP OVERLAP FIX ===== */
/* Prevent map and controls from overlapping UI elements on mobile */
@media (max-width: 767px) {
/* Constrain leaflet container to prevent overflow */
.leaflet-container {
max-width: 100%;
overflow: hidden;
}
/* Override Leaflet's default high z-index values */
/* Bottom nav is z-20, sidebar is z-40, so map must be below */
.leaflet-pane,
.leaflet-tile-pane,
.leaflet-overlay-pane,
.leaflet-shadow-pane,
.leaflet-marker-pane,
.leaflet-tooltip-pane,
.leaflet-popup-pane {
z-index: 1 !important;
}
/* Map controls should also be below navigation elements */
.leaflet-control-container,
.leaflet-top,
.leaflet-bottom,
.leaflet-left,
.leaflet-right {
z-index: 1 !important;
}
.leaflet-control {
z-index: 1 !important;
}
/* When sidebar is open, hide all Leaflet controls (zoom, attribution, etc) */
body.menu-open .leaflet-control-container {
opacity: 0;
pointer-events: none;
transition: opacity 0.3s ease-in-out;
}
/* Ensure map tiles are non-interactive when sidebar is open */
body.menu-open #fleet-map,
body.menu-open #unit-map {
pointer-events: none;
}
}
/* ===== PENDING SYNC BADGE ===== */ /* ===== PENDING SYNC BADGE ===== */
.pending-sync-badge { .pending-sync-badge {
display: inline-flex; display: inline-flex;

View File

@@ -20,12 +20,14 @@ function toggleMenu() {
backdrop.classList.remove('show'); backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open'); hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.classList.remove('menu-open');
} else { } else {
// Open menu // Open menu
sidebar.classList.add('open'); sidebar.classList.add('open');
backdrop.classList.add('show'); backdrop.classList.add('show');
hamburgerBtn?.classList.add('menu-open'); hamburgerBtn?.classList.add('menu-open');
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
document.body.classList.add('menu-open');
} }
} }
} }
@@ -41,6 +43,7 @@ function closeMenuFromBackdrop() {
backdrop.classList.remove('show'); backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open'); hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.classList.remove('menu-open');
} }
} }
@@ -56,6 +59,7 @@ function handleResize() {
backdrop.classList.remove('show'); backdrop.classList.remove('show');
hamburgerBtn?.classList.remove('menu-open'); hamburgerBtn?.classList.remove('menu-open');
document.body.style.overflow = ''; document.body.style.overflow = '';
document.body.classList.remove('menu-open');
} }
} }
} }

View 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"
}

View File

@@ -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
View 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

View File

@@ -5,3 +5,4 @@ pydantic==2.5.0
python-multipart==0.0.6 python-multipart==0.0.6
jinja2==3.1.2 jinja2==3.1.2
aiofiles==23.2.1 aiofiles==23.2.1
Pillow==10.1.0

149
scripts/clone_db_to_dev.py Executable file
View 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()

View File

@@ -69,14 +69,6 @@
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100"> <body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<!-- Hamburger Button (Mobile Only) -->
<button id="hamburgerBtn" class="hamburger-btn md:hidden" onclick="toggleMenu()" aria-label="Menu">
<div class="hamburger-icon">
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
<div class="hamburger-line"></div>
</div>
</button>
<!-- Offline Indicator --> <!-- Offline Indicator -->
<div id="offlineIndicator" class="offline-indicator"> <div id="offlineIndicator" class="offline-indicator">
@@ -172,6 +164,12 @@
<!-- Bottom Navigation (Mobile Only) --> <!-- Bottom Navigation (Mobile Only) -->
<nav class="bottom-nav"> <nav class="bottom-nav">
<div class="grid grid-cols-4 h-16"> <div class="grid grid-cols-4 h-16">
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
<span>Menu</span>
</button>
<button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'"> <button class="bottom-nav-btn" data-href="/" onclick="window.location.href='/'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
@@ -184,12 +182,6 @@
</svg> </svg>
<span>Roster</span> <span>Roster</span>
</button> </button>
<button class="bottom-nav-btn" data-href="/roster?action=add" onclick="window.location.href='/roster?action=add'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
<span>Add Unit</span>
</button>
<button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'"> <button class="bottom-nav-btn" data-href="/settings" onclick="window.location.href='/settings'">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
@@ -368,10 +360,10 @@
</script> </script>
<!-- Offline Database --> <!-- Offline Database -->
<script src="/static/offline-db.js?v=0.3.2"></script> <script src="/static/offline-db.js?v=0.4.0"></script>
<!-- Mobile JavaScript --> <!-- Mobile JavaScript -->
<script src="/static/mobile.js?v=0.3.2"></script> <script src="/static/mobile.js?v=0.4.0"></script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
</body> </body>

View File

@@ -30,16 +30,21 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Fleet Summary Card --> <!-- Fleet Summary Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-summary-card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-summary')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<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"> <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" <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"> 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> </path>
</svg> </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>
<div class="space-y-3 card-content" id="fleet-summary-content">
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Total Units</span> <span class="text-gray-600 dark:text-gray-400">Total Units</span>
<span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span> <span id="total-units" class="text-3xl md:text-2xl font-bold text-gray-900 dark:text-white">--</span>
@@ -92,65 +97,108 @@
</div> </div>
<!-- Recent Alerts Card --> <!-- Recent Alerts Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-alerts-card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-alerts')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<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"> <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" <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"> 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> </path>
</svg> </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>
<div id="alerts-list" class="space-y-3 card-content" id-content="recent-alerts-content">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p> <p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
</div> </div>
</div> </div>
<!-- Recent Photos Card --> <!-- Recently Called In Units Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="recent-callins-card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-callins')">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2> <h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Call-Ins</h2>
<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"> <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" <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"> d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z">
</path> </path>
</svg> </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"> </div>
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div class="card-content" id="recent-callins-content">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <div id="recent-callins-list" class="space-y-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"> <p class="text-sm text-gray-500 dark:text-gray-400">Loading recent call-ins...</p>
</path> </div>
</svg> <button id="show-all-callins" class="hidden mt-3 w-full text-center text-sm text-seismo-orange hover:text-seismo-burgundy font-medium">
<p class="text-sm">No recent photos</p> Show all recent call-ins
</button>
</div> </div>
</div> </div>
</div> </div>
<!-- Fleet Map --> <!-- Fleet Map -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="fleet-map-card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-map')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Map</h2>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-500 dark:text-gray-400">Deployed units</span> <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>
<div class="card-content" id="fleet-map-content">
<div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div> <div id="fleet-map" class="w-full h-64 md:h-96 rounded-lg"></div>
</div> </div>
</div>
<!-- Recent Photos Section -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6 mb-8" id="recent-photos-card">
<div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('recent-photos')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<div class="flex items-center gap-2">
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="recent-photos-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
<div class="card-content" id="recent-photos-content">
<div id="recentPhotosGallery" class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading recent photos...</p>
</div>
</div>
</div>
<!-- Fleet Status Section with Tabs --> <!-- Fleet Status Section with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6"> <div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6" id="fleet-status-card">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4 cursor-pointer md:cursor-default" onclick="toggleCard('fleet-status')">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center"> <div class="flex items-center gap-2">
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center" onclick="event.stopPropagation()">
Full Roster Full Roster
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg> </svg>
</a> </a>
<svg class="w-5 h-5 text-gray-500 transition-transform md:hidden chevron" id="fleet-status-chevron" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div> </div>
<div class="card-content" id="fleet-status-content">
<!-- Tab Bar --> <!-- Tab Bar -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4"> <div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button <button
@@ -177,6 +225,7 @@
hx-swap="innerHTML"> hx-swap="innerHTML">
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p> <p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
</div> </div>
</div>
</div> </div>
</div> </div>
@@ -195,10 +244,82 @@
color: #b84a12 !important; /* seismo orange */ color: #b84a12 !important; /* seismo orange */
border-bottom: 2px solid #b84a12 !important; border-bottom: 2px solid #b84a12 !important;
} }
/* Collapsible cards (mobile only) */
@media (max-width: 767px) {
.card-content.collapsed {
display: none;
}
.chevron.collapsed {
transform: rotate(-90deg);
}
}
</style> </style>
<script> <script>
// Toggle card collapse/expand (mobile only)
function toggleCard(cardName) {
// Only work on mobile
if (window.innerWidth >= 768) return;
const content = document.getElementById(`${cardName}-content`);
const chevron = document.getElementById(`${cardName}-chevron`);
if (!content || !chevron) return;
// Toggle collapsed state
const isCollapsed = content.classList.contains('collapsed');
if (isCollapsed) {
content.classList.remove('collapsed');
chevron.classList.remove('collapsed');
// If expanding the fleet map, invalidate size after animation
if (cardName === 'fleet-map' && window.fleetMap) {
setTimeout(() => {
window.fleetMap.invalidateSize();
}, 300);
}
} else {
content.classList.add('collapsed');
chevron.classList.add('collapsed');
}
// Save state to localStorage
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
cardStates[cardName] = !isCollapsed;
localStorage.setItem('dashboardCardStates', JSON.stringify(cardStates));
}
// Restore card states from localStorage on page load
function restoreCardStates() {
const cardStates = JSON.parse(localStorage.getItem('dashboardCardStates') || '{}');
const cardNames = ['fleet-summary', 'recent-alerts', 'recent-callins', 'fleet-map', 'fleet-status'];
cardNames.forEach(cardName => {
const content = document.getElementById(`${cardName}-content`);
const chevron = document.getElementById(`${cardName}-chevron`);
if (!content || !chevron) return;
// Default to expanded (true) if no saved state
const isCollapsed = cardStates[cardName] === false;
if (isCollapsed) {
content.classList.add('collapsed');
chevron.classList.add('collapsed');
}
});
}
// Restore states when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', restoreCardStates);
} else {
restoreCardStates();
}
function updateDashboard(event) { function updateDashboard(event) {
try { try {
const data = JSON.parse(event.detail.xhr.response); const data = JSON.parse(event.detail.xhr.response);
@@ -276,15 +397,24 @@ let fleetMap = null;
let fleetMarkers = []; let fleetMarkers = [];
let fleetMapInitialized = false; let fleetMapInitialized = false;
// Make fleetMap accessible globally for toggleCard function
window.fleetMap = null;
function initFleetMap() { function initFleetMap() {
// Initialize the map centered on the US (can adjust based on your deployment area) // Initialize the map centered on the US (can adjust based on your deployment area)
fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4); fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4);
window.fleetMap = fleetMap;
// Add OpenStreetMap tiles // Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors', attribution: '© OpenStreetMap contributors',
maxZoom: 18 maxZoom: 18
}).addTo(fleetMap); }).addTo(fleetMap);
// Force map to recalculate size after a brief delay to ensure container is fully rendered
setTimeout(() => {
fleetMap.invalidateSize();
}, 100);
} }
function updateFleetMap(data) { function updateFleetMap(data) {
@@ -336,9 +466,11 @@ function updateFleetMap(data) {
} }
}); });
// Fit map to show all markers only on first load // Fit map to show all markers
if (bounds.length > 0 && !fleetMapInitialized) { if (bounds.length > 0) {
fleetMap.fitBounds(bounds, { padding: [50, 50] }); // Use different padding for mobile vs desktop
const padding = window.innerWidth < 768 ? [20, 20] : [50, 50];
fleetMap.fitBounds(bounds, { padding: padding });
fleetMapInitialized = true; fleetMapInitialized = true;
} }
} }
@@ -359,6 +491,130 @@ function parseLocation(location) {
// TODO: Add geocoding support for address strings // TODO: Add geocoding support for address strings
return null; return null;
} }
// Load and display recent photos
async function loadRecentPhotos() {
try {
const response = await fetch('/api/recent-photos?limit=12');
if (!response.ok) {
throw new Error('Failed to load recent photos');
}
const data = await response.json();
const gallery = document.getElementById('recentPhotosGallery');
if (data.photos && data.photos.length > 0) {
gallery.innerHTML = '';
data.photos.forEach(photo => {
const photoDiv = document.createElement('div');
photoDiv.className = 'relative group';
photoDiv.innerHTML = `
<a href="/unit/${photo.unit_id}" class="block">
<img src="${photo.path}" alt="${photo.unit_id}"
class="w-full h-32 object-cover rounded-lg shadow hover:shadow-lg transition-shadow">
<div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/70 to-transparent p-2 rounded-b-lg">
<p class="text-white text-xs font-semibold">${photo.unit_id}</p>
</div>
</a>
`;
gallery.appendChild(photoDiv);
});
} else {
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos uploaded yet. Upload photos from unit detail pages.</p>';
}
} catch (error) {
console.error('Error loading recent photos:', error);
document.getElementById('recentPhotosGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load recent photos</p>';
}
}
// Load recent photos on page load and refresh every 30 seconds
loadRecentPhotos();
setInterval(loadRecentPhotos, 30000);
// Load and display recent call-ins
let showingAllCallins = false;
const DEFAULT_CALLINS_DISPLAY = 5;
async function loadRecentCallins() {
try {
const response = await fetch('/api/recent-callins?hours=6');
if (!response.ok) {
throw new Error('Failed to load recent call-ins');
}
const data = await response.json();
const callinsList = document.getElementById('recent-callins-list');
const showAllButton = document.getElementById('show-all-callins');
if (data.call_ins && data.call_ins.length > 0) {
// Determine how many to show
const displayCount = showingAllCallins ? data.call_ins.length : Math.min(DEFAULT_CALLINS_DISPLAY, data.call_ins.length);
const callinsToDisplay = data.call_ins.slice(0, displayCount);
// Build HTML for call-ins list
let html = '';
callinsToDisplay.forEach(callin => {
// Status color
const statusColor = callin.status === 'OK' ? 'green' : callin.status === 'Pending' ? 'yellow' : 'red';
const statusClass = callin.status === 'OK' ? 'bg-green-500' : callin.status === 'Pending' ? 'bg-yellow-500' : 'bg-red-500';
// Build location/note line
let subtitle = '';
if (callin.location) {
subtitle = callin.location;
} else if (callin.note) {
subtitle = callin.note;
}
html += `
<div class="flex items-center justify-between py-2 border-b border-gray-200 dark:border-gray-700 last:border-0">
<div class="flex items-center space-x-3">
<span class="w-2 h-2 rounded-full ${statusClass}"></span>
<div>
<a href="/unit/${callin.unit_id}" class="font-medium text-gray-900 dark:text-white hover:text-seismo-orange">
${callin.unit_id}
</a>
${subtitle ? `<p class="text-xs text-gray-500 dark:text-gray-400">${subtitle}</p>` : ''}
</div>
</div>
<span class="text-sm text-gray-600 dark:text-gray-400">${callin.time_ago}</span>
</div>`;
});
callinsList.innerHTML = html;
// Show/hide the "Show all" button
if (data.call_ins.length > DEFAULT_CALLINS_DISPLAY) {
showAllButton.classList.remove('hidden');
showAllButton.textContent = showingAllCallins
? `Show fewer (${DEFAULT_CALLINS_DISPLAY})`
: `Show all (${data.call_ins.length})`;
} else {
showAllButton.classList.add('hidden');
}
} else {
callinsList.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No units have called in within the past 6 hours</p>';
showAllButton.classList.add('hidden');
}
} catch (error) {
console.error('Error loading recent call-ins:', error);
document.getElementById('recent-callins-list').innerHTML = '<p class="text-sm text-red-500">Failed to load recent call-ins</p>';
}
}
// Toggle show all/show fewer
document.addEventListener('DOMContentLoaded', function() {
const showAllButton = document.getElementById('show-all-callins');
showAllButton.addEventListener('click', function() {
showingAllCallins = !showingAllCallins;
loadRecentCallins();
});
});
// Load recent call-ins on page load and refresh every 30 seconds
loadRecentCallins();
setInterval(loadRecentCallins, 30000);
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -6,11 +6,11 @@
<!-- Status Indicator --> <!-- Status Indicator -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
{% if unit.status == 'OK' %} {% if unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span> <span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %} {% elif unit.status == 'Pending' %}
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span> <span class="w-4 h-4 rounded-full bg-yellow-500" title="Pending"></span>
{% else %} {% else %}
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span> <span class="w-4 h-4 rounded-full bg-red-500" title="Missing"></span>
{% endif %} {% endif %}
</div> </div>

View File

@@ -5,7 +5,7 @@
<div class="flex items-center space-x-3 flex-1"> <div class="flex items-center space-x-3 flex-1">
<!-- Status Indicator (grayed out for benched) --> <!-- Status Indicator (grayed out for benched) -->
<div class="flex-shrink-0"> <div class="flex-shrink-0">
<span class="w-3 h-3 rounded-full bg-gray-400" title="Benched"></span> <span class="w-4 h-4 rounded-full bg-gray-400" title="Benched"></span>
</div> </div>
<!-- Unit Info --> <!-- Unit Info -->

View File

@@ -229,32 +229,14 @@
{% endif %} {% endif %}
</div> </div>
<!-- Location (Tap to Navigate) --> <!-- Location -->
{% if unit.address %} {% if unit.address %}
<div class="text-sm mb-1"> <div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.address | urlencode }}" 📍 {{ unit.address }}
target="_blank"
onclick="event.stopPropagation()"
class="flex items-center gap-1 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="underline">{{ unit.address }}</span>
</a>
</div> </div>
{% elif unit.coordinates %} {% elif unit.coordinates %}
<div class="text-sm mb-1"> <div class="text-sm text-gray-600 dark:text-gray-400 mb-1">
<a href="https://www.google.com/maps/search/?api=1&query={{ unit.coordinates | urlencode }}" 📍 {{ unit.coordinates }}
target="_blank"
onclick="event.stopPropagation()"
class="flex items-center gap-1 text-seismo-orange hover:text-orange-600 dark:text-seismo-orange dark:hover:text-orange-400">
<svg class="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="font-mono underline">{{ unit.coordinates }}</span>
</a>
</div> </div>
{% endif %} {% endif %}

View File

@@ -20,7 +20,13 @@
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg> </svg>
Data Management Roster Management
</button>
<button class="settings-tab" data-tab="database" onclick="showTab('database')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path>
</svg>
Database
</button> </button>
<button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')"> <button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -214,30 +220,24 @@
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p> <p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
</div> </div>
</div> </div>
</div>
</div>
<!-- Advanced Tab --> <!-- CSV Import - Replace Mode -->
<div id="advanced-tab" class="tab-content hidden"> <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="space-y-6"> <div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4 mb-4">
<!-- Warning Banner -->
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4">
<div class="flex"> <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"> <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> <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>
<div> <div>
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p> <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">These settings can affect system behavior. Change with caution.</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> </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">
<h2 class="text-xl font-semibold text-yellow-800 dark:text-yellow-300 mb-2">CSV Import - Replace Mode</h2> <h2 class="text-xl font-semibold text-yellow-800 dark:text-yellow-300 mb-2">CSV Import - Replace Mode</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4"> <p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
This will DELETE all existing roster units before importing. Use this to completely replace your roster with a CSV file. All existing roster data will be deleted first.
</p> </p>
<form id="importReplaceForm" class="space-y-4"> <form id="importReplaceForm" class="space-y-4">
@@ -257,6 +257,116 @@
</button> </button>
</form> </form>
</div> </div>
</div>
</div>
<!-- Database Tab -->
<div id="database-tab" class="tab-content hidden">
<div class="space-y-6">
<!-- Database Statistics -->
<div class="border-2 border-blue-300 dark:border-blue-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<h3 class="font-semibold text-blue-600 dark:text-blue-400 text-lg mb-3">Database Statistics</h3>
<div id="dbStatsLoading" class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
</div>
<div id="dbStatsContent" class="hidden">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500 dark:text-gray-400">Database Size</p>
<p id="dbSize" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Total Rows</p>
<p id="dbRows" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Last Modified</p>
<p id="dbModified" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
</div>
<div>
<p class="text-gray-500 dark:text-gray-400">Snapshots</p>
<p id="dbSnapshotCount" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
</div>
</div>
</div>
<button onclick="loadDatabaseStats()" class="mt-4 px-4 py-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors text-sm">
Refresh Stats
</button>
</div>
<!-- Create Snapshot -->
<div class="border border-green-200 dark:border-green-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="font-semibold text-green-600 dark:text-green-400">Create Database Snapshot</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Create a full backup of the current database state
</p>
</div>
<button onclick="createSnapshot()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors whitespace-nowrap">
Create Snapshot
</button>
</div>
</div>
<!-- Snapshots List -->
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex justify-between items-center mb-4">
<h3 class="font-semibold text-gray-900 dark:text-white">Available Snapshots</h3>
<button onclick="loadSnapshots()" class="px-3 py-1 text-sm text-seismo-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded transition-colors">
Refresh
</button>
</div>
<div id="snapshotsLoading" class="text-center py-4">
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
</div>
<div id="snapshotsList" class="hidden space-y-2">
<!-- Snapshots will be inserted here -->
</div>
<div id="snapshotsEmpty" class="hidden text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
</svg>
<p class="mt-2">No snapshots found</p>
<p class="text-sm">Create your first snapshot above</p>
</div>
</div>
<!-- Upload Snapshot -->
<div class="border border-purple-200 dark:border-purple-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<h3 class="font-semibold text-purple-600 dark:text-purple-400 mb-2">Upload Snapshot</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Upload a database snapshot file from another server
</p>
<form id="uploadSnapshotForm" class="space-y-3">
<input type="file" accept=".db" id="snapshotFileInput" class="block w-full text-sm text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer bg-gray-50 dark:bg-slate-700">
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors">
Upload Snapshot
</button>
</form>
<div id="uploadResult" class="hidden mt-3"></div>
</div>
</div>
</div>
<!-- Advanced Tab -->
<div id="advanced-tab" class="tab-content hidden">
<div class="space-y-6">
<!-- Warning Banner -->
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4">
<div class="flex">
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div>
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p>
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">These settings can affect system behavior. Change with caution.</p>
</div>
</div>
</div>
<!-- Status Thresholds --> <!-- Status Thresholds -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
@@ -1004,5 +1114,263 @@ async function confirmClearIgnored() {
alert('❌ Error: ' + error.message); alert('❌ Error: ' + error.message);
} }
} }
// ========== DATABASE MANAGEMENT ==========
async function loadDatabaseStats() {
const loading = document.getElementById('dbStatsLoading');
const content = document.getElementById('dbStatsContent');
try {
loading.classList.remove('hidden');
content.classList.add('hidden');
const response = await fetch('/api/settings/database/stats');
const stats = await response.json();
// Update stats display
document.getElementById('dbSize').textContent = stats.size_mb + ' MB';
document.getElementById('dbRows').textContent = stats.total_rows.toLocaleString();
const lastMod = new Date(stats.last_modified);
document.getElementById('dbModified').textContent = lastMod.toLocaleDateString();
// Load snapshot count
const snapshotsResp = await fetch('/api/settings/database/snapshots');
const snapshotsData = await snapshotsResp.json();
document.getElementById('dbSnapshotCount').textContent = snapshotsData.count;
loading.classList.add('hidden');
content.classList.remove('hidden');
} catch (error) {
loading.classList.add('hidden');
alert('Error loading database stats: ' + error.message);
}
}
async function createSnapshot() {
const description = prompt('Enter a description for this snapshot (optional):');
try {
const response = await fetch('/api/settings/database/snapshot', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description: description || null })
});
const result = await response.json();
if (response.ok) {
alert(`✅ Snapshot created successfully!\n\nFilename: ${result.snapshot.filename}\nSize: ${result.snapshot.size_mb} MB`);
loadSnapshots();
loadDatabaseStats();
} else {
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function loadSnapshots() {
const loading = document.getElementById('snapshotsLoading');
const list = document.getElementById('snapshotsList');
const empty = document.getElementById('snapshotsEmpty');
try {
loading.classList.remove('hidden');
list.classList.add('hidden');
empty.classList.add('hidden');
const response = await fetch('/api/settings/database/snapshots');
const data = await response.json();
if (data.snapshots.length === 0) {
loading.classList.add('hidden');
empty.classList.remove('hidden');
return;
}
list.innerHTML = data.snapshots.map(snapshot => createSnapshotCard(snapshot)).join('');
loading.classList.add('hidden');
list.classList.remove('hidden');
} catch (error) {
loading.classList.add('hidden');
alert('Error loading snapshots: ' + error.message);
}
}
function createSnapshotCard(snapshot) {
const createdDate = new Date(snapshot.created_at_iso);
const dateStr = createdDate.toLocaleString();
return `
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
<div class="flex justify-between items-start">
<div class="flex-1">
<div class="flex items-center gap-2">
<h4 class="font-medium text-gray-900 dark:text-white">${snapshot.filename}</h4>
<span class="text-xs px-2 py-1 rounded ${snapshot.type === 'manual' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'}">
${snapshot.type}
</span>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">${snapshot.description}</p>
<div class="flex gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
<span>📅 ${dateStr}</span>
<span>💾 ${snapshot.size_mb} MB</span>
</div>
</div>
<div class="flex gap-2 ml-4">
<button onclick="downloadSnapshot('${snapshot.filename}')"
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-blue-600 dark:text-blue-400"
title="Download">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
</svg>
</button>
<button onclick="restoreSnapshot('${snapshot.filename}')"
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-green-600 dark:text-green-400"
title="Restore">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="deleteSnapshot('${snapshot.filename}')"
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-red-600 dark:text-red-400"
title="Delete">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
</div>
</div>
`;
}
function downloadSnapshot(filename) {
window.location.href = `/api/settings/database/snapshot/${filename}`;
}
async function restoreSnapshot(filename) {
const confirmMsg = `⚠️ RESTORE DATABASE WARNING ⚠️
This will REPLACE the current database with snapshot:
${filename}
A backup of the current database will be created automatically before restoring.
THIS ACTION WILL RESTART THE APPLICATION!
Continue?`;
if (!confirm(confirmMsg)) {
return;
}
try {
const response = await fetch('/api/settings/database/restore', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
filename: filename,
create_backup: true
})
});
const result = await response.json();
if (response.ok) {
alert(`✅ Database restored successfully!\n\nRestored from: ${result.restored_from}\nBackup created: ${result.backup_created}\n\nThe page will now reload.`);
location.reload();
} else {
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function deleteSnapshot(filename) {
if (!confirm(`Delete snapshot ${filename}?\n\nThis cannot be undone.`)) {
return;
}
try {
const response = await fetch(`/api/settings/database/snapshot/${filename}`, {
method: 'DELETE'
});
const result = await response.json();
if (response.ok) {
alert(`✅ Snapshot deleted: ${filename}`);
loadSnapshots();
loadDatabaseStats();
} else {
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Upload snapshot form handler
document.getElementById('uploadSnapshotForm').addEventListener('submit', async function(e) {
e.preventDefault();
const fileInput = document.getElementById('snapshotFileInput');
const resultDiv = document.getElementById('uploadResult');
if (!fileInput.files[0]) {
alert('Please select a file');
return;
}
const formData = new FormData();
formData.append('file', fileInput.files[0]);
try {
const response = await fetch('/api/settings/database/upload-snapshot', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
resultDiv.className = 'mt-3 p-3 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
resultDiv.innerHTML = `✅ Uploaded: ${result.snapshot.filename} (${result.snapshot.size_mb} MB)`;
resultDiv.classList.remove('hidden');
fileInput.value = '';
loadSnapshots();
loadDatabaseStats();
setTimeout(() => {
resultDiv.classList.add('hidden');
}, 5000);
} else {
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `❌ Error: ${result.detail || 'Unknown error'}`;
resultDiv.classList.remove('hidden');
}
} catch (error) {
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `❌ Error: ${error.message}`;
resultDiv.classList.remove('hidden');
}
});
// Load database stats and snapshots when database tab is shown
const originalShowTab = showTab;
showTab = function(tabName) {
originalShowTab(tabName);
if (tabName === 'database') {
loadDatabaseStats();
loadSnapshots();
}
};
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -177,6 +177,46 @@
<label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label> <label class="text-sm font-medium text-gray-500 dark:text-gray-400">Notes</label>
<p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p> <p id="viewNote" class="mt-1 text-gray-900 dark:text-white whitespace-pre-wrap">--</p>
</div> </div>
<!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
<div id="historyTimeline" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading history...</p>
</div>
</div>
<!-- Photos -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<div class="flex justify-between items-start mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Photos</h3>
<div class="flex flex-col sm:flex-row gap-2">
<!-- Take Photo Button (Camera) -->
<label for="photoCameraUpload" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
<span class="hidden sm:inline">Take Photo</span>
<span class="sm:hidden">Camera</span>
</label>
<!-- Choose from Library Button -->
<label for="photoLibraryUpload" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors cursor-pointer flex items-center gap-2 text-sm sm:text-base">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<span class="hidden sm:inline">Choose Photo</span>
<span class="sm:hidden">Library</span>
</label>
<input type="file" id="photoCameraUpload" accept="image/*" capture="environment" class="hidden" onchange="uploadPhoto(this.files[0])">
<input type="file" id="photoLibraryUpload" accept="image/*" class="hidden" onchange="uploadPhoto(this.files[0])">
</div>
</div>
<div id="photoGallery" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">Loading photos...</p>
</div>
<div id="uploadStatus" class="hidden mt-4 p-4 rounded-lg"></div>
</div>
</div> </div>
</div> </div>
@@ -632,7 +672,228 @@ function parseLocation(location) {
return null; return null;
} }
// Load and display photos
async function loadPhotos() {
try {
const response = await fetch(`/api/unit/${unitId}/photos`);
if (!response.ok) {
throw new Error('Failed to load photos');
}
const data = await response.json();
const gallery = document.getElementById('photoGallery');
if (data.photos && data.photos.length > 0) {
gallery.innerHTML = '';
data.photo_urls.forEach((url, index) => {
const photoDiv = document.createElement('div');
photoDiv.className = 'relative group';
photoDiv.innerHTML = `
<img src="${url}" alt="Unit photo ${index + 1}"
class="w-full h-48 object-cover rounded-lg shadow cursor-pointer hover:shadow-lg transition-shadow"
onclick="window.open('${url}', '_blank')">
${index === 0 ? '<span class="absolute top-2 left-2 bg-seismo-orange text-white text-xs px-2 py-1 rounded">Primary</span>' : ''}
`;
gallery.appendChild(photoDiv);
});
} else {
gallery.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400 col-span-full">No photos yet. Add a photo to get started.</p>';
}
} catch (error) {
console.error('Error loading photos:', error);
document.getElementById('photoGallery').innerHTML = '<p class="text-sm text-red-500 col-span-full">Failed to load photos</p>';
}
}
// Upload photo with EXIF metadata extraction
async function uploadPhoto(file) {
if (!file) return;
const statusDiv = document.getElementById('uploadStatus');
statusDiv.className = 'mt-4 p-4 rounded-lg bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200';
statusDiv.textContent = 'Uploading photo and extracting metadata...';
statusDiv.classList.remove('hidden');
const formData = new FormData();
formData.append('photo', file);
try {
const response = await fetch(`/api/unit/${unitId}/upload-photo`, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error('Upload failed');
}
const result = await response.json();
// Show success message with metadata info
let message = 'Photo uploaded successfully!';
if (result.metadata && result.metadata.coordinates) {
message += ` GPS location detected: ${result.metadata.coordinates}`;
if (result.coordinates_updated) {
message += ' (Unit coordinates updated automatically)';
}
} else {
message += ' No GPS data found in photo.';
}
statusDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
statusDiv.textContent = message;
// Reload photos and unit data
await loadPhotos();
if (result.coordinates_updated) {
await loadUnitData();
}
// Hide status after 5 seconds
setTimeout(() => {
statusDiv.classList.add('hidden');
}, 5000);
// Reset both file inputs
document.getElementById('photoCameraUpload').value = '';
document.getElementById('photoLibraryUpload').value = '';
} catch (error) {
console.error('Error uploading photo:', error);
statusDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
statusDiv.textContent = `Error uploading photo: ${error.message}`;
// Hide error after 5 seconds
setTimeout(() => {
statusDiv.classList.add('hidden');
}, 5000);
}
}
// Load and display unit history timeline
async function loadUnitHistory() {
try {
const response = await fetch(`/api/roster/history/${unitId}`);
if (!response.ok) {
throw new Error('Failed to load history');
}
const data = await response.json();
const timeline = document.getElementById('historyTimeline');
if (data.history && data.history.length > 0) {
timeline.innerHTML = '';
data.history.forEach(entry => {
const timelineEntry = createTimelineEntry(entry);
timeline.appendChild(timelineEntry);
});
} else {
timeline.innerHTML = '<p class="text-sm text-gray-500 dark:text-gray-400">No history yet. Changes will appear here.</p>';
}
} catch (error) {
console.error('Error loading history:', error);
document.getElementById('historyTimeline').innerHTML = '<p class="text-sm text-red-500">Failed to load history</p>';
}
}
// Create a timeline entry element
function createTimelineEntry(entry) {
const div = document.createElement('div');
div.className = 'flex gap-3 p-3 rounded-lg bg-gray-50 dark:bg-slate-700/50';
// Icon based on change type
const icons = {
'note_change': `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>`,
'deployed_change': `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`,
'retired_change': `<svg class="w-5 h-5 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`
};
const icon = icons[entry.change_type] || `<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>`;
// Format change description
let description = '';
if (entry.change_type === 'note_change') {
description = `<strong>Note changed</strong>`;
if (entry.old_value) {
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">From: "${entry.old_value}"</span>`;
}
if (entry.new_value) {
description += `<br><span class="text-xs text-gray-600 dark:text-gray-300">To: "${entry.new_value}"</span>`;
}
} else if (entry.change_type === 'deployed_change') {
description = `<strong>Status changed to ${entry.new_value}</strong>`;
} else if (entry.change_type === 'retired_change') {
description = `<strong>Marked as ${entry.new_value}</strong>`;
} else {
description = `<strong>${entry.field_name} changed</strong>`;
if (entry.old_value && entry.new_value) {
description += `<br><span class="text-xs text-gray-500 dark:text-gray-400">${entry.old_value}${entry.new_value}</span>`;
}
}
// Format timestamp
const timestamp = new Date(entry.changed_at).toLocaleString();
div.innerHTML = `
<div class="flex-shrink-0">
${icon}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm text-gray-900 dark:text-white">
${description}
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
${timestamp}
${entry.source !== 'manual' ? `<span class="ml-2 px-2 py-0.5 bg-gray-200 dark:bg-gray-600 rounded text-xs">${entry.source}</span>` : ''}
</div>
</div>
<div class="flex-shrink-0">
<button onclick="deleteHistoryEntry(${entry.id})" class="text-gray-400 hover:text-red-500 transition-colors" title="Delete this history entry">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
</button>
</div>
`;
return div;
}
// Delete a history entry
async function deleteHistoryEntry(historyId) {
if (!confirm('Are you sure you want to delete this history entry?')) {
return;
}
try {
const response = await fetch(`/api/roster/history/${historyId}`, {
method: 'DELETE'
});
if (response.ok) {
// Reload history
await loadUnitHistory();
} else {
const result = await response.json();
alert(`Error: ${result.detail || 'Unknown error'}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Load data when page loads // Load data when page loads
loadUnitData(); loadUnitData().then(() => {
loadPhotos();
loadUnitHistory();
});
</script> </script>
{% endblock %} {% endblock %}