settings overhaul, many QOL improvements

This commit is contained in:
serversdwn
2025-12-09 02:08:00 +00:00
parent 690669c697
commit 6fc8721830
11 changed files with 1188 additions and 348 deletions

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.2.3"
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",

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';