Merge pull request #5 from serversdwn/claude/dev-015sto5mf2MpPCE57TbNKtaF

Merge to v0.1.1
This commit is contained in:
serversdwn
2025-12-02 03:39:22 -05:00
committed by GitHub
18 changed files with 1072 additions and 223 deletions

63
CHANGELOG.md Normal file
View File

@@ -0,0 +1,63 @@
# Changelog
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.1.1] - 2025-12-02
### Added
- **Roster Editing API**: Full CRUD operations for roster management
- `POST /api/roster/add` - Add new units to roster
- `POST /api/roster/set-deployed/{unit_id}` - Toggle deployment status
- `POST /api/roster/set-retired/{unit_id}` - Toggle retired status
- `POST /api/roster/set-note/{unit_id}` - Update unit notes
- **CSV Import**: Bulk roster import functionality
- `POST /api/roster/import-csv` - Import units from CSV file
- Support for all roster fields: unit_id, unit_type, deployed, retired, note, project_id, location
- Optional update_existing parameter to control duplicate handling
- Detailed import summary with added/updated/skipped/error counts
- **Enhanced Database Models**:
- Added `project_id` field to RosterUnit model
- Added `location` field to RosterUnit model
- Added `last_updated` timestamp tracking
- **Dashboard Enhancements**:
- Separate views for Active, Benched, and Retired units
- New endpoints: `/dashboard/active` and `/dashboard/benched`
### Fixed
- Database session management bug in `emit_status_snapshot()`
- Added `get_db_session()` helper function for direct session access
- Implemented proper session cleanup with try/finally blocks
- Database schema synchronization issues
- Database now properly recreates when model changes are detected
### Changed
- Updated RosterUnit model to include additional metadata fields
- Improved error handling in CSV import with row-level error reporting
- Enhanced snapshot service to properly manage database connections
### Technical Details
- All roster editing endpoints use Form data for better HTML form compatibility
- CSV import uses multipart/form-data for file uploads
- Boolean fields in CSV accept: 'true', '1', 'yes' (case-insensitive)
- Database sessions now properly closed to prevent connection leaks
## [0.1.0] - 2024-11-20
### Added
- Initial release of Seismo Fleet Manager
- FastAPI-based REST API for fleet management
- SQLite database with SQLAlchemy ORM
- Emitter reporting endpoints
- Basic fleet status monitoring
- Docker and Docker Compose support
- Web-based dashboard with HTMX
- Dark/light mode toggle
- Interactive maps with Leaflet
- Photo management per unit
- Automated status categorization (OK/Pending/Missing)
[0.1.1]: https://github.com/yourusername/seismo-fleet-manager/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/yourusername/seismo-fleet-manager/releases/tag/v0.1.0

209
README.md
View File

@@ -1,12 +1,16 @@
# Seismo Fleet Manager - Backend v0.1
# Seismo Fleet Manager v0.1.1
Backend API for managing seismograph fleet status. Track multiple seismographs calling in data from remote deployments, monitor their status, and manage your fleet through a unified database.
Backend API and web interface for managing seismograph fleet status. Track multiple seismographs calling in data from remote deployments, monitor their status, and manage your fleet through a unified database.
## Features
- **Web Dashboard**: Modern, responsive UI with dark/light mode support
- **Fleet Monitoring**: Track all seismograph units in one place
- **Roster Management**: Full CRUD operations via API or CSV import *(New in v0.1.1)*
- **Status Management**: Automatically mark units as OK, Pending (>12h), or Missing (>24h)
- **Data Ingestion**: Accept reports from emitter scripts via REST API
- **Photo Management**: Upload and view photos for each unit
- **Interactive Maps**: Leaflet-based maps showing unit locations
- **SQLite Storage**: Lightweight, file-based database for easy deployment
## Tech Stack
@@ -14,6 +18,10 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
- **FastAPI**: Modern, fast web framework
- **SQLAlchemy**: SQL toolkit and ORM
- **SQLite**: Lightweight database
- **HTMX**: Dynamic updates without heavy JavaScript frameworks
- **TailwindCSS**: Utility-first CSS framework
- **Leaflet**: Interactive maps
- **Jinja2**: Server-side templating
- **uvicorn**: ASGI server
- **Docker**: Containerization for easy deployment
@@ -39,11 +47,14 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
docker compose down
```
The API will be available at `http://localhost:8000`
The application will be available at:
- **Web Interface**: http://localhost:8001
- **API Documentation**: http://localhost:8001/docs
- **Health Check**: http://localhost:8001/health
### Data Persistence
The SQLite database is stored in the `./data` directory, which is mounted as a volume. Your data will persist even if you restart or rebuild the container.
The SQLite database and photos are stored in the `./data` directory, which is mounted as a volume. Your data will persist even if you restart or rebuild the container.
## Local Development (Without Docker)
@@ -60,65 +71,67 @@ The SQLite database is stored in the `./data` directory, which is mounted as a v
2. **Run the server:**
```bash
python main.py
uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
```
Or with auto-reload:
```bash
uvicorn main:app --reload
```
The API will be available at `http://localhost:8000`
The application will be available at http://localhost:8001
## API Endpoints
### Root
- **GET** `/` - Health check
### Web Pages
- **GET** `/` - Dashboard home page
- **GET** `/roster` - Fleet roster page
- **GET** `/unit/{unit_id}` - Unit detail page
### Emitter Report
- **POST** `/emitters/report`
- Submit status report from a seismograph unit
- **Request Body:**
```json
{
"unit": "SEISMO-001",
"unit_type": "series3",
"timestamp": "2025-11-20T10:30:00",
"file": "event_20251120_103000.dat",
"status": "OK"
}
### Fleet Status & Monitoring
- **GET** `/api/status-snapshot` - Complete fleet status snapshot
- **GET** `/api/roster` - List of all units with metadata
- **GET** `/api/unit/{unit_id}` - Detailed unit information
- **GET** `/health` - Health check endpoint
### Roster Management *(New in v0.1.1)*
- **POST** `/api/roster/add` - Add new unit to roster
```bash
curl -X POST http://localhost:8001/api/roster/add \
-F "id=BE1234" \
-F "unit_type=series3" \
-F "deployed=true" \
-F "note=Main site sensor"
```
- **Response:**
```json
{
"message": "Emitter report received",
"unit": "SEISMO-001",
"status": "OK"
}
- **POST** `/api/roster/set-deployed/{unit_id}` - Toggle deployment status
- **POST** `/api/roster/set-retired/{unit_id}` - Toggle retired status
- **POST** `/api/roster/set-note/{unit_id}` - Update unit notes
- **POST** `/api/roster/import-csv` - Bulk import from CSV
```bash
curl -X POST http://localhost:8001/api/roster/import-csv \
-F "file=@roster.csv" \
-F "update_existing=true"
```
### Fleet Status
- **GET** `/fleet/status`
- Retrieve status of all seismograph units
- **Response:**
```json
[
{
"id": "SEISMO-001",
"unit_type": "series3",
"last_seen": "2025-11-20T10:30:00",
"last_file": "event_20251120_103000.dat",
"status": "OK",
"notes": null
}
]
```
### CSV Import Format
Create a CSV file with the following columns (only `unit_id` is required):
```csv
unit_id,unit_type,deployed,retired,note,project_id,location
BE1234,series3,true,false,Primary sensor,PROJ-001,San Francisco CA
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
```
See [sample_roster.csv](sample_roster.csv) for a working example.
### Emitter Reporting
- **POST** `/emitters/report` - Submit status report from a seismograph unit
- **POST** `/api/series3/heartbeat` - Series3 multi-unit telemetry payload
- **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy)
### Photo Management
- **GET** `/api/unit/{unit_id}/photos` - List photos for a unit
- **GET** `/api/unit/{unit_id}/photo/{filename}` - Serve specific photo file
## API Documentation
Once running, interactive API documentation is available at:
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
- **Swagger UI**: http://localhost:8001/docs
- **ReDoc**: http://localhost:8001/redoc
## Testing the API
@@ -126,7 +139,7 @@ Once running, interactive API documentation is available at:
**Submit a report:**
```bash
curl -X POST http://localhost:8000/emitters/report \
curl -X POST http://localhost:8001/emitters/report \
-H "Content-Type: application/json" \
-d '{
"unit": "SEISMO-001",
@@ -139,7 +152,14 @@ curl -X POST http://localhost:8000/emitters/report \
**Get fleet status:**
```bash
curl http://localhost:8000/fleet/status
curl http://localhost:8001/api/roster
```
**Import roster from CSV:**
```bash
curl -X POST http://localhost:8001/api/roster/import-csv \
-F "file=@sample_roster.csv" \
-F "update_existing=true"
```
### Using Python
@@ -150,7 +170,7 @@ from datetime import datetime
# Submit report
response = requests.post(
"http://localhost:8000/emitters/report",
"http://localhost:8001/emitters/report",
json={
"unit": "SEISMO-001",
"unit_type": "series3",
@@ -162,13 +182,37 @@ response = requests.post(
print(response.json())
# Get fleet status
response = requests.get("http://localhost:8000/fleet/status")
response = requests.get("http://localhost:8001/api/roster")
print(response.json())
# Import CSV
with open('roster.csv', 'rb') as f:
files = {'file': f}
data = {'update_existing': 'true'}
response = requests.post(
"http://localhost:8001/api/roster/import-csv",
files=files,
data=data
)
print(response.json())
```
## Data Model
### Emitters Table
### RosterUnit Table (Your Fleet Roster)
| Field | Type | Description |
|-------|------|-------------|
| id | string | Unit identifier (primary key) |
| unit_type | string | Type of seismograph (default: "series3") |
| deployed | boolean | Whether unit is deployed in field |
| retired | boolean | Whether unit is retired from service |
| note | string | Notes about the unit |
| project_id | string | Associated project identifier |
| location | string | Deployment location description |
| last_updated | datetime | Last modification timestamp |
### Emitter Table (Device Check-ins)
| Field | Type | Description |
|-------|------|-------------|
@@ -183,15 +227,34 @@ print(response.json())
```
seismo-fleet-manager/
├── main.py # FastAPI app entry point
├── database.py # SQLAlchemy database configuration
├── models.py # Database models
├── routes.py # API endpoints
├── backend/
│ ├── main.py # FastAPI app entry point
│ ├── database.py # SQLAlchemy database configuration
│ ├── models.py # Database models (RosterUnit, Emitter)
│ ├── routes.py # Legacy API endpoints
│ ├── routers/ # Modular API routers
│ │ ├── roster.py # Fleet status endpoints
│ │ ├── roster_edit.py # Roster management & CSV import
│ │ ├── units.py # Unit detail endpoints
│ │ ├── photos.py # Photo management
│ │ ├── dashboard.py # Dashboard partials
│ │ └── dashboard_tabs.py # Dashboard tab endpoints
│ ├── services/
│ │ └── snapshot.py # Fleet status snapshot logic
│ └── static/ # Static assets (CSS, etc.)
├── templates/ # Jinja2 HTML templates
│ ├── base.html # Base layout with sidebar
│ ├── dashboard.html # Main dashboard
│ ├── roster.html # Fleet roster table
│ ├── unit_detail.html # Unit detail page
│ └── partials/ # HTMX partial templates
├── data/ # SQLite database & photos (persisted)
├── requirements.txt # Python dependencies
├── Dockerfile # Docker container definition
├── docker-compose.yml # Docker Compose configuration
├── .dockerignore # Docker ignore rules
── data/ # SQLite database directory (created at runtime)
├── CHANGELOG.md # Version history
── FRONTEND_README.md # Frontend documentation
└── README.md # This file
```
## Docker Commands
@@ -221,6 +284,11 @@ docker compose logs -f seismo-backend
docker compose restart
```
**Rebuild and restart:**
```bash
docker compose up -d --build
```
**Stop and remove containers:**
```bash
docker compose down
@@ -231,14 +299,25 @@ docker compose down
docker compose down -v
```
## What's New in v0.1.1
- **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 complete version history.
## Future Enhancements
- Automated status updates based on last_seen timestamps
- Web-based dashboard for fleet monitoring
- Email/SMS alerts for missing units
- Historical data tracking and reporting
- Multi-user authentication
- PostgreSQL support for larger deployments
- Advanced filtering and search
- Export roster to various formats
- Automated backup and restore
## License
@@ -246,4 +325,6 @@ MIT
## Version
0.1.0 - Initial Release
**Current: 0.1.1** - Roster Management & CSV Import (2025-12-02)
Previous: 0.1.0 - Initial Release (2024-11-20)

View File

@@ -24,3 +24,8 @@ def get_db():
yield db
finally:
db.close()
def get_db_session():
"""Get a database session directly (not as a dependency)"""
return SessionLocal()

View File

@@ -5,7 +5,7 @@ from fastapi.templating import Jinja2Templates
from fastapi.responses import HTMLResponse
from backend.database import engine, Base
from backend.routers import roster, units, photos
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
from backend.services.snapshot import emit_status_snapshot
# Create database tables
@@ -15,7 +15,7 @@ Base.metadata.create_all(bind=engine)
app = FastAPI(
title="Seismo Fleet Manager",
description="Backend API for managing seismograph fleet status",
version="0.1.0"
version="0.1.1"
)
# Configure CORS
@@ -37,6 +37,10 @@ templates = Jinja2Templates(directory="templates")
app.include_router(roster.router)
app.include_router(units.router)
app.include_router(photos.router)
app.include_router(roster_edit.router)
app.include_router(dashboard.router)
app.include_router(dashboard_tabs.router)
# Legacy routes from the original backend
@@ -98,8 +102,9 @@ async def roster_table_partial(request: Request):
def health_check():
"""Health check endpoint"""
return {
"message": "Seismo Fleet Manager v0.1",
"status": "running"
"message": "Seismo Fleet Manager v0.1.1",
"status": "running",
"version": "0.1.1"
}

View File

@@ -1,18 +1,31 @@
from sqlalchemy import Column, String, DateTime
from sqlalchemy import Column, String, DateTime, Boolean, Text
from datetime import datetime
from backend.database import Base
class Emitter(Base):
"""Emitter model representing a seismograph unit in the fleet"""
__tablename__ = "emitters"
id = Column(String, primary_key=True, index=True)
unit_type = Column(String, nullable=False)
last_seen = Column(DateTime, default=datetime.utcnow)
last_file = Column(String, nullable=False)
status = Column(String, nullable=False) # OK, Pending, Missing
status = Column(String, nullable=False)
notes = Column(String, nullable=True)
def __repr__(self):
return f"<Emitter(id={self.id}, type={self.unit_type}, status={self.status})>"
class RosterUnit(Base):
"""
Roster table: represents our *intended assignment* of a unit.
This is editable from the GUI.
"""
__tablename__ = "roster"
id = Column(String, primary_key=True, index=True)
unit_type = Column(String, default="series3")
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
location = Column(String, nullable=True)
last_updated = Column(DateTime, default=datetime.utcnow)

View File

@@ -0,0 +1,25 @@
from fastapi import APIRouter, Request, Depends
from fastapi.templating import Jinja2Templates
from backend.services.snapshot import emit_status_snapshot
router = APIRouter()
templates = Jinja2Templates(directory="templates")
@router.get("/dashboard/active")
def dashboard_active(request: Request):
snapshot = emit_status_snapshot()
return templates.TemplateResponse(
"partials/active_table.html",
{"request": request, "units": snapshot["active"]}
)
@router.get("/dashboard/benched")
def dashboard_benched(request: Request):
snapshot = emit_status_snapshot()
return templates.TemplateResponse(
"partials/benched_table.html",
{"request": request, "units": snapshot["benched"]}
)

View File

@@ -0,0 +1,34 @@
# backend/routers/dashboard_tabs.py
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot
router = APIRouter(prefix="/dashboard", tags=["dashboard-tabs"])
@router.get("/active")
def get_active_units(db: Session = Depends(get_db)):
"""
Return only ACTIVE (deployed) units for dashboard table swap.
"""
snap = emit_status_snapshot()
units = {
uid: u
for uid, u in snap["units"].items()
if u["deployed"] is True
}
return {"units": units}
@router.get("/benched")
def get_benched_units(db: Session = Depends(get_db)):
"""
Return only BENCHED (not deployed) units for dashboard table swap.
"""
snap = emit_status_snapshot()
units = {
uid: u
for uid, u in snap["units"].items()
if u["deployed"] is False
}
return {"units": units}

View File

@@ -0,0 +1,182 @@
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
from sqlalchemy.orm import Session
from datetime import datetime
import csv
import io
from backend.database import get_db
from backend.models import RosterUnit
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
def get_or_create_roster_unit(db: Session, unit_id: str):
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if not unit:
unit = RosterUnit(id=unit_id)
db.add(unit)
db.commit()
db.refresh(unit)
return unit
@router.post("/add")
def add_roster_unit(
id: str = Form(...),
unit_type: str = Form("series3"),
deployed: bool = Form(False),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
db: Session = Depends(get_db)
):
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
raise HTTPException(status_code=400, detail="Unit already exists")
unit = RosterUnit(
id=id,
unit_type=unit_type,
deployed=deployed,
note=note,
project_id=project_id,
location=location,
last_updated=datetime.utcnow(),
)
db.add(unit)
db.commit()
return {"message": "Unit added", "id": id}
@router.post("/set-deployed/{unit_id}")
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id)
unit.deployed = deployed
unit.last_updated = datetime.utcnow()
db.commit()
return {"message": "Updated", "id": unit_id, "deployed": deployed}
@router.post("/set-retired/{unit_id}")
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id)
unit.retired = retired
unit.last_updated = datetime.utcnow()
db.commit()
return {"message": "Updated", "id": unit_id, "retired": retired}
@router.post("/set-note/{unit_id}")
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
unit = get_or_create_roster_unit(db, unit_id)
unit.note = note
unit.last_updated = datetime.utcnow()
db.commit()
return {"message": "Updated", "id": unit_id, "note": note}
@router.post("/import-csv")
async def import_csv(
file: UploadFile = File(...),
update_existing: bool = Form(True),
db: Session = Depends(get_db)
):
"""
Import roster units from CSV file.
Expected CSV columns (unit_id is required, others are optional):
- unit_id: Unique identifier for the unit
- unit_type: Type of unit (default: "series3")
- deployed: Boolean for deployment status (default: False)
- retired: Boolean for retirement status (default: False)
- note: Notes about the unit
- project_id: Project identifier
- location: Location description
Args:
file: CSV file upload
update_existing: If True, update existing units; if False, skip them
"""
if not file.filename.endswith('.csv'):
raise HTTPException(status_code=400, detail="File must be a CSV")
# Read file content
contents = await file.read()
csv_text = contents.decode('utf-8')
csv_reader = csv.DictReader(io.StringIO(csv_text))
results = {
"added": [],
"updated": [],
"skipped": [],
"errors": []
}
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
try:
# Validate required field
unit_id = row.get('unit_id', '').strip()
if not unit_id:
results["errors"].append({
"row": row_num,
"error": "Missing required field: unit_id"
})
continue
# Check if unit exists
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
if existing_unit:
if not update_existing:
results["skipped"].append(unit_id)
continue
# Update existing unit
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
existing_unit.note = row.get('note', existing_unit.note or '')
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
existing_unit.location = row.get('location', existing_unit.location)
existing_unit.last_updated = datetime.utcnow()
results["updated"].append(unit_id)
else:
# Create new unit
new_unit = RosterUnit(
id=unit_id,
unit_type=row.get('unit_type', 'series3'),
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
note=row.get('note', ''),
project_id=row.get('project_id'),
location=row.get('location'),
last_updated=datetime.utcnow()
)
db.add(new_unit)
results["added"].append(unit_id)
except Exception as e:
results["errors"].append({
"row": row_num,
"unit_id": row.get('unit_id', 'unknown'),
"error": str(e)
})
# Commit all changes
try:
db.commit()
except Exception as e:
db.rollback()
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
return {
"message": "CSV import completed",
"summary": {
"added": len(results["added"]),
"updated": len(results["updated"]),
"skipped": len(results["skipped"]),
"errors": len(results["errors"])
},
"details": results
}

View File

@@ -80,3 +80,82 @@ def get_fleet_status(db: Session = Depends(get_db)):
"""
emitters = db.query(Emitter).all()
return emitters
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
from fastapi import Request
@router.post("/api/series3/heartbeat", status_code=200)
async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
"""
Accepts a full telemetry payload from the Series3 emitter.
Updates or inserts each unit into the database.
"""
payload = await request.json()
source = payload.get("source_id")
units = payload.get("units", [])
print("\n=== Series 3 Heartbeat ===")
print("Source:", source)
print("Units received:", len(units))
print("==========================\n")
results = []
for u in units:
uid = u.get("unit_id")
last_event_time = u.get("last_event_time")
event_meta = u.get("event_metadata", {})
age_minutes = u.get("age_minutes")
try:
if last_event_time:
ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00"))
else:
ts = None
except:
ts = None
# Pull from DB
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
# File name (from event_metadata)
last_file = event_meta.get("file_name")
status = "Unknown"
# Determine status based on age
if age_minutes is None:
status = "Missing"
elif age_minutes > 24 * 60:
status = "Missing"
elif age_minutes > 12 * 60:
status = "Pending"
else:
status = "OK"
if emitter:
# Update existing
emitter.last_seen = ts
emitter.last_file = last_file
emitter.status = status
else:
# Insert new
emitter = Emitter(
id=uid,
unit_type="series3",
last_seen=ts,
last_file=last_file,
status=status
)
db.add(emitter)
results.append({"unit": uid, "status": status})
db.commit()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
}

View File

@@ -1,96 +1,121 @@
"""
Mock implementation of emit_status_snapshot().
This will be replaced with real Series3 emitter logic by the user.
"""
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
import random
from backend.database import get_db_session
from backend.models import Emitter, RosterUnit
def ensure_utc(dt):
if dt is None:
return None
if dt.tzinfo is None:
return dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def format_age(last_seen):
if not last_seen:
return "N/A"
last_seen = ensure_utc(last_seen)
now = datetime.now(timezone.utc)
diff = now - last_seen
hours = diff.total_seconds() // 3600
mins = (diff.total_seconds() % 3600) // 60
return f"{int(hours)}h {int(mins)}m"
def emit_status_snapshot():
"""
Mock function that returns fleet status snapshot.
In production, this will call the real Series3 emitter logic.
Returns a dictionary with unit statuses, ages, deployment status, etc.
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
"""
# Mock data for demonstration
mock_units = {
"BE1234": {
"status": "OK",
"age": "1h 12m",
"last": "2025-11-22 12:32:10",
"fname": "evt_1234.mlg",
"deployed": True,
"note": "Bridge monitoring project - Golden Gate"
},
"BE5678": {
"status": "Pending",
"age": "2h 45m",
"last": "2025-11-22 11:05:33",
"fname": "evt_5678.mlg",
"deployed": True,
"note": "Dam structural analysis"
},
"BE9012": {
"status": "Missing",
"age": "5d 3h",
"last": "2025-11-17 09:15:00",
"fname": "evt_9012.mlg",
"deployed": True,
"note": "Tunnel excavation site"
},
"BE3456": {
"status": "OK",
"age": "30m",
"last": "2025-11-22 13:20:45",
"fname": "evt_3456.mlg",
"deployed": False,
"note": "Benched for maintenance"
},
"BE7890": {
"status": "OK",
"age": "15m",
"last": "2025-11-22 13:35:22",
"fname": "evt_7890.mlg",
"deployed": True,
"note": "Pipeline monitoring"
},
"BE2468": {
"status": "Pending",
"age": "4h 20m",
"last": "2025-11-22 09:30:15",
"fname": "evt_2468.mlg",
"deployed": True,
"note": "Building foundation survey"
},
"BE1357": {
"status": "OK",
"age": "45m",
"last": "2025-11-22 13:05:00",
"fname": "evt_1357.mlg",
"deployed": False,
"note": "Awaiting deployment"
},
"BE8642": {
"status": "Missing",
"age": "2d 12h",
"last": "2025-11-20 01:30:00",
"fname": "evt_8642.mlg",
"deployed": True,
"note": "Offshore platform - comms issue suspected"
db = get_db_session()
try:
roster = {r.id: r for r in db.query(RosterUnit).all()}
emitters = {e.id: e for e in db.query(Emitter).all()}
units = {}
# --- Merge roster entries first ---
for unit_id, r in roster.items():
e = emitters.get(unit_id)
if r.retired:
# Retired units get separated later
status = "Retired"
age = "N/A"
last_seen = None
fname = ""
else:
if e:
status = e.status
last_seen = ensure_utc(e.last_seen)
age = format_age(last_seen)
fname = e.last_file
else:
# Rostered but no emitter data
status = "Missing"
last_seen = None
age = "N/A"
fname = ""
units[unit_id] = {
"id": unit_id,
"status": status,
"age": age,
"last": last_seen.isoformat() if last_seen else None,
"fname": fname,
"deployed": r.deployed,
"note": r.note or "",
"retired": r.retired,
}
# --- Add unexpected emitter-only units ---
for unit_id, e in emitters.items():
if unit_id not in roster:
last_seen = ensure_utc(e.last_seen)
units[unit_id] = {
"id": unit_id,
"status": e.status,
"age": format_age(last_seen),
"last": last_seen.isoformat(),
"fname": e.last_file,
"deployed": False, # default
"note": "",
"retired": False,
}
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()
if not u["retired"] and u["deployed"]
}
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["deployed"]
}
retired_units = {
uid: u for uid, u in units.items()
if u["retired"]
}
return {
"units": mock_units,
"timestamp": datetime.now().isoformat(),
"total_units": len(mock_units),
"deployed_units": sum(1 for u in mock_units.values() if u["deployed"]),
"status_summary": {
"OK": sum(1 for u in mock_units.values() if u["status"] == "OK"),
"Pending": sum(1 for u in mock_units.values() if u["status"] == "Pending"),
"Missing": sum(1 for u in mock_units.values() if u["status"] == "Missing")
"timestamp": datetime.utcnow().isoformat(),
"units": units,
"active": active_units,
"benched": benched_units,
"retired": retired_units,
"summary": {
"total": len(units),
"active": len(active_units),
"benched": len(benched_units),
"retired": len(retired_units),
"ok": sum(1 for u in units.values() if u["status"] == "OK"),
"pending": sum(1 for u in units.values() if u["status"] == "Pending"),
"missing": sum(1 for u in units.values() if u["status"] == "Missing"),
}
}
finally:
db.close()

6
sample_roster.csv Normal file
View File

@@ -0,0 +1,6 @@
unit_id,unit_type,deployed,retired,note,project_id,location
BE1234,series3,true,false,Primary unit at main site,PROJ-001,San Francisco CA
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
BE9012,series3,false,false,In maintenance,PROJ-002,Workshop
BE3456,series3,true,false,,PROJ-003,New York NY
BE7890,series3,false,true,Decommissioned 2024,,Storage
1 unit_id unit_type deployed retired note project_id location
2 BE1234 series3 true false Primary unit at main site PROJ-001 San Francisco CA
3 BE5678 series3 true false Backup sensor PROJ-001 Los Angeles CA
4 BE9012 series3 false false In maintenance PROJ-002 Workshop
5 BE3456 series3 true false PROJ-003 New York NY
6 BE7890 series3 false true Decommissioned 2024 Storage

View File

@@ -68,6 +68,7 @@
Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v0.1.1</p>
</div>
<!-- Navigation -->

View File

@@ -9,14 +9,21 @@
</div>
<!-- Dashboard cards with auto-refresh -->
<div hx-get="/api/status-snapshot" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateDashboard(event)">
<div hx-get="/api/status-snapshot"
hx-trigger="load, every 10s"
hx-swap="none"
hx-on::after-request="updateDashboard(event)">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Fleet Summary Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
</path>
</svg>
</div>
<div class="space-y-3">
@@ -59,7 +66,9 @@
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
</path>
</svg>
</div>
<div id="alerts-list" class="space-y-3">
@@ -72,56 +81,104 @@
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z">
</path>
</svg>
</div>
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z">
</path>
</svg>
<p class="text-sm">No recent photos</p>
</div>
</div>
</div>
<!-- Quick Access to Fleet Roster -->
<!-- Fleet Status Section with Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center">
View All
Full Roster
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<div id="fleet-preview" class="space-y-2">
<!-- Tab Bar -->
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
<button
class="px-4 py-2 text-sm font-medium tab-button active-tab"
hx-get="/dashboard/active"
hx-target="#fleet-table"
hx-swap="innerHTML">
Active
</button>
<button
class="px-4 py-2 text-sm font-medium tab-button"
hx-get="/dashboard/benched"
hx-target="#fleet-table"
hx-swap="innerHTML">
Benched
</button>
</div>
<!-- Tab Content Target -->
<div id="fleet-table" class="space-y-2">
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
</div>
</div>
</div>
<!-- TAB STYLE -->
<style>
.tab-button {
color: #6b7280; /* gray-500 */
border-bottom: 2px solid transparent;
}
.tab-button:hover {
color: #374151; /* gray-700 */
}
.active-tab {
color: #b84a12 !important; /* seismo orange */
border-bottom: 2px solid #b84a12 !important;
}
</style>
<script>
function updateDashboard(event) {
try {
const data = JSON.parse(event.detail.xhr.response);
// Update fleet summary
document.getElementById('total-units').textContent = data.total_units || 0;
document.getElementById('deployed-units').textContent = data.deployed_units || 0;
document.getElementById('status-ok').textContent = data.status_summary.OK || 0;
document.getElementById('status-pending').textContent = data.status_summary.Pending || 0;
document.getElementById('status-missing').textContent = data.status_summary.Missing || 0;
// ===== Fleet summary numbers =====
document.getElementById('total-units').textContent = data.total_units ?? 0;
document.getElementById('deployed-units').textContent = data.deployed_units ?? 0;
document.getElementById('status-ok').textContent = data.status_summary.OK ?? 0;
document.getElementById('status-pending').textContent = data.status_summary.Pending ?? 0;
document.getElementById('status-missing').textContent = data.status_summary.Missing ?? 0;
// Update alerts list
// ===== Alerts =====
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Missing');
const pendingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Pending');
const missingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Missing');
const pendingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Pending');
if (missingUnits.length === 0 && pendingUnits.length === 0) {
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
if (!missingUnits.length && !pendingUnits.length) {
alertsList.innerHTML =
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
} else {
let alertsHtml = '';
missingUnits.slice(0, 3).forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
@@ -130,9 +187,9 @@ function updateDashboard(event) {
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>
`;
</div>`;
});
pendingUnits.slice(0, 2).forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
@@ -141,38 +198,16 @@ function updateDashboard(event) {
<a href="/unit/${id}" class="font-medium text-yellow-600 dark:text-yellow-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Pending for ${unit.age}</p>
</div>
</div>
`;
</div>`;
});
alertsList.innerHTML = alertsHtml;
}
// Update fleet preview
const fleetPreview = document.getElementById('fleet-preview');
const unitsList = Object.entries(data.units).slice(0, 5);
let previewHtml = '';
unitsList.forEach(([id, unit]) => {
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'yellow' : 'red';
const deployedDot = unit.deployed ? '<span class="w-2 h-2 rounded-full bg-blue-500"></span>' : '';
previewHtml += `
<a href="/unit/${id}" class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="flex items-center space-x-3">
<span class="w-3 h-3 rounded-full bg-${statusColor}-500"></span>
${deployedDot}
<span class="font-medium">${id}</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">${unit.age}</span>
</a>
`;
});
fleetPreview.innerHTML = previewHtml;
} catch (error) {
console.error('Error updating dashboard:', error);
} catch (err) {
console.error("Dashboard update error:", err);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,25 @@
<table class="fleet-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Age</th>
<th>Last Seen</th>
<th>File</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{% for uid, u in units.items() %}
<tr>
<td>{{ uid }}</td>
<td>{{ u.status }}</td>
<td>{{ u.age }}</td>
<td>{{ u.last }}</td>
<td>{{ u.fname }}</td>
<td>{{ u.note }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,25 @@
<table class="fleet-table">
<thead>
<tr>
<th>ID</th>
<th>Status</th>
<th>Age</th>
<th>Last Seen</th>
<th>File</th>
<th>Note</th>
</tr>
</thead>
<tbody>
{% for uid, u in units.items() %}
<tr>
<td>{{ uid }}</td>
<td>{{ u.status }}</td>
<td>{{ u.age }}</td>
<td>{{ u.last }}</td>
<td>{{ u.fname }}</td>
<td>{{ u.note }}</td>
</tr>
{% endfor %}
</tbody>
</table>

View File

@@ -0,0 +1,23 @@
{% for id, unit in units.items() %}
<a href="/unit/{{ id }}"
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<span class="w-3 h-3 rounded-full
{% if unit.status == 'OK' %} bg-green-500
{% elif unit.status == 'Pending' %} bg-yellow-500
{% else %} bg-red-500 {% endif %}">
</span>
<span class="w-2 h-2 rounded-full bg-blue-500"></span>
<span class="font-medium">{{ id }}</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
</a>
{% endfor %}
{% if units|length == 0 %}
<p class="text-gray-500 dark:text-gray-400 text-sm">No active units</p>
{% endif %}

View File

@@ -0,0 +1,23 @@
{% for id, unit in units.items() %}
<a href="/unit/{{ id }}"
class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 border border-gray-200 dark:border-gray-700">
<div class="flex items-center space-x-3">
<span class="w-3 h-3 rounded-full
{% if unit.status == 'OK' %} bg-green-500
{% elif unit.status == 'Pending' %} bg-yellow-500
{% else %} bg-red-500 {% endif %}">
</span>
<!-- No deployed dot for benched units -->
<span class="font-medium">{{ id }}</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">{{ unit.age }}</span>
</a>
{% endfor %}
{% if units|length == 0 %}
<p class="text-gray-500 dark:text-gray-400 text-sm">No benched units</p>
{% endif %}

View File

@@ -4,8 +4,26 @@
{% block content %}
<div class="mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
</div>
<div class="flex gap-3">
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 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="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
Add Unit
</button>
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 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="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>
Import CSV
</button>
</div>
</div>
</div>
<!-- Auto-refresh roster every 10 seconds -->
@@ -26,4 +44,185 @@
</div>
</div>
<!-- Add Unit Modal -->
<div id="addUnitModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Add Unit</h2>
<button onclick="closeAddUnitModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
<input type="text" name="id" required
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="BE1234">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
<input type="text" name="unit_type" value="series3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
<input type="text" name="project_id"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="PROJ-001">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
<input type="text" name="location"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="San Francisco, CA">
</div>
<div class="flex items-center gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" value="true" checked
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea name="note" rows="3"
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
placeholder="Additional notes..."></textarea>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
Add Unit
</button>
<button type="button" onclick="closeAddUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
Cancel
</button>
</div>
</form>
</div>
</div>
<!-- Import CSV Modal -->
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<div class="flex justify-between items-center">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Import CSV</h2>
<button onclick="closeImportModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<form id="importForm" class="p-6 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="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Format: unit_id,unit_type,deployed,retired,note,project_id,location</p>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" name="update_existing" id="updateExisting" checked
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<label for="updateExisting" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Update existing units</label>
</div>
<div class="flex gap-3 pt-4">
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors">
Import
</button>
<button type="button" onclick="closeImportModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
Cancel
</button>
</div>
<div id="importResult" class="hidden mt-4 p-4 rounded-lg"></div>
</form>
</div>
</div>
<script>
// Add Unit Modal
function openAddUnitModal() {
document.getElementById('addUnitModal').classList.remove('hidden');
}
function closeAddUnitModal() {
document.getElementById('addUnitModal').classList.add('hidden');
document.getElementById('addUnitForm').reset();
}
// Import Modal
function openImportModal() {
document.getElementById('importModal').classList.remove('hidden');
}
function closeImportModal() {
document.getElementById('importModal').classList.add('hidden');
document.getElementById('importForm').reset();
document.getElementById('importResult').classList.add('hidden');
}
// Handle Add Unit form submission
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
if (event.detail.successful) {
closeAddUnitModal();
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Show success message
alert('Unit added successfully!');
} else {
alert('Error adding unit. Please check the form and try again.');
}
});
// Handle CSV Import
document.getElementById('importForm').addEventListener('submit', async function(e) {
e.preventDefault();
const formData = new FormData(this);
const resultDiv = document.getElementById('importResult');
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');
// Trigger roster refresh
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
// Close modal after 2 seconds
setTimeout(() => closeImportModal(), 2000);
} 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');
}
});
</script>
{% endblock %}