e15481884a
Two related operator-facing improvements after the nav reorg. 1) Events as a top-level sidebar entry. The /sfm page (fleet-wide event database) was demoted to Settings → Developer in the previous reorg. Bringing it back to main nav as "Events" — operators do reach for the cross-project, sortable event list, so it earns a top-level slot. Sidebar now (7 items): Dashboard · Devices · Projects · Events · Tools · Job Planner · Settings Settings → Developer card pointing at /sfm is removed. /sfm page title/subtitle updated from "SFM Event Data" to just "Events". URL unchanged. 2) "Peak PVS" KPI tile becomes "Overall Peak" and excludes false triggers from the calculation. When operators ask "what's the biggest event at this location/unit/ project?" they mean the biggest REAL event, not the biggest sensor glitch. A single mis-flagged false trigger could otherwise dominate the tile (the 14.13 in/s spike at Loc 1 was a prime example). backend/services/sfm_events.py: - _compute_stats() skips false_trigger=True events when computing peak_pvs / peak_pvs_at / peak_pvs_serial. Continues counting them in false_trigger_count so the separate "False Triggers" tile still reflects what got filtered out. last_event unchanged (recency, not magnitude). - Same change automatically propagates to events_for_unit() and vibration_summary_for_project() — both call _compute_stats(). Templates: "Peak PVS" → "Overall Peak" in 3 KPI tile locations (vibration_location_detail.html, partials/projects/vibration_summary .html, unit_detail.html). The physical-quantity name "Peak Vector Sum" in the event-detail modal stays — that's the actual physics term, not a summary stat. Verified end-to-end: Overall Peak renders on real data; peak event false_trigger flag confirmed False. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1500 lines
68 KiB
HTML
1500 lines
68 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}Settings - Seismo Fleet Manager{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="mb-8">
|
|
<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">Configure application preferences and manage fleet data</p>
|
|
</div>
|
|
|
|
<!-- Tab Navigation -->
|
|
<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>
|
|
Roster Management
|
|
</button>
|
|
<button class="settings-tab" data-tab="database" onclick="showTab('database')">
|
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4"></path>
|
|
</svg>
|
|
Database
|
|
</button>
|
|
<button class="settings-tab" data-tab="advanced" onclick="showTab('advanced')">
|
|
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<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>
|
|
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
|
|
<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="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
|
|
</svg>
|
|
Developer
|
|
</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-1">
|
|
<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">
|
|
Download all roster data as CSV for backup or editing externally
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<a href="/api/settings/export-csv"
|
|
class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors inline-flex items-center gap-2">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
</svg>
|
|
Download CSV
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Import Section (Merge Mode Only) -->
|
|
<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 Data (Merge Mode)</h2>
|
|
|
|
<form id="importMergeForm" 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">
|
|
CSV must include column: unit_id (required). Existing units will be updated, new units will be added.
|
|
</p>
|
|
</div>
|
|
|
|
<div id="importMergeResult" class="hidden"></div>
|
|
|
|
<button type="submit" class="px-6 py-3 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
|
Import CSV
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Roster Management Table -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Roster Units</h2>
|
|
<p class="text-gray-600 dark:text-gray-400 text-sm mt-1">
|
|
Manage all units with inline editing and quick actions
|
|
</p>
|
|
</div>
|
|
<button onclick="refreshRosterTable()" class="px-4 py-2 text-seismo-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded-lg transition-colors">
|
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Loading State -->
|
|
<div id="rosterTableLoading" class="text-center py-8">
|
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange"></div>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-2">Loading roster units...</p>
|
|
</div>
|
|
|
|
<!-- Table Container -->
|
|
<div id="rosterTableContainer" class="hidden overflow-x-auto">
|
|
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
|
<thead class="bg-gray-50 dark:bg-gray-700">
|
|
<tr>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Unit ID</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Type</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Status</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Note</th>
|
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Location</th>
|
|
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="rosterTableBody" class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
|
|
<!-- Rows will be inserted here by JavaScript -->
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<!-- Empty State -->
|
|
<div id="rosterTableEmpty" class="hidden text-center py-8">
|
|
<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>
|
|
<p class="text-gray-600 dark:text-gray-400 mt-2">No roster units found</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- CSV Import - Replace Mode -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 border-2 border-yellow-200 dark:border-yellow-800">
|
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4 mb-4">
|
|
<div class="flex">
|
|
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Replace Mode Warning</p>
|
|
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">This will DELETE all existing roster units before importing.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 class="text-xl font-semibold text-yellow-800 dark:text-yellow-300 mb-2">CSV Import - Replace Mode</h2>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
Use this to completely replace your roster with a CSV file. All existing roster data will be deleted first.
|
|
</p>
|
|
|
|
<form id="importReplaceForm" class="space-y-4">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Database Tab -->
|
|
<div id="database-tab" class="tab-content hidden">
|
|
<div class="space-y-6">
|
|
<!-- Database Statistics -->
|
|
<div class="border-2 border-blue-300 dark:border-blue-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
|
<h3 class="font-semibold text-blue-600 dark:text-blue-400 text-lg mb-3">Database Statistics</h3>
|
|
<div id="dbStatsLoading" class="text-center py-4">
|
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-blue-600"></div>
|
|
</div>
|
|
<div id="dbStatsContent" class="hidden">
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
|
<div>
|
|
<p class="text-gray-500 dark:text-gray-400">Database Size</p>
|
|
<p id="dbSize" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-gray-500 dark:text-gray-400">Total Rows</p>
|
|
<p id="dbRows" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-gray-500 dark:text-gray-400">Last Modified</p>
|
|
<p id="dbModified" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
|
</div>
|
|
<div>
|
|
<p class="text-gray-500 dark:text-gray-400">Snapshots</p>
|
|
<p id="dbSnapshotCount" class="text-lg font-semibold text-gray-900 dark:text-white">-</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<button onclick="loadDatabaseStats()" class="mt-4 px-4 py-2 text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-lg transition-colors text-sm">
|
|
Refresh Stats
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Create Snapshot -->
|
|
<div class="border border-green-200 dark:border-green-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<h3 class="font-semibold text-green-600 dark:text-green-400">Create Database Snapshot</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
|
Create a full backup of the current database state
|
|
</p>
|
|
</div>
|
|
<button onclick="createSnapshot()" class="px-4 py-2 bg-green-600 hover:bg-green-700 text-white rounded-lg transition-colors whitespace-nowrap">
|
|
Create Snapshot
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Snapshots List -->
|
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-6 bg-white dark:bg-slate-800">
|
|
<div class="flex justify-between items-center mb-4">
|
|
<h3 class="font-semibold text-gray-900 dark:text-white">Available Snapshots</h3>
|
|
<button onclick="loadSnapshots()" class="px-3 py-1 text-sm text-seismo-orange hover:bg-orange-50 dark:hover:bg-orange-900/20 rounded transition-colors">
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
|
|
<div id="snapshotsLoading" class="text-center py-4">
|
|
<div class="inline-block animate-spin rounded-full h-6 w-6 border-b-2 border-seismo-orange"></div>
|
|
</div>
|
|
|
|
<div id="snapshotsList" class="hidden space-y-2">
|
|
<!-- Snapshots will be inserted here -->
|
|
</div>
|
|
|
|
<div id="snapshotsEmpty" class="hidden text-center py-8 text-gray-500 dark:text-gray-400">
|
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"></path>
|
|
</svg>
|
|
<p class="mt-2">No snapshots found</p>
|
|
<p class="text-sm">Create your first snapshot above</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Upload Snapshot -->
|
|
<div class="border border-purple-200 dark:border-purple-800 rounded-lg p-6 bg-white dark:bg-slate-800">
|
|
<h3 class="font-semibold text-purple-600 dark:text-purple-400 mb-2">Upload Snapshot</h3>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
|
Upload a database snapshot file from another server
|
|
</p>
|
|
<form id="uploadSnapshotForm" class="space-y-3">
|
|
<input type="file" accept=".db" id="snapshotFileInput" class="block w-full text-sm text-gray-900 dark:text-white border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer bg-gray-50 dark:bg-slate-700">
|
|
<button type="submit" class="px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition-colors">
|
|
Upload Snapshot
|
|
</button>
|
|
</form>
|
|
<div id="uploadResult" class="hidden mt-3"></div>
|
|
</div>
|
|
|
|
<!-- Deleted Projects -->
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<div>
|
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Deleted Projects</h2>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Projects that have been soft-deleted. Restore them or permanently remove them.</p>
|
|
</div>
|
|
<button onclick="loadDeletedProjects()" class="px-4 py-2 text-sm text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors flex items-center gap-2">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
Refresh
|
|
</button>
|
|
</div>
|
|
<div id="deletedProjectsList">
|
|
<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Advanced Tab -->
|
|
<div id="advanced-tab" class="tab-content hidden">
|
|
<div class="space-y-6">
|
|
<!-- Warning Banner -->
|
|
<div class="bg-yellow-50 dark:bg-yellow-900/20 border-l-4 border-yellow-500 p-4">
|
|
<div class="flex">
|
|
<svg class="w-5 h-5 text-yellow-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
|
</svg>
|
|
<div>
|
|
<p class="font-semibold text-yellow-800 dark:text-yellow-300">Advanced Settings</p>
|
|
<p class="text-sm text-yellow-700 dark:text-yellow-400 mt-1">These settings can affect system behavior. Change with caution.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 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>
|
|
</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>
|
|
<label for="cal-interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
|
Calibration Interval (days)
|
|
</label>
|
|
<input type="number" id="cal-interval" 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">
|
|
Default interval between calibrations
|
|
</p>
|
|
</div>
|
|
|
|
<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
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear Roster Only -->
|
|
<div class="border border-red-200 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-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)
|
|
</p>
|
|
</div>
|
|
<button onclick="confirmClearRoster()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
|
|
Clear Roster
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear Emitters Only -->
|
|
<div class="border border-red-200 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-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)
|
|
</p>
|
|
</div>
|
|
<button onclick="confirmClearEmitters()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
|
|
Clear Emitters
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Clear Ignored Units -->
|
|
<div class="border border-red-200 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-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
|
|
</p>
|
|
</div>
|
|
<button onclick="confirmClearIgnored()" class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors whitespace-nowrap">
|
|
Clear Ignored
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Developer Tab -->
|
|
<div id="developer-tab" class="tab-content hidden">
|
|
<div class="space-y-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-1">Developer Tools</h2>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
|
|
|
|
<div class="space-y-4">
|
|
<!-- Watcher Manager -->
|
|
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
|
|
<div>
|
|
<div class="font-medium text-gray-900 dark:text-white">Watcher Manager</div>
|
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
|
|
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
|
|
</div>
|
|
</div>
|
|
<a href="/admin/watchers"
|
|
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
|
|
Open
|
|
</a>
|
|
</div>
|
|
|
|
{# SFM Admin moved back to main nav as "Events" — see sidebar. #}
|
|
|
|
{# Metadata Backfill + Project Tidy moved to Tools (they're
|
|
operator workflows, not admin/dev surfaces). Find them
|
|
at /tools. #}
|
|
</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>
|
|
// ========== DELETED PROJECTS ==========
|
|
|
|
async function loadDeletedProjects() {
|
|
const container = document.getElementById('deletedProjectsList');
|
|
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">Loading...</p>';
|
|
try {
|
|
const resp = await fetch('/api/projects/deleted');
|
|
const projects = await resp.json();
|
|
if (!projects.length) {
|
|
container.innerHTML = '<p class="text-sm text-gray-400 dark:text-gray-500 italic">No deleted projects.</p>';
|
|
return;
|
|
}
|
|
container.innerHTML = `
|
|
<div class="divide-y divide-gray-100 dark:divide-gray-700 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
|
${projects.map(p => `
|
|
<div class="flex items-center justify-between px-4 py-3 bg-gray-50 dark:bg-gray-900/30">
|
|
<div>
|
|
<div class="font-medium text-gray-900 dark:text-white">${p.name}</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
|
${p.client_name ? p.client_name + ' · ' : ''}Deleted ${new Date(p.deleted_at).toLocaleDateString()}
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-2 ml-4">
|
|
<button onclick="restoreProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
|
class="px-3 py-1 text-xs bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors">
|
|
Restore
|
|
</button>
|
|
<button onclick="permanentlyDeleteProject('${p.id}', '${p.name.replace(/'/g, "\\'")}')"
|
|
class="px-3 py-1 text-xs bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors">
|
|
Delete Permanently
|
|
</button>
|
|
</div>
|
|
</div>`).join('')}
|
|
</div>`;
|
|
} catch (e) {
|
|
container.innerHTML = '<p class="text-sm text-red-500">Failed to load deleted projects.</p>';
|
|
}
|
|
}
|
|
|
|
async function restoreProject(projectId, name) {
|
|
if (!confirm(`Restore "${name}"?`)) return;
|
|
const resp = await fetch(`/api/projects/${projectId}/restore`, { method: 'POST' });
|
|
if (resp.ok) {
|
|
loadDeletedProjects();
|
|
} else {
|
|
const d = await resp.json();
|
|
alert('Failed to restore: ' + (d.detail || 'Unknown error'));
|
|
}
|
|
}
|
|
|
|
async function permanentlyDeleteProject(projectId, name) {
|
|
if (!confirm(`Permanently delete "${name}" and all its data? This cannot be undone.`)) return;
|
|
const resp = await fetch(`/api/projects/${projectId}/permanent`, { method: 'DELETE' });
|
|
if (resp.ok) {
|
|
loadDeletedProjects();
|
|
} else {
|
|
const d = await resp.json();
|
|
alert('Failed to delete: ' + (d.detail || 'Unknown error'));
|
|
}
|
|
}
|
|
|
|
// ========== TAB MANAGEMENT ==========
|
|
|
|
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 and deleted projects when data tab is shown
|
|
if (tabName === 'data') {
|
|
loadRosterTable();
|
|
loadDeletedProjects();
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
|
|
const formData = new FormData(this);
|
|
const resultDiv = document.getElementById('importMergeResult');
|
|
|
|
try {
|
|
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 {
|
|
const stats = await fetch('/api/settings/stats').then(r => r.json());
|
|
|
|
const confirmMsg = `⚠️ REPLACE MODE WARNING ⚠️
|
|
|
|
This will DELETE all ${stats.roster} roster units first, then import from CSV.
|
|
|
|
THIS CANNOT BE UNDONE!
|
|
|
|
Make sure you have a backup before proceeding.
|
|
|
|
Continue?`;
|
|
|
|
if (!confirm(confirmMsg)) {
|
|
return;
|
|
}
|
|
} catch (error) {
|
|
alert('Error fetching statistics: ' + error.message);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/settings/import-csv-replace', {
|
|
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">Replace Import Successful!</p>
|
|
<ul class="text-sm space-y-1">
|
|
<li>🗑️ Deleted: ${result.deleted}</li>
|
|
<li>✅ Added: ${result.added}</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');
|
|
}
|
|
});
|
|
|
|
// ========== ROSTER MANAGEMENT TABLE ==========
|
|
|
|
async function loadRosterTable() {
|
|
const loading = document.getElementById('rosterTableLoading');
|
|
const container = document.getElementById('rosterTableContainer');
|
|
const empty = document.getElementById('rosterTableEmpty');
|
|
const tbody = document.getElementById('rosterTableBody');
|
|
|
|
try {
|
|
loading.classList.remove('hidden');
|
|
container.classList.add('hidden');
|
|
empty.classList.add('hidden');
|
|
|
|
const response = await fetch('/api/settings/roster-units');
|
|
const units = await response.json();
|
|
|
|
if (units.length === 0) {
|
|
loading.classList.add('hidden');
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
tbody.innerHTML = units.map(unit => createRosterRow(unit)).join('');
|
|
|
|
loading.classList.add('hidden');
|
|
container.classList.remove('hidden');
|
|
} catch (error) {
|
|
loading.classList.add('hidden');
|
|
alert('Error loading roster: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function createRosterRow(unit) {
|
|
const statusBadges = [];
|
|
if (unit.retired) {
|
|
statusBadges.push('<span class="inline-flex items-center px-2 py-1 rounded text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300">Retired</span>');
|
|
}
|
|
|
|
return `
|
|
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors" data-unit-id="${unit.id}">
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<a href="/unit/${unit.id}" class="text-sm font-medium text-seismo-orange hover:text-orange-600 dark:hover:text-orange-400">
|
|
${unit.id}
|
|
</a>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap">
|
|
<div class="text-sm text-gray-900 dark:text-white">${unit.device_type}</div>
|
|
<div class="text-xs text-gray-500 dark:text-gray-400">${unit.unit_type}</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="flex flex-col gap-2">
|
|
<div class="flex gap-3">
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="deployed-${unit.id}" value="true"
|
|
${unit.deployed ? 'checked' : ''}
|
|
onchange="toggleDeployed('${unit.id}', true)"
|
|
class="w-4 h-4 text-green-600 focus:ring-green-500">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
|
</label>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input type="radio" name="deployed-${unit.id}" value="false"
|
|
${!unit.deployed ? 'checked' : ''}
|
|
onchange="toggleDeployed('${unit.id}', false)"
|
|
class="w-4 h-4 text-gray-600 focus:ring-gray-500">
|
|
<span class="text-sm text-gray-700 dark:text-gray-300">Benched</span>
|
|
</label>
|
|
</div>
|
|
${statusBadges.length > 0 ? '<div class="flex flex-wrap gap-1">' + statusBadges.join('') + '</div>' : ''}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate" title="${unit.note}">
|
|
${unit.note || '<span class="text-gray-400 italic">No note</span>'}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3">
|
|
<div class="text-sm text-gray-600 dark:text-gray-400 max-w-xs truncate" title="${unit.location}">
|
|
${unit.location || '<span class="text-gray-400 italic">—</span>'}
|
|
</div>
|
|
</td>
|
|
<td class="px-4 py-3 whitespace-nowrap text-right text-sm">
|
|
<div class="flex justify-end gap-1">
|
|
<button onclick="toggleRetired('${unit.id}', ${unit.retired})"
|
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors"
|
|
title="${unit.retired ? 'Unretire Unit' : 'Retire Unit'}">
|
|
<svg class="w-4 h-4 ${unit.retired ? 'text-purple-600 dark:text-purple-400' : 'text-gray-400'}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"></path>
|
|
</svg>
|
|
</button>
|
|
<button onclick="editUnit('${unit.id}')"
|
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors text-blue-600 dark:text-blue-400"
|
|
title="Edit Unit">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
|
|
</svg>
|
|
</button>
|
|
<button onclick="confirmDeleteUnit('${unit.id}')"
|
|
class="p-2 hover:bg-gray-100 dark:hover:bg-gray-600 rounded transition-colors text-red-600 dark:text-red-400"
|
|
title="Delete Unit">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
async function toggleDeployed(unitId, newState) {
|
|
try {
|
|
const response = await fetch(`/api/roster/set-deployed/${unitId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `deployed=${newState}`
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadRosterTable();
|
|
} else {
|
|
const result = await response.json();
|
|
alert('Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function toggleRetired(unitId, currentState) {
|
|
try {
|
|
const response = await fetch(`/api/roster/set-retired/${unitId}`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
body: `retired=${!currentState}`
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadRosterTable();
|
|
} else {
|
|
const result = await response.json();
|
|
alert('Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function editUnit(unitId) {
|
|
window.location.href = `/roster?edit=${unitId}`;
|
|
}
|
|
|
|
async function confirmDeleteUnit(unitId) {
|
|
if (!confirm(`Delete unit ${unitId}?\n\nThis will permanently remove the unit from the roster.\n\nContinue?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/roster/${unitId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
await loadRosterTable();
|
|
alert(`✅ Unit ${unitId} deleted successfully`);
|
|
} else {
|
|
const result = await response.json();
|
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('❌ Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function refreshRosterTable() {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ========== DATABASE MANAGEMENT ==========
|
|
|
|
async function loadDatabaseStats() {
|
|
const loading = document.getElementById('dbStatsLoading');
|
|
const content = document.getElementById('dbStatsContent');
|
|
|
|
try {
|
|
loading.classList.remove('hidden');
|
|
content.classList.add('hidden');
|
|
|
|
const response = await fetch('/api/settings/database/stats');
|
|
const stats = await response.json();
|
|
|
|
// Update stats display
|
|
document.getElementById('dbSize').textContent = stats.size_mb + ' MB';
|
|
document.getElementById('dbRows').textContent = stats.total_rows.toLocaleString();
|
|
|
|
const lastMod = new Date(stats.last_modified);
|
|
document.getElementById('dbModified').textContent = lastMod.toLocaleDateString();
|
|
|
|
// Load snapshot count
|
|
const snapshotsResp = await fetch('/api/settings/database/snapshots');
|
|
const snapshotsData = await snapshotsResp.json();
|
|
document.getElementById('dbSnapshotCount').textContent = snapshotsData.count;
|
|
|
|
loading.classList.add('hidden');
|
|
content.classList.remove('hidden');
|
|
} catch (error) {
|
|
loading.classList.add('hidden');
|
|
alert('Error loading database stats: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function createSnapshot() {
|
|
const description = prompt('Enter a description for this snapshot (optional):');
|
|
|
|
try {
|
|
const response = await fetch('/api/settings/database/snapshot', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ description: description || null })
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert(`✅ Snapshot created successfully!\n\nFilename: ${result.snapshot.filename}\nSize: ${result.snapshot.size_mb} MB`);
|
|
loadSnapshots();
|
|
loadDatabaseStats();
|
|
} else {
|
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('❌ Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function loadSnapshots() {
|
|
const loading = document.getElementById('snapshotsLoading');
|
|
const list = document.getElementById('snapshotsList');
|
|
const empty = document.getElementById('snapshotsEmpty');
|
|
|
|
try {
|
|
loading.classList.remove('hidden');
|
|
list.classList.add('hidden');
|
|
empty.classList.add('hidden');
|
|
|
|
const response = await fetch('/api/settings/database/snapshots');
|
|
const data = await response.json();
|
|
|
|
if (data.snapshots.length === 0) {
|
|
loading.classList.add('hidden');
|
|
empty.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = data.snapshots.map(snapshot => createSnapshotCard(snapshot)).join('');
|
|
|
|
loading.classList.add('hidden');
|
|
list.classList.remove('hidden');
|
|
} catch (error) {
|
|
loading.classList.add('hidden');
|
|
alert('Error loading snapshots: ' + error.message);
|
|
}
|
|
}
|
|
|
|
function createSnapshotCard(snapshot) {
|
|
const createdDate = new Date(snapshot.created_at_iso);
|
|
const dateStr = createdDate.toLocaleString();
|
|
|
|
return `
|
|
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-gray-700/50">
|
|
<div class="flex justify-between items-start">
|
|
<div class="flex-1">
|
|
<div class="flex items-center gap-2">
|
|
<h4 class="font-medium text-gray-900 dark:text-white">${snapshot.filename}</h4>
|
|
<span class="text-xs px-2 py-1 rounded ${snapshot.type === 'manual' ? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300' : 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300'}">
|
|
${snapshot.type}
|
|
</span>
|
|
</div>
|
|
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">${snapshot.description}</p>
|
|
<div class="flex gap-4 mt-2 text-xs text-gray-500 dark:text-gray-400">
|
|
<span>📅 ${dateStr}</span>
|
|
<span>💾 ${snapshot.size_mb} MB</span>
|
|
</div>
|
|
</div>
|
|
<div class="flex gap-2 ml-4">
|
|
<button onclick="downloadSnapshot('${snapshot.filename}')"
|
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-blue-600 dark:text-blue-400"
|
|
title="Download">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
|
</svg>
|
|
</button>
|
|
<button onclick="restoreSnapshot('${snapshot.filename}')"
|
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-green-600 dark:text-green-400"
|
|
title="Restore">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
|
</svg>
|
|
</button>
|
|
<button onclick="deleteSnapshot('${snapshot.filename}')"
|
|
class="p-2 hover:bg-gray-200 dark:hover:bg-gray-600 rounded transition-colors text-red-600 dark:text-red-400"
|
|
title="Delete">
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function downloadSnapshot(filename) {
|
|
window.location.href = `/api/settings/database/snapshot/${filename}`;
|
|
}
|
|
|
|
async function restoreSnapshot(filename) {
|
|
const confirmMsg = `⚠️ RESTORE DATABASE WARNING ⚠️
|
|
|
|
This will REPLACE the current database with snapshot:
|
|
${filename}
|
|
|
|
A backup of the current database will be created automatically before restoring.
|
|
|
|
THIS ACTION WILL RESTART THE APPLICATION!
|
|
|
|
Continue?`;
|
|
|
|
if (!confirm(confirmMsg)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/api/settings/database/restore', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
filename: filename,
|
|
create_backup: true
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert(`✅ Database restored successfully!\n\nRestored from: ${result.restored_from}\nBackup created: ${result.backup_created}\n\nThe page will now reload.`);
|
|
location.reload();
|
|
} else {
|
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('❌ Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function deleteSnapshot(filename) {
|
|
if (!confirm(`Delete snapshot ${filename}?\n\nThis cannot be undone.`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/settings/database/snapshot/${filename}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
alert(`✅ Snapshot deleted: ${filename}`);
|
|
loadSnapshots();
|
|
loadDatabaseStats();
|
|
} else {
|
|
alert('❌ Error: ' + (result.detail || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
alert('❌ Error: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// Upload snapshot form handler
|
|
document.getElementById('uploadSnapshotForm').addEventListener('submit', async function(e) {
|
|
e.preventDefault();
|
|
|
|
const fileInput = document.getElementById('snapshotFileInput');
|
|
const resultDiv = document.getElementById('uploadResult');
|
|
|
|
if (!fileInput.files[0]) {
|
|
alert('Please select a file');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', fileInput.files[0]);
|
|
|
|
try {
|
|
const response = await fetch('/api/settings/database/upload-snapshot', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (response.ok) {
|
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
|
resultDiv.innerHTML = `✅ Uploaded: ${result.snapshot.filename} (${result.snapshot.size_mb} MB)`;
|
|
resultDiv.classList.remove('hidden');
|
|
|
|
fileInput.value = '';
|
|
loadSnapshots();
|
|
loadDatabaseStats();
|
|
|
|
setTimeout(() => {
|
|
resultDiv.classList.add('hidden');
|
|
}, 5000);
|
|
} else {
|
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
|
resultDiv.innerHTML = `❌ Error: ${result.detail || 'Unknown error'}`;
|
|
resultDiv.classList.remove('hidden');
|
|
}
|
|
} catch (error) {
|
|
resultDiv.className = 'mt-3 p-3 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
|
resultDiv.innerHTML = `❌ Error: ${error.message}`;
|
|
resultDiv.classList.remove('hidden');
|
|
}
|
|
});
|
|
|
|
// Load database stats and snapshots when database tab is shown
|
|
const originalShowTab = showTab;
|
|
showTab = function(tabName) {
|
|
originalShowTab(tabName);
|
|
if (tabName === 'database') {
|
|
loadDatabaseStats();
|
|
loadSnapshots();
|
|
}
|
|
};
|
|
</script>
|
|
{% endblock %}
|