Update main to 0.3.0

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

View File

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

View File

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

View File

@@ -18,7 +18,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.2.2"
VERSION = "0.3.0"
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
@@ -258,9 +258,9 @@ async def unknown_emitters_partial(request: Request):
def health_check():
"""Health check endpoint"""
return {
"message": "Seismo Fleet Manager v0.1.1",
"message": f"Seismo Fleet Manager v{VERSION}",
"status": "running",
"version": "0.1.1"
"version": VERSION
}

View File

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

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
from datetime import datetime
from backend.database import Base
@@ -56,4 +56,24 @@ class IgnoredUnit(Base):
id = Column(String, primary_key=True, index=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 sqlalchemy.orm import Session
from datetime import datetime, date
from pydantic import BaseModel
from typing import Optional
import csv
import io
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"])
@@ -239,3 +241,87 @@ def clear_ignored(db: Session = Depends(get_db)):
except Exception as e:
db.rollback()
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') {
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>
{% block extra_scripts %}{% endblock %}

View File

@@ -10,9 +10,15 @@
</div>
{% endif %}
<div class="mb-8">
<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>
<div class="mb-8 flex justify-between items-center">
<div>
<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>
</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 -->
@@ -48,23 +54,35 @@
</div>
<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>
<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">
<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>
</div>
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
</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">
<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>
</div>
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
</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">
<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>
</div>
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
@@ -185,6 +203,17 @@ function updateDashboard(event) {
try {
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 =====
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;

View File

@@ -46,5 +46,11 @@
{% endfor %}
</div>
{% 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 %}

View File

@@ -46,5 +46,11 @@
{% endfor %}
</div>
{% 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 %}

View File

@@ -34,6 +34,21 @@
<!-- Fleet Roster with Tabs -->
<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 -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
@@ -805,6 +820,31 @@
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>
<style>

File diff suppressed because it is too large Load Diff

View File

@@ -4,14 +4,44 @@
{% block content %}
<div class="mb-6">
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Back to Fleet Roster
</a>
<!-- Breadcrumb Navigation -->
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
<ol class="inline-flex items-center space-x-1 md:space-x-3">
<li class="inline-flex items-center">
<a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
Dashboard
</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">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
<div class="flex items-center gap-2">
<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">
<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">
@@ -323,8 +353,10 @@ async function loadUnitData() {
// Populate view mode (read-only display)
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('breadcrumb-unit').textContent = currentUnit.id;
// Get status info from snapshot
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('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
// 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('age').textContent = unitStatus.age || '--';
} else {
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';