Update main to 0.3.0
Update main to 0.3.0
This commit is contained in:
60
CHANGELOG.md
60
CHANGELOG.md
@@ -5,6 +5,65 @@ All notable changes to Seismo Fleet Manager will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.3.0] - 2025-12-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Series 4 (Micromate) Support**: New `/api/series4/heartbeat` endpoint for receiving telemetry from Series 4 Micromate units
|
||||||
|
- Auto-detection of Series 4 units via UM##### ID pattern
|
||||||
|
- Stores project hints from emitter payload in unit notes
|
||||||
|
- Automatic unit type classification across both Series 3 and Series 4 endpoints
|
||||||
|
- **Development Environment Labels**: Visual indicators to distinguish dev from production deployments
|
||||||
|
- Yellow "DEV" badge in sidebar navigation
|
||||||
|
- "[DEV]" prefix in browser title
|
||||||
|
- Yellow banner on dashboard when running in development mode
|
||||||
|
- Environment variable support in docker-compose.yml (ENVIRONMENT=production|development)
|
||||||
|
- **Quality of Life Improvements**:
|
||||||
|
- Human-readable relative timestamps (e.g., "2h 15m ago", "3d ago") with full date in tooltips
|
||||||
|
- "Last Updated" timestamp indicator on dashboard
|
||||||
|
- Status icons for colorblind accessibility (checkmark for OK, clock for Pending, X for Missing)
|
||||||
|
- Breadcrumb navigation on unit detail pages
|
||||||
|
- Copy-to-clipboard buttons for unit IDs
|
||||||
|
- Search/filter functionality for fleet roster table
|
||||||
|
- Improved empty state messages with icons
|
||||||
|
- **Timezone Support**: Comprehensive timezone handling across the application
|
||||||
|
- Timezone selector in Settings (defaults to America/New_York EST)
|
||||||
|
- Human-readable timestamp format (e.g., "9/10/2020 8:00 AM EST")
|
||||||
|
- Timezone-aware display for all timestamps site-wide
|
||||||
|
- Settings stored in localStorage for immediate effect
|
||||||
|
- **Settings Page Redesign**: Complete overhaul with tabbed interface and persistent preferences
|
||||||
|
- **General Tab**: Display preferences (timezone, theme, auto-refresh interval)
|
||||||
|
- **Data Management Tab**: Safe operations (CSV export, merge import, roster table)
|
||||||
|
- **Advanced Tab**: Power user settings (replace mode import, calibration defaults, status thresholds)
|
||||||
|
- **Danger Zone Tab**: Destructive operations isolated with enhanced warnings
|
||||||
|
- Backend preferences storage via new UserPreferences model
|
||||||
|
- Tab state persistence in localStorage
|
||||||
|
- Smooth animations and consistent styling with existing pages
|
||||||
|
- **User Preferences API**: New backend endpoints for persistent settings storage
|
||||||
|
- `GET /api/settings/preferences` - Retrieve all user preferences
|
||||||
|
- `PUT /api/settings/preferences` - Update preferences (supports partial updates)
|
||||||
|
- Database-backed storage for cross-device preference sync
|
||||||
|
- Migration script: `backend/migrate_add_user_preferences.py`
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Timestamps now display in user-selected timezone with human-readable format throughout the application
|
||||||
|
- Settings page reorganized from 644-line flat layout to clean 4-tab interface
|
||||||
|
- CSV Replace Mode moved from Data Management to Advanced tab with additional warnings
|
||||||
|
- Import operations separated: safe merge in Data Management tab, destructive replace in Advanced tab
|
||||||
|
- Page title changed from "Roster Manager" to "Settings" for better clarity
|
||||||
|
- All preferences now persist to backend database instead of relying solely on localStorage
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Unit type classification now consistent across Series 3 and Series 4 heartbeat endpoints
|
||||||
|
- Auto-correction of misclassified unit types when they report to wrong endpoint
|
||||||
|
|
||||||
|
### Technical Details
|
||||||
|
- New `detect_unit_type()` helper function for pattern-based unit classification
|
||||||
|
- UserPreferences model with single-row table pattern (id=1) for global settings
|
||||||
|
- Series 4 units identified by UM prefix followed by digits (e.g., UM11719)
|
||||||
|
- JavaScript Intl API used for client-side timezone conversion
|
||||||
|
- Pydantic schema for partial preference updates (PreferencesUpdate model)
|
||||||
|
- Environment context injection via custom FastAPI template response wrapper
|
||||||
|
|
||||||
## [0.2.1] - 2025-12-03
|
## [0.2.1] - 2025-12-03
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -92,6 +151,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
- Photo management per unit
|
- Photo management per unit
|
||||||
- Automated status categorization (OK/Pending/Missing)
|
- Automated status categorization (OK/Pending/Missing)
|
||||||
|
|
||||||
|
[0.3.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.1...v0.3.0
|
||||||
[0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1
|
[0.2.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.2.0...v0.2.1
|
||||||
[0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0
|
[0.2.0]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.1...v0.2.0
|
||||||
[0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1
|
[0.1.1]: https://github.com/serversdwn/seismo-fleet-manager/compare/v0.1.0...v0.1.1
|
||||||
|
|||||||
79
README.md
79
README.md
@@ -1,4 +1,4 @@
|
|||||||
# Seismo Fleet Manager v0.2.1
|
# Seismo Fleet Manager v0.3.0
|
||||||
|
|
||||||
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
|
||||||
|
|
||||||
@@ -100,15 +100,27 @@ cp /tmp/sfm_test.db data/seismo_fleet.db
|
|||||||
|
|
||||||
The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately.
|
The helper script creates a modem/seismograph mix so you can exercise the dashboard, roster tabs, and unit detail screens immediately.
|
||||||
|
|
||||||
## Upgrading from v0.1.x
|
## Upgrading from Previous Versions
|
||||||
|
|
||||||
Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file before starting the app:
|
### From v0.2.x to v0.3.0
|
||||||
|
|
||||||
|
Version 0.3.0 introduces user preferences storage. Run the migration once per database file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python backend/migrate_add_user_preferences.py
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates the `user_preferences` table for persistent settings storage (timezone, theme, auto-refresh interval, calibration defaults, status thresholds).
|
||||||
|
|
||||||
|
### From v0.1.x to v0.2.x or later
|
||||||
|
|
||||||
|
Versions ≥0.2 introduce new roster columns (device_type, calibration dates, modem metadata, addresses, etc.). Run the migration once per database file:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python backend/migrate_add_device_types.py
|
python backend/migrate_add_device_types.py
|
||||||
```
|
```
|
||||||
|
|
||||||
The script is idempotent—if the new columns already exist it simply exits.
|
Both migration scripts are idempotent—if the columns/tables already exist, they simply exit.
|
||||||
|
|
||||||
## API Endpoints
|
## API Endpoints
|
||||||
|
|
||||||
@@ -156,6 +168,8 @@ The script is idempotent—if the new columns already exist it simply exits.
|
|||||||
- **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables
|
- **GET** `/api/settings/stats` - Counts for roster, emitters, and ignored tables
|
||||||
- **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid
|
- **GET** `/api/settings/roster-units` - Raw roster dump for the settings data grid
|
||||||
- **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction
|
- **POST** `/api/settings/import-csv-replace` - Replace the entire roster in one atomic transaction
|
||||||
|
- **GET** `/api/settings/preferences` - Get user preferences (timezone, theme, calibration defaults, etc.)
|
||||||
|
- **PUT** `/api/settings/preferences` - Update user preferences (supports partial updates)
|
||||||
- **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables
|
- **POST** `/api/settings/clear-all` - Danger-zone action that wipes roster, emitters, and ignored tables
|
||||||
- **POST** `/api/settings/clear-roster` - Delete only roster entries
|
- **POST** `/api/settings/clear-roster` - Delete only roster entries
|
||||||
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
|
- **POST** `/api/settings/clear-emitters` - Delete auto-discovered emitters
|
||||||
@@ -182,7 +196,8 @@ See [sample_roster.csv](sample_roster.csv) for a minimal working example.
|
|||||||
|
|
||||||
### Emitter Reporting
|
### Emitter Reporting
|
||||||
- **POST** `/emitters/report` - Submit status report from a seismograph unit
|
- **POST** `/emitters/report` - Submit status report from a seismograph unit
|
||||||
- **POST** `/api/series3/heartbeat` - 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)
|
- **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy)
|
||||||
|
|
||||||
### Photo Management
|
### Photo Management
|
||||||
@@ -314,6 +329,22 @@ print(response.json())
|
|||||||
| reason | string | Optional context for ignoring |
|
| reason | string | Optional context for ignoring |
|
||||||
| ignored_at | datetime | When the ignore action occurred |
|
| ignored_at | datetime | When the ignore action occurred |
|
||||||
|
|
||||||
|
### UserPreferences Table (Settings Storage)
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| id | integer | Always 1 (single-row table) |
|
||||||
|
| timezone | string | Display timezone (default: America/New_York) |
|
||||||
|
| theme | string | UI theme: auto, light, or dark |
|
||||||
|
| auto_refresh_interval | integer | Dashboard refresh interval in seconds |
|
||||||
|
| date_format | string | Date format preference |
|
||||||
|
| table_rows_per_page | integer | Default pagination size |
|
||||||
|
| calibration_interval_days | integer | Default days between calibrations |
|
||||||
|
| calibration_warning_days | integer | Warning threshold before calibration due |
|
||||||
|
| status_ok_threshold_hours | integer | Hours for OK status threshold |
|
||||||
|
| status_pending_threshold_hours | integer | Hours for Pending status threshold |
|
||||||
|
| updated_at | datetime | Last preference update timestamp |
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -321,8 +352,8 @@ seismo-fleet-manager/
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ ├── main.py # FastAPI app entry point
|
│ ├── main.py # FastAPI app entry point
|
||||||
│ ├── database.py # SQLAlchemy database configuration
|
│ ├── database.py # SQLAlchemy database configuration
|
||||||
│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit)
|
│ ├── models.py # Database models (RosterUnit, Emitter, IgnoredUnit, UserPreferences)
|
||||||
│ ├── routes.py # Legacy API endpoints
|
│ ├── routes.py # Legacy API endpoints + Series 3/4 heartbeat endpoints
|
||||||
│ ├── routers/ # Modular API routers
|
│ ├── routers/ # Modular API routers
|
||||||
│ │ ├── roster.py # Fleet status endpoints
|
│ │ ├── roster.py # Fleet status endpoints
|
||||||
│ │ ├── roster_edit.py # Roster management & CSV import
|
│ │ ├── roster_edit.py # Roster management & CSV import
|
||||||
@@ -330,10 +361,11 @@ seismo-fleet-manager/
|
|||||||
│ │ ├── photos.py # Photo management
|
│ │ ├── photos.py # Photo management
|
||||||
│ │ ├── dashboard.py # Dashboard partials
|
│ │ ├── dashboard.py # Dashboard partials
|
||||||
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
│ │ ├── dashboard_tabs.py # Dashboard tab endpoints
|
||||||
│ │ └── settings.py # Roster manager/data operations
|
│ │ └── settings.py # Settings, preferences, and data management
|
||||||
│ ├── services/
|
│ ├── services/
|
||||||
│ │ └── snapshot.py # Fleet status snapshot logic
|
│ │ └── snapshot.py # Fleet status snapshot logic
|
||||||
│ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema
|
│ ├── migrate_add_device_types.py # SQLite migration for v0.2 schema
|
||||||
|
│ ├── migrate_add_user_preferences.py # SQLite migration for v0.3 schema
|
||||||
│ └── static/ # Static assets (CSS, etc.)
|
│ └── static/ # Static assets (CSS, etc.)
|
||||||
├── create_test_db.py # Generate a sample SQLite DB with mixed devices
|
├── create_test_db.py # Generate a sample SQLite DB with mixed devices
|
||||||
├── templates/ # Jinja2 HTML templates
|
├── templates/ # Jinja2 HTML templates
|
||||||
@@ -400,24 +432,31 @@ docker compose down -v
|
|||||||
|
|
||||||
## Release Highlights
|
## Release Highlights
|
||||||
|
|
||||||
|
### v0.3.0 — 2025-12-09
|
||||||
|
- **Series 4 Support**: New `/api/series4/heartbeat` endpoint with auto-detection for Micromate units (UM##### pattern)
|
||||||
|
- **Settings Redesign**: Completely redesigned Settings page with 4-tab interface (General, Data Management, Advanced, Danger Zone)
|
||||||
|
- **User Preferences**: Backend storage for timezone, theme, auto-refresh interval, calibration defaults, and status thresholds
|
||||||
|
- **Development Labels**: Visual indicators to distinguish dev from production environments
|
||||||
|
- **Timezone Support**: Comprehensive timezone handling with human-readable timestamps site-wide
|
||||||
|
- **Quality of Life**: Relative timestamps, status icons for accessibility, breadcrumb navigation, copy-to-clipboard, search functionality
|
||||||
|
|
||||||
### v0.2.1 — 2025-12-03
|
### v0.2.1 — 2025-12-03
|
||||||
- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions.
|
- Added the `/settings` roster manager with CSV export/import, live stats, and danger-zone table reset actions
|
||||||
- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions (edit, deploy toggle, ignore, delete).
|
- Deployed/Benched/Retired/Ignored tabs now have dedicated HTMX partials, sorting, and inline actions
|
||||||
- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields) with a refreshed editing experience.
|
- Unit detail pages expose device-type specific metadata (calibration windows, modem pairing, IP/phone fields)
|
||||||
- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data for mapping widgets.
|
- Snapshot summary and dashboard counts now focus on deployed units and include address/coordinate data
|
||||||
|
|
||||||
### v0.2.0 — 2025-12-03
|
### v0.2.0 — 2025-12-03
|
||||||
- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper for new installs.
|
- Introduced device-type aware roster schema (seismograph vs modem) plus migration + `create_test_db.py` helper
|
||||||
- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster.
|
- Added Ignore list model/endpoints to quarantine noisy emitters directly from the roster
|
||||||
- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts.
|
- Roster page gained Add Unit + CSV Import modals, HTMX-driven updates, and unknown emitter callouts
|
||||||
- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata for the dashboard and roster tabs.
|
- Snapshot service now returns active/benched/retired/unknown buckets containing richer metadata
|
||||||
|
|
||||||
### v0.1.1 — 2025-12-02
|
### v0.1.1 — 2025-12-02
|
||||||
- **Roster Editing API**: Full CRUD operations for managing your fleet roster
|
- **Roster Editing API**: Full CRUD operations for managing your fleet roster
|
||||||
- **CSV Import**: Bulk upload roster data from CSV files
|
- **CSV Import**: Bulk upload roster data from CSV files
|
||||||
- **Enhanced Data Model**: Added project_id and location fields to roster
|
- **Enhanced Data Model**: Added project_id and location fields to roster
|
||||||
- **Bug Fixes**: Improved database session management and error handling
|
- **Bug Fixes**: Improved database session management and error handling
|
||||||
- **Dashboard Improvements**: Separate views for Active, Benched, and Retired units
|
|
||||||
|
|
||||||
See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
|
See [CHANGELOG.md](CHANGELOG.md) for the full release notes.
|
||||||
|
|
||||||
@@ -437,9 +476,11 @@ MIT
|
|||||||
|
|
||||||
## Version
|
## Version
|
||||||
|
|
||||||
**Current: 0.2.1** — Settings & roster manager refresh (2025-12-03)
|
**Current: 0.3.0** — Series 4 support, settings redesign, user preferences (2025-12-09)
|
||||||
|
|
||||||
Previous: 0.2.0 — Device-type aware roster + ignore list (2025-12-03)
|
Previous: 0.2.1 — Settings & roster manager refresh (2025-12-03)
|
||||||
|
|
||||||
|
0.2.0 — Device-type aware roster + ignore list (2025-12-03)
|
||||||
|
|
||||||
0.1.1 — Roster Management & CSV Import (2025-12-02)
|
0.1.1 — Roster Management & CSV Import (2025-12-02)
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ Base.metadata.create_all(bind=engine)
|
|||||||
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
VERSION = "0.2.2"
|
VERSION = "0.3.0"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
@@ -258,9 +258,9 @@ async def unknown_emitters_partial(request: Request):
|
|||||||
def health_check():
|
def health_check():
|
||||||
"""Health check endpoint"""
|
"""Health check endpoint"""
|
||||||
return {
|
return {
|
||||||
"message": "Seismo Fleet Manager v0.1.1",
|
"message": f"Seismo Fleet Manager v{VERSION}",
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"version": "0.1.1"
|
"version": VERSION
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
80
backend/migrate_add_user_preferences.py
Normal file
80
backend/migrate_add_user_preferences.py
Normal 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()
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date
|
from sqlalchemy import Column, String, DateTime, Boolean, Text, Date, Integer
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from backend.database import Base
|
from backend.database import Base
|
||||||
|
|
||||||
@@ -57,3 +57,23 @@ class IgnoredUnit(Base):
|
|||||||
id = Column(String, primary_key=True, index=True)
|
id = Column(String, primary_key=True, index=True)
|
||||||
reason = Column(String, nullable=True)
|
reason = Column(String, nullable=True)
|
||||||
ignored_at = Column(DateTime, default=datetime.utcnow)
|
ignored_at = Column(DateTime, default=datetime.utcnow)
|
||||||
|
|
||||||
|
|
||||||
|
class UserPreferences(Base):
|
||||||
|
"""
|
||||||
|
User preferences: persistent storage for application settings.
|
||||||
|
Single-row table (id=1) to store global user preferences.
|
||||||
|
"""
|
||||||
|
__tablename__ = "user_preferences"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, default=1)
|
||||||
|
timezone = Column(String, default="America/New_York")
|
||||||
|
theme = Column(String, default="auto") # auto, light, dark
|
||||||
|
auto_refresh_interval = Column(Integer, default=10) # seconds
|
||||||
|
date_format = Column(String, default="MM/DD/YYYY")
|
||||||
|
table_rows_per_page = Column(Integer, default=25)
|
||||||
|
calibration_interval_days = Column(Integer, default=365)
|
||||||
|
calibration_warning_days = Column(Integer, default=30)
|
||||||
|
status_ok_threshold_hours = Column(Integer, default=12)
|
||||||
|
status_pending_threshold_hours = Column(Integer, default=24)
|
||||||
|
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
@@ -2,11 +2,13 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
import csv
|
import csv
|
||||||
import io
|
import io
|
||||||
|
|
||||||
from backend.database import get_db
|
from backend.database import get_db
|
||||||
from backend.models import RosterUnit, Emitter, IgnoredUnit
|
from backend.models import RosterUnit, Emitter, IgnoredUnit, UserPreferences
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -239,3 +241,87 @@ def clear_ignored(db: Session = Depends(get_db)):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
db.rollback()
|
db.rollback()
|
||||||
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
raise HTTPException(status_code=500, detail=f"Clear failed: {str(e)}")
|
||||||
|
|
||||||
|
|
||||||
|
# User Preferences Endpoints
|
||||||
|
|
||||||
|
class PreferencesUpdate(BaseModel):
|
||||||
|
"""Schema for updating user preferences (all fields optional)"""
|
||||||
|
timezone: Optional[str] = None
|
||||||
|
theme: Optional[str] = None
|
||||||
|
auto_refresh_interval: Optional[int] = None
|
||||||
|
date_format: Optional[str] = None
|
||||||
|
table_rows_per_page: Optional[int] = None
|
||||||
|
calibration_interval_days: Optional[int] = None
|
||||||
|
calibration_warning_days: Optional[int] = None
|
||||||
|
status_ok_threshold_hours: Optional[int] = None
|
||||||
|
status_pending_threshold_hours: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/preferences")
|
||||||
|
def get_preferences(db: Session = Depends(get_db)):
|
||||||
|
"""
|
||||||
|
Get user preferences. Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/preferences")
|
||||||
|
def update_preferences(
|
||||||
|
updates: PreferencesUpdate,
|
||||||
|
db: Session = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Update user preferences. Accepts partial updates.
|
||||||
|
Creates default preferences if none exist.
|
||||||
|
"""
|
||||||
|
prefs = db.query(UserPreferences).filter(UserPreferences.id == 1).first()
|
||||||
|
|
||||||
|
if not prefs:
|
||||||
|
# Create default preferences
|
||||||
|
prefs = UserPreferences(id=1)
|
||||||
|
db.add(prefs)
|
||||||
|
|
||||||
|
# Update only provided fields
|
||||||
|
update_data = updates.dict(exclude_unset=True)
|
||||||
|
for field, value in update_data.items():
|
||||||
|
setattr(prefs, field, value)
|
||||||
|
|
||||||
|
prefs.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(prefs)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"message": "Preferences updated successfully",
|
||||||
|
"timezone": prefs.timezone,
|
||||||
|
"theme": prefs.theme,
|
||||||
|
"auto_refresh_interval": prefs.auto_refresh_interval,
|
||||||
|
"date_format": prefs.date_format,
|
||||||
|
"table_rows_per_page": prefs.table_rows_per_page,
|
||||||
|
"calibration_interval_days": prefs.calibration_interval_days,
|
||||||
|
"calibration_warning_days": prefs.calibration_warning_days,
|
||||||
|
"status_ok_threshold_hours": prefs.status_ok_threshold_hours,
|
||||||
|
"status_pending_threshold_hours": prefs.status_pending_threshold_hours,
|
||||||
|
"updated_at": prefs.updated_at.isoformat() if prefs.updated_at else None
|
||||||
|
}
|
||||||
|
|||||||
@@ -149,6 +149,116 @@
|
|||||||
} else if (localStorage.getItem('theme') === 'dark') {
|
} else if (localStorage.getItem('theme') === 'dark') {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function: Convert timestamp to relative time
|
||||||
|
function timeAgo(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now - date) / 1000);
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) {
|
||||||
|
const remainingMins = minutes % 60;
|
||||||
|
return remainingMins > 0 ? `${hours}h ${remainingMins}m ago` : `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 7) {
|
||||||
|
const remainingHours = hours % 24;
|
||||||
|
return remainingHours > 0 ? `${days}d ${remainingHours}h ago` : `${days}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = Math.floor(days / 7);
|
||||||
|
if (weeks < 4) {
|
||||||
|
const remainingDays = days % 7;
|
||||||
|
return remainingDays > 0 ? `${weeks}w ${remainingDays}d ago` : `${weeks}w ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
return `${months}mo ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Get user's selected timezone
|
||||||
|
function getTimezone() {
|
||||||
|
return localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp with tooltip (timezone-aware)
|
||||||
|
function formatTimestamp(dateString) {
|
||||||
|
if (!dateString) return '<span class="text-gray-400">Never</span>';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
const fullDate = date.toLocaleString('en-US', {
|
||||||
|
weekday: 'short',
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
|
return `<span title="${fullDate}" class="cursor-help">${timeAgo(dateString)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function: Format timestamp as full date/time (no relative time)
|
||||||
|
// Format: "9/10/2020 8:00 AM EST"
|
||||||
|
function formatFullTimestamp(dateString) {
|
||||||
|
if (!dateString) return 'Never';
|
||||||
|
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const timezone = getTimezone();
|
||||||
|
|
||||||
|
return date.toLocaleString('en-US', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all timestamps on page load and periodically
|
||||||
|
function updateAllTimestamps() {
|
||||||
|
document.querySelectorAll('[data-timestamp]').forEach(el => {
|
||||||
|
const timestamp = el.getAttribute('data-timestamp');
|
||||||
|
el.innerHTML = formatTimestamp(timestamp);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run on load and every minute
|
||||||
|
updateAllTimestamps();
|
||||||
|
setInterval(updateAllTimestamps, 60000);
|
||||||
|
|
||||||
|
// Copy to clipboard helper
|
||||||
|
function copyToClipboard(text, button) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Visual feedback
|
||||||
|
const originalHTML = button.innerHTML;
|
||||||
|
button.innerHTML = '<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/></svg>';
|
||||||
|
button.classList.add('text-green-500');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
button.innerHTML = originalHTML;
|
||||||
|
button.classList.remove('text-green-500');
|
||||||
|
}, 1500);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error('Failed to copy:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{% block extra_scripts %}{% endblock %}
|
{% block extra_scripts %}{% endblock %}
|
||||||
|
|||||||
@@ -10,9 +10,15 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="mb-8">
|
<div class="mb-8 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
|
</div>
|
||||||
|
<div 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>
|
</div>
|
||||||
|
|
||||||
<!-- Dashboard cards with auto-refresh -->
|
<!-- Dashboard cards with auto-refresh -->
|
||||||
@@ -48,23 +54,35 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
|
||||||
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
<p class="text-xs text-gray-500 dark:text-gray-500 mb-2 italic">Deployed Status:</p>
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2" title="Units reporting normally (last seen < 12 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
|
<span class="w-3 h-3 rounded-full bg-green-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center mb-2">
|
<div class="flex justify-between items-center mb-2" title="Units with delayed reports (12-24 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
|
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm1-12a1 1 0 10-2 0v4a1 1 0 00.293.707l2.828 2.829a1 1 0 101.415-1.415L11 9.586V6z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center" title="Units not reporting (> 24 hours)">
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class="w-3 h-3 rounded-full bg-red-500 mr-2"></span>
|
<span class="w-3 h-3 rounded-full bg-red-500 mr-2 flex items-center justify-center">
|
||||||
|
<svg class="w-2 h-2 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
|
||||||
</div>
|
</div>
|
||||||
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
|
||||||
@@ -185,6 +203,17 @@ function updateDashboard(event) {
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.detail.xhr.response);
|
const data = JSON.parse(event.detail.xhr.response);
|
||||||
|
|
||||||
|
// Update "Last updated" timestamp with timezone
|
||||||
|
const now = new Date();
|
||||||
|
const timezone = localStorage.getItem('timezone') || 'America/New_York';
|
||||||
|
document.getElementById('last-refresh').textContent = now.toLocaleTimeString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
timeZone: timezone,
|
||||||
|
timeZoneName: 'short'
|
||||||
|
});
|
||||||
|
|
||||||
// ===== Fleet summary numbers =====
|
// ===== Fleet summary numbers =====
|
||||||
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
document.getElementById('total-units').textContent = data.summary?.total ?? 0;
|
||||||
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
document.getElementById('deployed-units').textContent = data.summary?.active ?? 0;
|
||||||
|
|||||||
@@ -46,5 +46,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No active units</p>
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No active units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Get started by adding a new unit to the fleet.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -46,5 +46,11 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-8">No benched units</p>
|
<div class="text-center py-12">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
|
||||||
|
</svg>
|
||||||
|
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-gray-100">No benched units</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Units awaiting deployment will appear here.</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -34,6 +34,21 @@
|
|||||||
<!-- Fleet Roster with Tabs -->
|
<!-- Fleet Roster with Tabs -->
|
||||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||||
|
|
||||||
|
<!-- Search Bar -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="roster-search"
|
||||||
|
placeholder="Search by Unit ID, Type, or Note..."
|
||||||
|
class="w-full px-4 py-2 pl-10 pr-4 text-gray-900 dark:text-gray-100 bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-seismo-orange"
|
||||||
|
onkeyup="filterRosterTable()">
|
||||||
|
<svg class="absolute left-3 top-3 w-5 h-5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tab Bar -->
|
<!-- Tab Bar -->
|
||||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||||
<button
|
<button
|
||||||
@@ -805,6 +820,31 @@
|
|||||||
alert(`Error: ${error.message}`);
|
alert(`Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Filter roster table based on search input
|
||||||
|
function filterRosterTable() {
|
||||||
|
const searchInput = document.getElementById('roster-search').value.toLowerCase();
|
||||||
|
const table = document.querySelector('#roster-content table tbody');
|
||||||
|
|
||||||
|
if (!table) return;
|
||||||
|
|
||||||
|
const rows = table.getElementsByTagName('tr');
|
||||||
|
|
||||||
|
for (let row of rows) {
|
||||||
|
const cells = row.getElementsByTagName('td');
|
||||||
|
if (cells.length === 0) continue; // Skip header or empty rows
|
||||||
|
|
||||||
|
const unitId = cells[1]?.textContent?.toLowerCase() || '';
|
||||||
|
const unitType = cells[2]?.textContent?.toLowerCase() || '';
|
||||||
|
const note = cells[6]?.textContent?.toLowerCase() || '';
|
||||||
|
|
||||||
|
const matches = unitId.includes(searchInput) ||
|
||||||
|
unitType.includes(searchInput) ||
|
||||||
|
note.includes(searchInput);
|
||||||
|
|
||||||
|
row.style.display = matches ? '' : 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -4,14 +4,44 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center mb-4">
|
<!-- Breadcrumb Navigation -->
|
||||||
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<nav class="flex mb-4 text-sm" aria-label="Breadcrumb">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
<ol class="inline-flex items-center space-x-1 md:space-x-3">
|
||||||
</svg>
|
<li class="inline-flex items-center">
|
||||||
Back to Fleet Roster
|
<a href="/" class="text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
||||||
|
Dashboard
|
||||||
</a>
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<a href="/roster" class="ml-1 text-gray-500 hover:text-seismo-orange dark:text-gray-400">
|
||||||
|
Fleet Roster
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li aria-current="page">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<svg class="w-4 h-4 text-gray-400" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fill-rule="evenodd" d="M7.293 14.707a1 1 0 010-1.414L10.586 10 7.293 6.707a1 1 0 011.414-1.414l4 4a1 1 0 010 1.414l-4 4a1 1 0 01-1.414 0z" clip-rule="evenodd"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="ml-1 text-gray-700 dark:text-gray-300 font-medium" id="breadcrumb-unit">Unit</span>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
|
<h1 class="text-3xl font-bold text-gray-900 dark:text-white" id="pageTitle">Loading...</h1>
|
||||||
|
<button onclick="copyToClipboard(window.currentUnitId, this)" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors p-1" title="Copy Unit ID">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
<button id="editButton" onclick="enterEditMode()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors flex items-center gap-2">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -323,8 +353,10 @@ async function loadUnitData() {
|
|||||||
|
|
||||||
// Populate view mode (read-only display)
|
// Populate view mode (read-only display)
|
||||||
function populateViewMode() {
|
function populateViewMode() {
|
||||||
// Update page title
|
// Update page title and store unit ID for copy function
|
||||||
|
window.currentUnitId = currentUnit.id;
|
||||||
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
|
document.getElementById('pageTitle').textContent = `Unit ${currentUnit.id}`;
|
||||||
|
document.getElementById('breadcrumb-unit').textContent = currentUnit.id;
|
||||||
|
|
||||||
// Get status info from snapshot
|
// Get status info from snapshot
|
||||||
let unitStatus = null;
|
let unitStatus = null;
|
||||||
@@ -348,7 +380,14 @@ function populateViewMode() {
|
|||||||
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
|
document.getElementById('statusIndicator').className = `w-3 h-3 rounded-full ${statusColors[unitStatus.status] || 'bg-gray-400'}`;
|
||||||
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
|
document.getElementById('statusText').className = `font-semibold ${statusTextColors[unitStatus.status] || 'text-gray-600'}`;
|
||||||
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
|
document.getElementById('statusText').textContent = unitStatus.status || 'Unknown';
|
||||||
|
|
||||||
|
// Format "Last Seen" with timezone-aware formatting
|
||||||
|
if (unitStatus.last && typeof formatFullTimestamp === 'function') {
|
||||||
|
document.getElementById('lastSeen').textContent = formatFullTimestamp(unitStatus.last);
|
||||||
|
} else {
|
||||||
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
|
document.getElementById('lastSeen').textContent = unitStatus.last || '--';
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('age').textContent = unitStatus.age || '--';
|
document.getElementById('age').textContent = unitStatus.age || '--';
|
||||||
} else {
|
} else {
|
||||||
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
document.getElementById('statusIndicator').className = 'w-3 h-3 rounded-full bg-gray-400';
|
||||||
|
|||||||
Reference in New Issue
Block a user