Update main to 0.3.0

Update main to 0.3.0
This commit is contained in:
serversdwn
2025-12-09 01:21:42 -05:00
committed by GitHub
13 changed files with 1310 additions and 369 deletions

View File

@@ -5,6 +5,65 @@ 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.3.0] - 2025-12-09
### Added
- **Series 4 (Micromate) Support**: New `/api/series4/heartbeat` endpoint for receiving telemetry from Series 4 Micromate units
- Auto-detection of Series 4 units via UM##### ID pattern
- Stores project hints from emitter payload in unit notes
- Automatic unit type classification across both Series 3 and Series 4 endpoints
- **Development Environment Labels**: Visual indicators to distinguish dev from production deployments
- Yellow "DEV" badge in sidebar navigation
- "[DEV]" prefix in browser title
- Yellow banner on dashboard when running in development mode
- Environment variable support in docker-compose.yml (ENVIRONMENT=production|development)
- **Quality of Life Improvements**:
- Human-readable relative timestamps (e.g., "2h 15m ago", "3d ago") with full date in tooltips
- "Last Updated" timestamp indicator on dashboard
- Status icons for colorblind accessibility (checkmark for OK, clock for Pending, X for Missing)
- Breadcrumb navigation on unit detail pages
- Copy-to-clipboard buttons for unit IDs
- Search/filter functionality for fleet roster table
- Improved empty state messages with icons
- **Timezone Support**: Comprehensive timezone handling across the application
- Timezone selector in Settings (defaults to America/New_York EST)
- Human-readable timestamp format (e.g., "9/10/2020 8:00 AM EST")
- Timezone-aware display for all timestamps site-wide
- Settings stored in localStorage for immediate effect
- **Settings Page Redesign**: Complete overhaul with tabbed interface and persistent preferences
- **General Tab**: Display preferences (timezone, theme, auto-refresh interval)
- **Data Management Tab**: Safe operations (CSV export, merge import, roster table)
- **Advanced Tab**: Power user settings (replace mode import, calibration defaults, status thresholds)
- **Danger Zone Tab**: Destructive operations isolated with enhanced warnings
- Backend preferences storage via new UserPreferences model
- Tab state persistence in localStorage
- Smooth animations and consistent styling with existing pages
- **User Preferences API**: New backend endpoints for persistent settings storage
- `GET /api/settings/preferences` - Retrieve all user preferences
- `PUT /api/settings/preferences` - Update preferences (supports partial updates)
- Database-backed storage for cross-device preference sync
- Migration script: `backend/migrate_add_user_preferences.py`
### Changed
- Timestamps now display in user-selected timezone with human-readable format throughout the application
- Settings page reorganized from 644-line flat layout to clean 4-tab interface
- CSV Replace Mode moved from Data Management to Advanced tab with additional warnings
- Import operations separated: safe merge in Data Management tab, destructive replace in Advanced tab
- Page title changed from "Roster Manager" to "Settings" for better clarity
- All preferences now persist to backend database instead of relying solely on localStorage
### Fixed
- Unit type classification now consistent across Series 3 and Series 4 heartbeat endpoints
- Auto-correction of misclassified unit types when they report to wrong endpoint
### Technical Details
- New `detect_unit_type()` helper function for pattern-based unit classification
- UserPreferences model with single-row table pattern (id=1) for global settings
- Series 4 units identified by UM prefix followed by digits (e.g., UM11719)
- JavaScript Intl API used for client-side timezone conversion
- Pydantic schema for partial preference updates (PreferencesUpdate model)
- Environment context injection via custom FastAPI template response wrapper
## [0.2.1] - 2025-12-03 ## [0.2.1] - 2025-12-03
### Added ### Added
@@ -92,6 +151,7 @@ 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.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1 [0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0 [0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0
[0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1 [0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1

View File

@@ -1,4 +1,4 @@
# Seismo Fleet Manager v0.2.1 # Seismo Fleet Manager v0.3.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.
@@ -100,15 +100,27 @@ cp /tmp/sfm_test.db data/seismo_fleet.db
The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately. The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately.
## Upgrading from v0.1.x ## Upgrading from Previous Versions
Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file before starting the app: ### From v0.2.x to v0.3.0
Version 0.3.0 introduces user preferences storage. Run the migration once per database file:
```bash
python backend/migrate_add_user_preferences.py
```
This creates the `user_preferences` table for persistent settings storage (timezone, theme, auto-refresh interval, calibration defaults, status thresholds).
### From v0.1.x to v0.2.x or later
Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file:
```bash ```bash
python backend/migrate_add_device_types.py python backend/migrate_add_device_types.py
``` ```
The script is idempotent—if the new columns already exist it simply exits. Both migration scripts are idempotent—if the columns/tables already exist, they simply exit.
## API Endpoints ## API Endpoints
@@ -156,6 +168,8 @@ The script is idempotent—if the new columns already exist it simply exits.
- **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables - **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables
- **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid - **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid
- **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction - **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction
- **GET** `/api/settings/preferences` - Get user preferences (timezone, theme, calibration defaults, etc.)
- **PUT** `/api/settings/preferences` - Update user preferences (supports partial updates)
- **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables - **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables
- **POST** `/api/settings/clear-roster` - Delete only roster entries - **POST** `/api/settings/clear-roster` - Delete only roster entries
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters - **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
@@ -183,6 +197,7 @@ See [sample_roster.csv](sample_roster.csv) for a minimal working example.
### Emitter Reporting ### Emitter Reporting
- **POST** `/emitters/report` - Submit status report from a seismograph unit - **POST** `/emitters/report` - Submit status report from a seismograph unit
- **POST** `/api/series3/heartbeat` - Series 3 multi-unit telemetry payload - **POST** `/api/series3/heartbeat` - Series 3 multi-unit telemetry payload
- **POST** `/api/series4/heartbeat` - Series 4 (Micromate) multi-unit telemetry payload
- **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy) - **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy)
### Photo Management ### Photo Management
@@ -314,6 +329,22 @@ print(response.json())
| reason | string | Optional context for ignoring | | reason | string | Optional context for ignoring |
| ignored_at | datetime | When the ignore action occurred | | ignored_at | datetime | When the ignore action occurred |
### UserPreferences Table (Settings Storage)
| Field | Type | Description |
|-------|------|-------------|
| id | integer | Always 1 (single-row table) |
| timezone | string | Display timezone (default: America/New_York) |
| theme | string | UI theme: auto, light, or dark |
| auto_refresh_interval | integer | Dashboard refresh interval in seconds |
| date_format | string | Date format preference |
| table_rows_per_page | integer | Default pagination size |
| calibration_interval_days | integer | Default days between calibrations |
| calibration_warning_days | integer | Warning threshold before calibration due |
| status_ok_threshold_hours | integer | Hours for OK status threshold |
| status_pending_threshold_hours | integer | Hours for Pending status threshold |
| updated_at | datetime | Last preference update timestamp |
## Project Structure ## Project Structure
``` ```
@@ -321,8 +352,8 @@ seismo-fleet-manager/
├── backend/ ├── backend/
│ ├── main.py # FastAPI app entry point │ ├── main.py # FastAPI app entry point
│ ├── database.py # SQLAlchemy database configuration │ ├── database.py # SQLAlchemy database configuration
│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit) │ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit, UserPreferences)
│ ├── routes.py # Legacy API endpoints │ ├── routes.py # Legacy API endpoints + Series 3/4 heartbeat endpoints
│ ├── routers/ # Modular API routers │ ├── routers/ # Modular API routers
│ │ ├── roster.py # Fleet status endpoints │ │ ├── roster.py # Fleet status endpoints
│ │ ├── roster_edit.py # Roster management & CSV import │ │ ├── roster_edit.py # Roster management & CSV import
@@ -330,10 +361,11 @@ seismo-fleet-manager/
│ │ ├── photos.py # Photo management │ │ ├── photos.py # Photo management
│ │ ├── dashboard.py # Dashboard partials │ │ ├── dashboard.py # Dashboard partials
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints │ │ ├── dashboard_tabs.py # Dashboard tab endpoints
│ │ └── settings.py # Roster manager/data operations │ │ └── settings.py # Settings, preferences, and data management
│ ├── services/ │ ├── services/
│ │ └── snapshot.py # Fleet status snapshot logic │ │ └── snapshot.py # Fleet status snapshot logic
│ ├── 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
│ └── static/ # Static assets (CSS, etc.) │ └── static/ # Static assets (CSS, etc.)
├── create_test_db.py # Generate a sample SQLite DB with mixed devices ├── create_test_db.py # Generate a sample SQLite DB with mixed devices
├── templates/ # Jinja2 HTML templates ├── templates/ # Jinja2 HTML templates
@@ -400,24 +432,31 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.3.0 — 2025-12-09
- **Series 4 Support**: New `/api/series4/heartbeat` endpoint with auto-detection for Micromate units (UM##### pattern)
- **Settings Redesign**: Completely redesigned Settings page with 4-tab interface (General, Data Management, Advanced, Danger Zone)
- **User Preferences**: Backend storage for timezone, theme, auto-refresh interval, calibration defaults, and status thresholds
- **Development Labels**: Visual indicators to distinguish dev from production environments
- **Timezone Support**: Comprehensive timezone handling with human-readable timestamps site-wide
- **Quality of Life**: Relative timestamps, status icons for accessibility, breadcrumb navigation, copy-to-clipboard, search functionality
### v0.2.1 — 2025-12-03 ### v0.2.1 — 2025-12-03
- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions. - Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions
- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions (edit, deploy toggle, ignore, delete). - Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions
- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields) with a refreshed editing experience. - Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields)
- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data for mapping widgets. - Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data
### v0.2.0 — 2025-12-03 ### v0.2.0 — 2025-12-03
- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper for new installs. - Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper
- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster. - Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster
- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts. - Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts
- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata for the dashboard and roster tabs. - Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata
### v0.1.1 — 2025-12-02 ### v0.1.1 — 2025-12-02
- **Roster Editing API**: Full CRUD operations for managing your fleet roster - **Roster Editing API**: Full CRUD operations for managing your fleet roster
- **CSV Import**: Bulk upload roster data from CSV files - **CSV Import**: Bulk upload roster data from CSV files
- **Enhanced Data Model**: Added project_id and location fields to roster - **Enhanced Data Model**: Added project_id and location fields to roster
- **Bug Fixes**: Improved database session management and error handling - **Bug Fixes**: Improved database session management and error handling
- **Dashboard Improvements**: Separate views for Active, Benched, and Retired units
See [CHANGELOG.md](CHANGELOG.md) for the full release notes. See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
@@ -437,9 +476,11 @@ MIT
## Version ## Version
**Current: 0.2.1** — Settings & roster manager refresh (2025-12-03) **Current: 0.3.0** — Series 4 support, settings redesign, user preferences (2025-12-09)
Previous: 0.2.0Device-type aware roster + ignore list (2025-12-03) Previous: 0.2.1Settings & roster manager refresh (2025-12-03)
0.2.0 — Device-type aware roster + ignore list (2025-12-03)
0.1.1 — Roster Management & CSV Import (2025-12-02) 0.1.1 — Roster Management & CSV Import (2025-12-02)

View File

@@ -18,7 +18,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.2.2" VERSION = "0.3.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",
@@ -258,9 +258,9 @@ async def unknown_emitters_partial(request: Request):
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""
return { return {
"message": "Seismo Fleet Manager v0.1.1", "message": f"Seismo Fleet Manager v{VERSION}",
"status": "running", "status": "running",
"version": "0.1.1" "version": VERSION
} }

View File

@@ -0,0 +1,80 @@
"""
Migration script to add user_preferences table.
This creates a new table for storing persistent user preferences:
- Display settings (timezone, theme, date format)
- Auto-refresh configuration
- Calibration defaults
- Status threshold customization
Run this script once to migrate an existing database.
"""
import sqlite3
import os
# Database path
DB_PATH = "./data/seismo_fleet.db"
def migrate_database():
"""Create user_preferences table"""
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
print("The database will be created automatically when you run the application.")
return
print(f"Migrating database: {DB_PATH}")
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
# Check if user_preferences table already exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='user_preferences'")
table_exists = cursor.fetchone()
if table_exists:
print("Migration already applied - user_preferences table exists")
conn.close()
return
print("Creating user_preferences table...")
try:
cursor.execute("""
CREATE TABLE user_preferences (
id INTEGER PRIMARY KEY DEFAULT 1,
timezone TEXT DEFAULT 'America/New_York',
theme TEXT DEFAULT 'auto',
auto_refresh_interval INTEGER DEFAULT 10,
date_format TEXT DEFAULT 'MM/DD/YYYY',
table_rows_per_page INTEGER DEFAULT 25,
calibration_interval_days INTEGER DEFAULT 365,
calibration_warning_days INTEGER DEFAULT 30,
status_ok_threshold_hours INTEGER DEFAULT 12,
status_pending_threshold_hours INTEGER DEFAULT 24,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
print(" ✓ Created user_preferences table")
# Insert default preferences
cursor.execute("""
INSERT INTO user_preferences (id) VALUES (1)
""")
print(" ✓ Inserted default preferences")
conn.commit()
print("\nMigration completed successfully!")
except sqlite3.Error as e:
print(f"\nError during migration: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
migrate_database()

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
from datetime import datetime from datetime import datetime
from backend.database import Base from backend.database import Base
@@ -57,3 +57,23 @@ class IgnoredUnit(Base):
id = Column(String, primary_key=True, index=True) id = Column(String, primary_key=True, index=True)
reason = Column(String, nullable=True) reason = Column(String, nullable=True)
ignored_at = Column(DateTime, default=datetime.utcnow) ignored_at = Column(DateTime, default=datetime.utcnow)
class UserPreferences(Base):
"""
User preferences: persistent storage for application settings.
Single-row table (id=1) to store global user preferences.
"""
__tablename__ = "user_preferences"
id = Column(Integer, primary_key=True, default=1)
timezone = Column(String, default="America/New_York")
theme = Column(String, default="auto") # auto, light, dark
auto_refresh_interval = Column(Integer, default=10) # seconds
date_format = Column(String, default="MM/DD/YYYY")
table_rows_per_page = Column(Integer, default=25)
calibration_interval_days = Column(Integer, default=365)
calibration_warning_days = Column(Integer, default=30)
status_ok_threshold_hours = Column(Integer, default=12)
status_pending_threshold_hours = Column(Integer, default=24)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)

View File

@@ -2,11 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
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 typing import Optional
import csv import csv
import io import io
from backend.database import get_db from backend.database import get_db
from backend.models import RosterUnit, Emitter, IgnoredUnit from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
router = APIRouter(prefix="/api/settings", tags=["settings"]) router = APIRouter(prefix="/api/settings", tags=["settings"])
@@ -239,3 +241,87 @@ def clear_ignored(db: Session = Depends(get_db)):
except Exception as e: except Exception as e:
db.rollback() db.rollback()
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}") raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
# User Preferences Endpoints
class PreferencesUpdate(BaseModel):
"""Schema for updating user preferences (all fields optional)"""
timezone: Optional[str] = None
theme: Optional[str] = None
auto_refresh_interval: Optional[int] = None
date_format: Optional[str] = None
table_rows_per_page: Optional[int] = None
calibration_interval_days: Optional[int] = None
calibration_warning_days: Optional[int] = None
status_ok_threshold_hours: Optional[int] = None
status_pending_threshold_hours: Optional[int] = None
@router.get("/preferences")
def get_preferences(db: Session = Depends(get_db)):
"""
Get user preferences. Creates default preferences if none exist.
"""
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
if not prefs:
# Create default preferences
prefs = UserPreferences(id=1)
db.add(prefs)
db.commit()
db.refresh(prefs)
return {
"timezone": prefs.timezone,
"theme": prefs.theme,
"auto_refresh_interval": prefs.auto_refresh_interval,
"date_format": prefs.date_format,
"table_rows_per_page": prefs.table_rows_per_page,
"calibration_interval_days": prefs.calibration_interval_days,
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}
@router.put("/preferences")
def update_preferences(
updates: PreferencesUpdate,
db: Session = Depends(get_db)
):
"""
Update user preferences. Accepts partial updates.
Creates default preferences if none exist.
"""
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
if not prefs:
# Create default preferences
prefs = UserPreferences(id=1)
db.add(prefs)
# Update only provided fields
update_data = updates.dict(exclude_unset=True)
for field, value in update_data.items():
setattr(prefs, field, value)
prefs.updated_at = datetime.utcnow()
db.commit()
db.refresh(prefs)
return {
"message": "Preferences updated successfully",
"timezone": prefs.timezone,
"theme": prefs.theme,
"auto_refresh_interval": prefs.auto_refresh_interval,
"date_format": prefs.date_format,
"table_rows_per_page": prefs.table_rows_per_page,
"calibration_interval_days": prefs.calibration_interval_days,
"calibration_warning_days": prefs.calibration_warning_days,
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
}

View File

@@ -149,6 +149,116 @@
} else if (localStorage.getItem('theme') === 'dark') { } else if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark'); document.documentElement.classList.add('dark');
} }
// Helper function: Convert timestamp to relative time
function timeAgo(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
const now = new Date();
const seconds = Math.floor((now - date) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) {
const remainingMins = minutes % 60;
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
}
const days = Math.floor(hours / 24);
if (days < 7) {
const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
}
const weeks = Math.floor(days / 7);
if (weeks < 4) {
const remainingDays = days % 7;
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
}
const months = Math.floor(days / 30);
return `${months}mo ago`;
}
// Helper function: Get user's selected timezone
function getTimezone() {
return localStorage.getItem('timezone') || 'America/New_York';
}
// Helper function: Format timestamp with tooltip (timezone-aware)
function formatTimestamp(dateString) {
if (!dateString) return '<span class="text-gray-400">Never</span>';
const date = new Date(dateString);
const timezone = getTimezone();
const fullDate = date.toLocaleString('en-US', {
weekday: 'short',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: timezone,
timeZoneName: 'short'
});
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
}
// Helper function: Format timestamp as full date/time (no relative time)
// Format: "9/10/2020 8:00 AM EST"
function formatFullTimestamp(dateString) {
if (!dateString) return 'Never';
const date = new Date(dateString);
const timezone = getTimezone();
return date.toLocaleString('en-US', {
month: 'numeric',
day: 'numeric',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
timeZoneName: 'short'
});
}
// Update all timestamps on page load and periodically
function updateAllTimestamps() {
document.querySelectorAll('[data-timestamp]').forEach(el => {
const timestamp = el.getAttribute('data-timestamp');
el.innerHTML = formatTimestamp(timestamp);
});
}
// Run on load and every minute
updateAllTimestamps();
setInterval(updateAllTimestamps, 60000);
// Copy to clipboard helper
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
// Visual feedback
const originalHTML = button.innerHTML;
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
button.classList.add('text-green-500');
setTimeout(() => {
button.innerHTML = originalHTML;
button.classList.remove('text-green-500');
}, 1500);
}).catch(err => {
console.error('Failed to copy:', err);
});
}
</script> </script>
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}

View File

@@ -10,10 +10,16 @@
</div> </div>
{% endif %} {% endif %}
<div class="mb-8"> <div class="mb-8 flex justify-between items-center">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p> <p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
</div> </div>
<div class="text-right">
<p class="text-xs text-gray-500 dark:text-gray-400">Last updated</p>
<p id="last-refresh" class="text-sm text-gray-700 dark:text-gray-300 font-mono">--</p>
</div>
</div>
<!-- Dashboard cards with auto-refresh --> <!-- Dashboard cards with auto-refresh -->
<div hx-get="/api/status-snapshot" <div hx-get="/api/status-snapshot"
@@ -48,23 +54,35 @@
</div> </div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"> <div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p> <p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span> <span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
</svg>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span> <span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
</div> </div>
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span> <span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
</div> </div>
<div class="flex justify-between items-center mb-2"> <div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span> <span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
</svg>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span> <span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
</div> </div>
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span> <span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
</div> </div>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
<div class="flex items-center"> <div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-red-500 mr-2"></span> <span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
</svg>
</span>
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span> <span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
</div> </div>
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span> <span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
@@ -185,6 +203,17 @@ function updateDashboard(event) {
try { try {
const data = JSON.parse(event.detail.xhr.response); const data = JSON.parse(event.detail.xhr.response);
// Update "Last updated" timestamp with timezone
const now = new Date();
const timezone = localStorage.getItem('timezone') || 'America/New_York';
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZone: timezone,
timeZoneName: 'short'
});
// ===== Fleet summary numbers ===== // ===== Fleet summary numbers =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0; document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;

View File

@@ -46,5 +46,11 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No active units</p> <div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No active units</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new unit to the fleet.</p>
</div>
{% endif %} {% endif %}

View File

@@ -46,5 +46,11 @@
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No benched units</p> <div class="text-center py-12">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No benched units</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Units awaiting deployment will appear here.</p>
</div>
{% endif %} {% endif %}

View File

@@ -34,6 +34,21 @@
<!-- Fleet Roster with Tabs --> <!-- Fleet Roster 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">
<!-- Search Bar -->
<div class="mb-4">
<div class="relative">
<input
type="text"
id="roster-search"
placeholder="Search by Unit ID, Type, or Note..."
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
onkeyup="filterRosterTable()">
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
<!-- Tab Bar --> <!-- 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
@@ -805,6 +820,31 @@
alert(`Error: ${error.message}`); alert(`Error: ${error.message}`);
} }
} }
// Filter roster table based on search input
function filterRosterTable() {
const searchInput = document.getElementById('roster-search').value.toLowerCase();
const table = document.querySelector('#roster-content table tbody');
if (!table) return;
const rows = table.getElementsByTagName('tr');
for (let row of rows) {
const cells = row.getElementsByTagName('td');
if (cells.length === 0) continue; // Skip header or empty rows
const unitId = cells[1]?.textContent?.toLowerCase() || '';
const unitType = cells[2]?.textContent?.toLowerCase() || '';
const note = cells[6]?.textContent?.toLowerCase() || '';
const matches = unitId.includes(searchInput) ||
unitType.includes(searchInput) ||
note.includes(searchInput);
row.style.display = matches ? '' : 'none';
}
}
</script> </script>
<style> <style>

View File

@@ -4,16 +4,130 @@
{% block content %} {% block content %}
<div class="mb-8"> <div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Roster Manager</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Settings</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Manage your fleet roster data - import, export, and reset</p> <p class="text-gray-600 dark:text-gray-400 mt-1">Configure application preferences and manage fleet data</p>
</div> </div>
<!-- CSV Export Section --> <!-- Tab Navigation -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6"> <div class="flex border-b border-gray-200 dark:border-gray-700 mb-6">
<button class="settings-tab active-settings-tab" data-tab="general" onclick="showTab('general')">
<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="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4"></path>
</svg>
General
</button>
<button class="settings-tab" data-tab="data" onclick="showTab('data')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
</svg>
Data Management
</button>
<button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Advanced
</button>
<button class="settings-tab text-red-600 dark:text-red-400" data-tab="danger" onclick="showTab('danger')">
<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="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>
Danger Zone
</button>
</div>
<!-- General Tab -->
<div id="general-tab" class="tab-content">
<div class="space-y-6">
<!-- Display Preferences Card -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Display Preferences</h2>
<div class="space-y-4">
<!-- Timezone -->
<div>
<label for="timezone-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Timezone
</label>
<select id="timezone-select"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<option value="America/New_York">Eastern Time (ET)</option>
<option value="America/Chicago">Central Time (CT)</option>
<option value="America/Denver">Mountain Time (MT)</option>
<option value="America/Los_Angeles">Pacific Time (PT)</option>
<option value="America/Anchorage">Alaska Time (AKT)</option>
<option value="Pacific/Honolulu">Hawaii Time (HT)</option>
<option value="UTC">UTC</option>
<option value="Europe/London">London (GMT/BST)</option>
<option value="Europe/Paris">Paris (CET/CEST)</option>
<option value="Asia/Tokyo">Tokyo (JST)</option>
<option value="Australia/Sydney">Sydney (AEST/AEDT)</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
All timestamps will be displayed in this timezone
</p>
</div>
<!-- Theme -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Theme
</label>
<div class="flex gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="theme" value="auto" class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange">
<span class="text-sm text-gray-700 dark:text-gray-300">Auto (System)</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="theme" value="light" class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange">
<span class="text-sm text-gray-700 dark:text-gray-300">Light</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" name="theme" value="dark" class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange">
<span class="text-sm text-gray-700 dark:text-gray-300">Dark</span>
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Choose your preferred color scheme
</p>
</div>
<!-- Auto-refresh Interval -->
<div>
<label for="refresh-interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Auto-Refresh Interval
</label>
<select id="refresh-interval"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<option value="5">5 seconds</option>
<option value="10" selected>10 seconds</option>
<option value="30">30 seconds</option>
<option value="60">1 minute</option>
<option value="0">Disabled</option>
</select>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
How often the dashboard should refresh automatically
</p>
</div>
</div>
<button onclick="saveGeneralSettings()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Changes
</button>
</div>
</div>
</div>
<!-- Data Management Tab -->
<div id="data-tab" class="tab-content hidden">
<div class="space-y-6">
<!-- Export Section -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-start justify-between"> <div class="flex items-start justify-between">
<div class="flex-1"> <div class="flex-1">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Export Roster</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-2">Export Data</h2>
<p class="text-gray-600 dark:text-gray-400 mb-4"> <p class="text-gray-600 dark:text-gray-400">
Download all roster data as CSV for backup or editing externally Download all roster data as CSV for backup or editing externally
</p> </p>
</div> </div>
@@ -29,43 +143,21 @@
</div> </div>
</div> </div>
<!-- CSV Import Section --> <!-- Import Section (Merge Mode Only) -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Import Roster</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Import Data (Merge Mode)</h2>
<form id="importSettingsForm" class="space-y-4"> <form id="importMergeForm" class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File *</label> <label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File</label>
<input type="file" name="file" accept=".csv" required <input type="file" name="file" accept=".csv" required
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 focus:outline-none"> 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 focus:outline-none">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1"> <p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
CSV must include column: unit_id (required) CSV must include column: unit_id (required). Existing units will be updated, new units will be added.
</p> </p>
</div> </div>
<div> <div id="importMergeResult" class="hidden"></div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">Import Mode *</label>
<div class="space-y-3">
<label class="flex items-start gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-slate-700 cursor-pointer">
<input type="radio" name="mode" value="merge" checked
class="mt-1 w-4 h-4 text-seismo-orange focus:ring-seismo-orange">
<div>
<div class="font-medium text-gray-900 dark:text-white">Merge/Overwrite</div>
<div class="text-sm text-gray-600 dark:text-gray-400">Update existing units, add new units (safe)</div>
</div>
</label>
<label class="flex items-start gap-3 p-3 rounded-lg border border-red-300 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20 cursor-pointer">
<input type="radio" name="mode" value="replace"
class="mt-1 w-4 h-4 text-red-600 focus:ring-red-600">
<div>
<div class="font-medium text-red-600 dark:text-red-400">Replace All</div>
<div class="text-sm text-red-600 dark:text-red-400">⚠️ Delete ALL roster units first, then import (DANGEROUS)</div>
</div>
</label>
</div>
</div>
<div id="importResult" class="hidden"></div>
<button type="submit" class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"> <button type="submit" class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Import CSV Import CSV
@@ -74,7 +166,7 @@
</div> </div>
<!-- Roster Management Table --> <!-- Roster Management Table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-6"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<div> <div>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Roster Units</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white">Roster Units</h2>
@@ -122,91 +214,449 @@
<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>
<!-- Danger Zone --> <!-- Advanced Tab -->
<div class="bg-red-50 dark:bg-red-900/20 rounded-xl border-2 border-red-200 dark:border-red-800 p-6"> <div id="advanced-tab" class="tab-content hidden">
<div class="flex items-start gap-3 mb-6"> <div class="space-y-6">
<svg class="w-6 h-6 text-red-600 dark:text-red-400 flex-shrink-0 mt-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <!-- 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> <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>
<h2 class="text-xl font-semibold text-red-600 dark:text-red-400 mb-1">Danger Zone</h2> <p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p>
<p class="text-sm text-gray-700 dark:text-gray-300"> <p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">These settings can affect system behavior. Change with caution.</p>
Irreversible operations - use with extreme caution </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>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
This will DELETE all existing roster units before importing.
</p>
<form id="importReplaceForm" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File</label>
<input type="file" name="file" accept=".csv" required
class="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 focus:outline-none">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
All roster data will be replaced with this CSV file
</p>
</div>
<div id="importReplaceResult" class="hidden"></div>
<button type="submit" class="px-6 py-3 bg-yellow-600 hover:bg-yellow-700 text-white rounded-lg transition-colors">
Replace All Roster Data
</button>
</form>
</div>
<!-- Status Thresholds -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Status Thresholds</h2>
<div class="space-y-4">
<div>
<label for="ok-threshold" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
OK Threshold (hours)
</label>
<input type="number" id="ok-threshold" min="1" max="24"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Units are OK if last seen within this many hours
</p>
</div>
<div>
<label for="pending-threshold" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Pending Threshold (hours)
</label>
<input type="number" id="pending-threshold" min="1" max="48"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Units are Pending if last seen within this many hours (units exceeding this are Missing)
</p> </p>
</div> </div>
</div> </div>
<button onclick="saveStatusThresholds()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Thresholds
</button>
</div>
<!-- Calibration Defaults -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Calibration Defaults</h2>
<div class="space-y-4"> <div class="space-y-4">
<!-- Clear All Data --> <div>
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800"> <label for="cal-interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
<div class="flex-1"> Calibration Interval (days)
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear All Data</h3> </label>
<p class="text-sm text-gray-600 dark:text-gray-400"> <input type="number" id="cal-interval" min="1"
Delete ALL roster units, emitters, and ignored units class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Default interval between calibrations
</p> </p>
</div> </div>
<button onclick="confirmClearAll()"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap"> <div>
<label for="cal-warning" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Warning Threshold (days before due)
</label>
<input type="number" id="cal-warning" min="1"
class="w-full max-w-md px-4 py-2 text-gray-900 dark:text-gray-100 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
Show warnings when calibration is due within this many days
</p>
</div>
</div>
<button onclick="saveCalibrationDefaults()" class="mt-6 px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Save Defaults
</button>
</div>
</div>
</div>
<!-- Danger Zone Tab -->
<div id="danger-tab" class="tab-content hidden">
<div class="space-y-6">
<!-- Warning Banner -->
<div class="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 p-4">
<div class="flex">
<svg class="w-5 h-5 text-red-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-red-800 dark:text-red-300">Danger Zone</p>
<p class="text-sm text-red-700 dark:text-red-400 mt-1">These operations are PERMANENT and CANNOT be undone. Proceed with extreme caution.</p>
</div>
</div>
</div>
<!-- Clear All Data -->
<div class="border-2 border-red-300 dark:border-red-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex justify-between items-start">
<div>
<h3 class="font-semibold text-red-600 dark:text-red-400 text-lg">Clear All Data</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Deletes roster units, emitters, and ignored units. Everything will be lost.
</p>
</div>
<button onclick="confirmClearAll()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear All Clear All
</button> </button>
</div> </div>
</div>
<!-- Clear Roster Only --> <!-- Clear Roster Only -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800"> <div class="border border-red-200 dark:border-red-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex-1"> <div class="flex justify-between items-start">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Roster Table</h3> <div>
<p class="text-sm text-gray-600 dark:text-gray-400"> <h3 class="font-semibold text-gray-900 dark:text-white">Clear Roster Table</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Delete all roster units only (keeps emitters and ignored units) Delete all roster units only (keeps emitters and ignored units)
</p> </p>
</div> </div>
<button onclick="confirmClearRoster()" <button onclick="confirmClearRoster()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Roster Clear Roster
</button> </button>
</div> </div>
</div>
<!-- Clear Emitters Only --> <!-- Clear Emitters Only -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800"> <div class="border border-red-200 dark:border-red-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex-1"> <div class="flex justify-between items-start">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Emitters Table</h3> <div>
<p class="text-sm text-gray-600 dark:text-gray-400"> <h3 class="font-semibold text-gray-900 dark:text-white">Clear Emitters Table</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Delete all auto-discovered emitters (will repopulate automatically) Delete all auto-discovered emitters (will repopulate automatically)
</p> </p>
</div> </div>
<button onclick="confirmClearEmitters()" <button onclick="confirmClearEmitters()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Emitters Clear Emitters
</button> </button>
</div> </div>
</div>
<!-- Clear Ignored Only --> <!-- Clear Ignored Units -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 p-4 bg-white dark:bg-slate-800 rounded-lg border border-red-200 dark:border-red-800"> <div class="border border-red-200 dark:border-red-800 rounded-lg p-6 bg-white dark:bg-slate-800">
<div class="flex-1"> <div class="flex justify-between items-start">
<h3 class="font-semibold text-gray-900 dark:text-white mb-1">Clear Ignored Units</h3> <div>
<p class="text-sm text-gray-600 dark:text-gray-400"> <h3 class="font-semibold text-gray-900 dark:text-white">Clear Ignored Units</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
Remove all units from the ignore list Remove all units from the ignore list
</p> </p>
</div> </div>
<button onclick="confirmClearIgnored()" <button onclick="confirmClearIgnored()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
Clear Ignored Clear Ignored
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
<style>
.settings-tab {
padding: 0.75rem 1.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #6b7280;
border-bottom: 2px solid transparent;
transition: all 0.2s;
background: none;
cursor: pointer;
}
.settings-tab:hover {
color: #374151;
background-color: rgba(249, 250, 251, 0.5);
}
.dark .settings-tab:hover {
color: #d1d5db;
background-color: rgba(55, 65, 81, 0.3);
}
.active-settings-tab {
color: #f48b1c !important;
border-bottom-color: #f48b1c !important;
}
.tab-content {
animation: fadeIn 0.3s ease-in;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
<script> <script>
// CSV Import Handler // ========== TAB MANAGEMENT ==========
document.getElementById('importSettingsForm').addEventListener('submit', async function(e) {
function showTab(tabName) {
// Hide all tabs
document.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.add('hidden');
});
// Remove active class from all buttons
document.querySelectorAll('.settings-tab').forEach(btn => {
btn.classList.remove('active-settings-tab');
});
// Show selected tab
document.getElementById(tabName + '-tab').classList.remove('hidden');
// Add active class to clicked button
const clickedButton = document.querySelector(`[data-tab="${tabName}"]`);
if (clickedButton) {
clickedButton.classList.add('active-settings-tab');
}
// Save last active tab to localStorage
localStorage.setItem('settings-last-tab', tabName);
// Load roster table when data tab is shown
if (tabName === 'data') {
loadRosterTable();
}
}
// Restore last active tab on page load
window.addEventListener('DOMContentLoaded', () => {
const lastTab = localStorage.getItem('settings-last-tab') || 'general';
showTab(lastTab);
loadPreferences();
});
// ========== GENERAL TAB - PREFERENCES ==========
let currentPreferences = {};
async function loadPreferences() {
try {
const response = await fetch('/api/settings/preferences');
const prefs = await response.json();
currentPreferences = prefs;
// Load timezone
document.getElementById('timezone-select').value = prefs.timezone || 'America/New_York';
// Load theme
const themeRadios = document.querySelectorAll('input[name="theme"]');
themeRadios.forEach(radio => {
radio.checked = radio.value === (prefs.theme || 'auto');
});
// Load auto-refresh interval
document.getElementById('refresh-interval').value = prefs.auto_refresh_interval || 10;
// Load status thresholds
document.getElementById('ok-threshold').value = prefs.status_ok_threshold_hours || 12;
document.getElementById('pending-threshold').value = prefs.status_pending_threshold_hours || 24;
// Load calibration defaults
document.getElementById('cal-interval').value = prefs.calibration_interval_days || 365;
document.getElementById('cal-warning').value = prefs.calibration_warning_days || 30;
} catch (error) {
console.error('Error loading preferences:', error);
}
}
async function saveGeneralSettings() {
const timezone = document.getElementById('timezone-select').value;
const theme = document.querySelector('input[name="theme"]:checked').value;
const autoRefreshInterval = parseInt(document.getElementById('refresh-interval').value);
try {
const response = await fetch('/api/settings/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
timezone,
theme,
auto_refresh_interval: autoRefreshInterval
})
});
if (response.ok) {
// Also update localStorage for immediate effect
localStorage.setItem('timezone', timezone);
// Visual feedback
alert('Settings saved successfully!');
// Refresh timestamps on page
if (typeof updateAllTimestamps === 'function') {
updateAllTimestamps();
}
} else {
const result = await response.json();
alert('Error saving settings: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error saving settings: ' + error.message);
}
}
async function saveStatusThresholds() {
const okThreshold = parseInt(document.getElementById('ok-threshold').value);
const pendingThreshold = parseInt(document.getElementById('pending-threshold').value);
try {
const response = await fetch('/api/settings/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
status_ok_threshold_hours: okThreshold,
status_pending_threshold_hours: pendingThreshold
})
});
if (response.ok) {
alert('Status thresholds saved successfully!');
} else {
const result = await response.json();
alert('Error saving thresholds: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error saving thresholds: ' + error.message);
}
}
async function saveCalibrationDefaults() {
const calInterval = parseInt(document.getElementById('cal-interval').value);
const calWarning = parseInt(document.getElementById('cal-warning').value);
try {
const response = await fetch('/api/settings/preferences', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
calibration_interval_days: calInterval,
calibration_warning_days: calWarning
})
});
if (response.ok) {
alert('Calibration defaults saved successfully!');
} else {
const result = await response.json();
alert('Error saving defaults: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('Error saving defaults: ' + error.message);
}
}
// ========== DATA TAB - IMPORT/EXPORT ==========
// Merge Mode Import
document.getElementById('importMergeForm').addEventListener('submit', async function(e) {
e.preventDefault(); e.preventDefault();
const formData = new FormData(this); const formData = new FormData(this);
const mode = formData.get('mode'); const resultDiv = document.getElementById('importMergeResult');
const resultDiv = document.getElementById('importResult');
// For replace mode, get confirmation try {
if (mode === 'replace') { const response = await fetch('/api/roster/import-csv', {
method: 'POST',
body: formData
});
const result = await response.json();
if (response.ok) {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
resultDiv.innerHTML = `
<p class="font-semibold mb-2">Import Successful!</p>
<ul class="text-sm space-y-1">
<li>✅ Added: ${result.summary.added}</li>
<li>🔄 Updated: ${result.summary.updated}</li>
<li>⏭️ Skipped: ${result.summary.skipped}</li>
<li>❌ Errors: ${result.summary.errors}</li>
</ul>
`;
resultDiv.classList.remove('hidden');
// Refresh roster table
setTimeout(() => {
this.reset();
resultDiv.classList.add('hidden');
loadRosterTable();
}, 3000);
} else {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${result.detail || 'Unknown error'}</p>`;
resultDiv.classList.remove('hidden');
}
} catch (error) {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${error.message}</p>`;
resultDiv.classList.remove('hidden');
}
});
// Replace Mode Import
document.getElementById('importReplaceForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('importReplaceResult');
// Get confirmation
try { try {
const stats = await fetch('/api/settings/stats').then(r => r.json()); const stats = await fetch('/api/settings/stats').then(r => r.json());
@@ -227,15 +677,9 @@ Continue?`;
alert('Error fetching statistics: ' + error.message); alert('Error fetching statistics: ' + error.message);
return; return;
} }
}
// Choose endpoint based on mode
const endpoint = mode === 'replace'
? '/api/settings/import-csv-replace'
: '/api/roster/import-csv';
try { try {
const response = await fetch(endpoint, { const response = await fetch('/api/settings/import-csv-replace', {
method: 'POST', method: 'POST',
body: formData body: formData
}); });
@@ -244,32 +688,20 @@ Continue?`;
if (response.ok) { if (response.ok) {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200'; resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
if (mode === 'replace') {
resultDiv.innerHTML = ` resultDiv.innerHTML = `
<p class="font-semibold mb-2">Import Successful!</p> <p class="font-semibold mb-2">Replace Import Successful!</p>
<ul class="text-sm space-y-1"> <ul class="text-sm space-y-1">
<li> Deleted: ${result.deleted}</li> <li>🗑️ Deleted: ${result.deleted}</li>
<li>✅ Added: ${result.added}</li> <li>✅ Added: ${result.added}</li>
</ul> </ul>
`; `;
} else {
resultDiv.innerHTML = `
<p class="font-semibold mb-2">Import Successful!</p>
<ul class="text-sm space-y-1">
<li>✅ Added: ${result.summary.added}</li>
<li>🔄 Updated: ${result.summary.updated}</li>
<li>⏭️ Skipped: ${result.summary.skipped}</li>
<li>❌ Errors: ${result.summary.errors}</li>
</ul>
`;
}
resultDiv.classList.remove('hidden'); resultDiv.classList.remove('hidden');
// Reset form after 3 seconds // Refresh roster table
setTimeout(() => { setTimeout(() => {
this.reset(); this.reset();
resultDiv.classList.add('hidden'); resultDiv.classList.add('hidden');
loadRosterTable();
}, 3000); }, 3000);
} else { } else {
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200'; resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
@@ -283,132 +715,8 @@ Continue?`;
} }
}); });
// Clear All Data
async function confirmClearAll() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
const message = `⚠️ CLEAR ALL DATA WARNING ⚠️
You are about to DELETE ALL data:
• Roster units: ${stats.roster}
• Emitters: ${stats.emitters}
• Ignored units: ${stats.ignored}
• TOTAL: ${stats.total} records
THIS ACTION CANNOT BE UNDONE!
Export a backup first if needed.
Are you absolutely sure?`;
if (!confirm(message)) {
return;
}
const response = await fetch('/api/settings/clear-all', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted.total} records\n\n• Roster: ${result.deleted.roster}\n• Emitters: ${result.deleted.emitters}\n• Ignored: ${result.deleted.ignored}`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Roster Only
async function confirmClearRoster() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.roster} roster units?\n\nThis cannot be undone!`)) {
return;
}
const response = await fetch('/api/settings/clear-roster', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} roster units`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Emitters Only
async function confirmClearEmitters() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.emitters} emitters?\n\nEmitters will repopulate automatically from ACH files.`)) {
return;
}
const response = await fetch('/api/settings/clear-emitters', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} emitters`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// Clear Ignored Only
async function confirmClearIgnored() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Remove all ${stats.ignored} units from ignore list?\n\nThey will appear as unknown emitters again.`)) {
return;
}
const response = await fetch('/api/settings/clear-ignored', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Removed ${result.deleted} units from ignore list`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
// ========== ROSTER MANAGEMENT TABLE ========== // ========== ROSTER MANAGEMENT TABLE ==========
// Load roster table on page load
document.addEventListener('DOMContentLoaded', function() {
loadRosterTable();
});
async function loadRosterTable() { async function loadRosterTable() {
const loading = document.getElementById('rosterTableLoading'); const loading = document.getElementById('rosterTableLoading');
const container = document.getElementById('rosterTableContainer'); const container = document.getElementById('rosterTableContainer');
@@ -551,7 +859,6 @@ async function toggleRetired(unitId, currentState) {
} }
function editUnit(unitId) { function editUnit(unitId) {
// Navigate to unit detail page for full editing
window.location.href = `/unit/${unitId}`; window.location.href = `/unit/${unitId}`;
} }
@@ -580,5 +887,122 @@ async function confirmDeleteUnit(unitId) {
function refreshRosterTable() { function refreshRosterTable() {
loadRosterTable(); loadRosterTable();
} }
// ========== DANGER ZONE ==========
async function confirmClearAll() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
const message = `⚠️ CLEAR ALL DATA WARNING ⚠️
You are about to DELETE ALL data:
• Roster units: ${stats.roster}
• Emitters: ${stats.emitters}
• Ignored units: ${stats.ignored}
• TOTAL: ${stats.total} records
THIS ACTION CANNOT BE UNDONE!
Export a backup first if needed.
Are you absolutely sure?`;
if (!confirm(message)) {
return;
}
const response = await fetch('/api/settings/clear-all', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted.total} records\n\n• Roster: ${result.deleted.roster}\n• Emitters: ${result.deleted.emitters}\n• Ignored: ${result.deleted.ignored}`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function confirmClearRoster() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.roster} roster units?\n\nThis cannot be undone!`)) {
return;
}
const response = await fetch('/api/settings/clear-roster', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} roster units`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function confirmClearEmitters() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Delete all ${stats.emitters} emitters?\n\nEmitters will repopulate automatically from heartbeats.`)) {
return;
}
const response = await fetch('/api/settings/clear-emitters', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Deleted ${result.deleted} emitters`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
async function confirmClearIgnored() {
try {
const stats = await fetch('/api/settings/stats').then(r => r.json());
if (!confirm(`Remove all ${stats.ignored} units from ignore list?\n\nThey will appear as unknown emitters again.`)) {
return;
}
const response = await fetch('/api/settings/clear-ignored', {
method: 'POST'
});
if (response.ok) {
const result = await response.json();
alert(`✅ Success! Removed ${result.deleted} units from ignore list`);
location.reload();
} else {
const result = await response.json();
alert('❌ Error: ' + (result.detail || 'Unknown error'));
}
} catch (error) {
alert('❌ Error: ' + error.message);
}
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -4,14 +4,44 @@
{% block content %} {% block content %}
<div class="mb-6"> <div class="mb-6">
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center mb-4"> <!-- Breadcrumb Navigation -->
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path> <ol class="inline-flex items-center space-x-1 md:space-x-3">
</svg> <li class="inline-flex items-center">
Back to Fleet Roster <a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
Dashboard
</a> </a>
</li>
<li>
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
<a href="/roster" class="ml-1 text-gray-500 hover:text-seismo-orange dark:text-gray-400">
Fleet Roster
</a>
</div>
</li>
<li aria-current="page">
<div class="flex items-center">
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
</svg>
<span class="ml-1 text-gray-700 dark:text-gray-300 font-medium" id="breadcrumb-unit">Unit</span>
</div>
</li>
</ol>
</nav>
<div class="flex justify-between items-center"> <div class="flex justify-between items-center">
<div class="flex items-center gap-2">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<button onclick="copyToClipboard(window.currentUnitId, this)" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1" title="Copy Unit ID">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
</svg>
</button>
</div>
<div class="flex gap-3"> <div class="flex gap-3">
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2"> <button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -323,8 +353,10 @@ async function loadUnitData() {
// Populate view mode (read-only display) // Populate view mode (read-only display)
function populateViewMode() { function populateViewMode() {
// Update page title // Update page title and store unit ID for copy function
window.currentUnitId = currentUnit.id;
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`; document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
document.getElementById('breadcrumb-unit').textContent = currentUnit.id;
// Get status info from snapshot // Get status info from snapshot
let unitStatus = null; let unitStatus = null;
@@ -348,7 +380,14 @@ function populateViewMode() {
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`; document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`; document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown'; document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
// Format "Last Seen" with timezone-aware formatting
if (unitStatus.last && typeof formatFullTimestamp === 'function') {
document.getElementById('lastSeen').textContent = formatFullTimestamp(unitStatus.last);
} else {
document.getElementById('lastSeen').textContent = unitStatus.last || '--'; document.getElementById('lastSeen').textContent = unitStatus.last || '--';
}
document.getElementById('age').textContent = unitStatus.age || '--'; document.getElementById('age').textContent = unitStatus.age || '--';
} else { } else {
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400'; document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';