v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1

Merged
serversdown merged 32 commits from claude/dev-015sto5mf2MpPCE57TbNKtaF into main 2026-01-02 16:10:54 -05:00
17 changed files with 867 additions and 221 deletions
Showing only changes of commit 90ecada35f - Show all commits

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 ## Features
- **Web Dashboard**: Modern, responsive UI with dark/light mode support
- **Fleet Monitoring**: Track all seismograph units in one place - **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) - **Status Management**: Automatically mark units as OK, Pending (>12h), or Missing (>24h)
- **Data Ingestion**: Accept reports from emitter scripts via REST API - **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 - **SQLite Storage**: Lightweight, file-based database for easy deployment
## Tech Stack ## Tech Stack
@@ -14,6 +18,10 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
- **FastAPI**: Modern, fast web framework - **FastAPI**: Modern, fast web framework
- **SQLAlchemy**: SQL toolkit and ORM - **SQLAlchemy**: SQL toolkit and ORM
- **SQLite**: Lightweight database - **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 - **uvicorn**: ASGI server
- **Docker**: Containerization for easy deployment - **Docker**: Containerization for easy deployment
@@ -39,11 +47,14 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
docker compose down 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 ### 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) ## 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:** 2. **Run the server:**
```bash ```bash
python main.py uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
``` ```
Or with auto-reload: The application will be available at http://localhost:8001
```bash
uvicorn main:app --reload
```
The API will be available at `http://localhost:8000`
## API Endpoints ## API Endpoints
### Root ### Web Pages
- **GET** `/` - Health check - **GET** `/` - Dashboard home page
- **GET** `/roster` - Fleet roster page
- **GET** `/unit/{unit_id}` - Unit detail page
### Emitter Report ### Fleet Status & Monitoring
- **POST** `/emitters/report` - **GET** `/api/status-snapshot` - Complete fleet status snapshot
- Submit status report from a seismograph unit - **GET** `/api/roster` - List of all units with metadata
- **Request Body:** - **GET** `/api/unit/{unit_id}` - Detailed unit information
```json - **GET** `/health` - Health check endpoint
{
"unit": "SEISMO-001", ### Roster Management *(New in v0.1.1)*
"unit_type": "series3", - **POST** `/api/roster/add` - Add new unit to roster
"timestamp": "2025-11-20T10:30:00", ```bash
"file": "event_20251120_103000.dat", curl -X POST http://localhost:8001/api/roster/add \
"status": "OK" -F "id=BE1234" \
} -F "unit_type=series3" \
-F "deployed=true" \
-F "note=Main site sensor"
``` ```
- **Response:** - **POST** `/api/roster/set-deployed/{unit_id}` - Toggle deployment status
```json - **POST** `/api/roster/set-retired/{unit_id}` - Toggle retired status
{ - **POST** `/api/roster/set-note/{unit_id}` - Update unit notes
"message": "Emitter report received", - **POST** `/api/roster/import-csv` - Bulk import from CSV
"unit": "SEISMO-001", ```bash
"status": "OK" curl -X POST http://localhost:8001/api/roster/import-csv \
} -F "file=@roster.csv" \
-F "update_existing=true"
``` ```
### Fleet Status ### CSV Import Format
- **GET** `/fleet/status` Create a CSV file with the following columns (only `unit_id` is required):
- Retrieve status of all seismograph units ```csv
- **Response:** unit_id,unit_type,deployed,retired,note,project_id,location
```json BE1234,series3,true,false,Primary sensor,PROJ-001,San Francisco CA
[ BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
{ ```
"id": "SEISMO-001",
"unit_type": "series3", See [sample_roster.csv](sample_roster.csv) for a working example.
"last_seen": "2025-11-20T10:30:00",
"last_file": "event_20251120_103000.dat", ### Emitter Reporting
"status": "OK", - **POST** `/emitters/report` - Submit status report from a seismograph unit
"notes": null - **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 ## API Documentation
Once running, interactive API documentation is available at: Once running, interactive API documentation is available at:
- **Swagger UI**: http://localhost:8000/docs - **Swagger UI**: http://localhost:8001/docs
- **ReDoc**: http://localhost:8000/redoc - **ReDoc**: http://localhost:8001/redoc
## Testing the API ## Testing the API
@@ -126,7 +139,7 @@ Once running, interactive API documentation is available at:
**Submit a report:** **Submit a report:**
```bash ```bash
curl -X POST http://localhost:8000/emitters/report \ curl -X POST http://localhost:8001/emitters/report \
-H "Content-Type: application/json" \ -H "Content-Type: application/json" \
-d '{ -d '{
"unit": "SEISMO-001", "unit": "SEISMO-001",
@@ -139,7 +152,14 @@ curl -X POST http://localhost:8000/emitters/report \
**Get fleet status:** **Get fleet status:**
```bash ```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 ### Using Python
@@ -150,7 +170,7 @@ from datetime import datetime
# Submit report # Submit report
response = requests.post( response = requests.post(
"http://localhost:8000/emitters/report", "http://localhost:8001/emitters/report",
json={ json={
"unit": "SEISMO-001", "unit": "SEISMO-001",
"unit_type": "series3", "unit_type": "series3",
@@ -162,13 +182,37 @@ response = requests.post(
print(response.json()) print(response.json())
# Get fleet status # 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()) print(response.json())
``` ```
## Data Model ## 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 | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
@@ -183,15 +227,34 @@ print(response.json())
``` ```
seismo-fleet-manager/ seismo-fleet-manager/
├── main.py # FastAPI app entry point ├── backend/
├── database.py # SQLAlchemy database configuration │ ├── main.py # FastAPI app entry point
├── models.py # Database models │ ├── database.py # SQLAlchemy database configuration
├── routes.py # API endpoints │ ├── 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 ├── requirements.txt # Python dependencies
├── Dockerfile # Docker container definition ├── Dockerfile # Docker container definition
├── docker-compose.yml # Docker Compose configuration ├── docker-compose.yml # Docker Compose configuration
├── .dockerignore # Docker ignore rules ├── CHANGELOG.md # Version history
── data/ # SQLite database directory (created at runtime) ── FRONTEND_README.md # Frontend documentation
└── README.md # This file
``` ```
## Docker Commands ## Docker Commands
@@ -221,6 +284,11 @@ docker compose logs -f seismo-backend
docker compose restart docker compose restart
``` ```
**Rebuild and restart:**
```bash
docker compose up -d --build
```
**Stop and remove containers:** **Stop and remove containers:**
```bash ```bash
docker compose down docker compose down
@@ -231,14 +299,25 @@ docker compose down
docker compose down -v 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 ## Future Enhancements
- Automated status updates based on last_seen timestamps
- Web-based dashboard for fleet monitoring
- Email/SMS alerts for missing units - Email/SMS alerts for missing units
- Historical data tracking and reporting - Historical data tracking and reporting
- Multi-user authentication - Multi-user authentication
- PostgreSQL support for larger deployments - PostgreSQL support for larger deployments
- Advanced filtering and search
- Export roster to various formats
- Automated backup and restore
## License ## License
@@ -246,4 +325,6 @@ MIT
## Version ## 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 yield db
finally: finally:
db.close() 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 fastapi.responses import HTMLResponse
from backend.database import engine, Base 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 from backend.services.snapshot import emit_status_snapshot
# Create database tables # Create database tables
@@ -15,7 +15,7 @@ Base.metadata.create_all(bind=engine)
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",
version="0.1.0" version="0.1.1"
) )
# Configure CORS # Configure CORS
@@ -37,6 +37,10 @@ templates = Jinja2Templates(directory="templates")
app.include_router(roster.router) app.include_router(roster.router)
app.include_router(units.router) app.include_router(units.router)
app.include_router(photos.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 # Legacy routes from the original backend
@@ -98,8 +102,9 @@ async def roster_table_partial(request: Request):
def health_check(): def health_check():
"""Health check endpoint""" """Health check endpoint"""
return { return {
"message": "Seismo Fleet Manager v0.1", "message": "Seismo Fleet Manager v0.1.1",
"status": "running" "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 datetime import datetime
from backend.database import Base from backend.database import Base
class Emitter(Base): class Emitter(Base):
"""Emitter model representing a seismograph unit in the fleet"""
__tablename__ = "emitters" __tablename__ = "emitters"
id = Column(String, primary_key=True, index=True) id = Column(String, primary_key=True, index=True)
unit_type = Column(String, nullable=False) unit_type = Column(String, nullable=False)
last_seen = Column(DateTime, default=datetime.utcnow) last_seen = Column(DateTime, default=datetime.utcnow)
last_file = Column(String, nullable=False) last_file = Column(String, nullable=False)
status = Column(String, nullable=False) # OK, Pending, Missing status = Column(String, nullable=False)
notes = Column(String, nullable=True) 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,178 @@
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(True),
note: str = Form(""),
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,
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() emitters = db.query(Emitter).all()
return emitters 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 @@
""" from datetime import datetime, timezone
Mock implementation of emit_status_snapshot(). from sqlalchemy.orm import Session
This will be replaced with real Series3 emitter logic by the user.
"""
from datetime import datetime, timedelta from backend.database import get_db_session
import random 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(): def emit_status_snapshot():
""" """
Mock function that returns fleet status snapshot. Merge roster (what we *intend*) with emitter data (what is *actually happening*).
In production, this will call the real Series3 emitter logic.
Returns a dictionary with unit statuses, ages, deployment status, etc.
""" """
# Mock data for demonstration db = get_db_session()
mock_units = { try:
"BE1234": { roster = {r.id: r for r in db.query(RosterUnit).all()}
"status": "OK", emitters = {e.id: e for e in db.query(Emitter).all()}
"age": "1h 12m",
"last": "2025-11-22 12:32:10", units = {}
"fname": "evt_1234.mlg",
"deployed": True, # --- Merge roster entries first ---
"note": "Bridge monitoring project - Golden Gate" for unit_id, r in roster.items():
}, e = emitters.get(unit_id)
"BE5678": {
"status": "Pending", if r.retired:
"age": "2h 45m", # Retired units get separated later
"last": "2025-11-22 11:05:33", status = "Retired"
"fname": "evt_5678.mlg", age = "N/A"
"deployed": True, last_seen = None
"note": "Dam structural analysis" fname = ""
}, else:
"BE9012": { if e:
"status": "Missing", status = e.status
"age": "5d 3h", last_seen = ensure_utc(e.last_seen)
"last": "2025-11-17 09:15:00", age = format_age(last_seen)
"fname": "evt_9012.mlg", fname = e.last_file
"deployed": True, else:
"note": "Tunnel excavation site" # Rostered but no emitter data
}, status = "Missing"
"BE3456": { last_seen = None
"status": "OK", age = "N/A"
"age": "30m", fname = ""
"last": "2025-11-22 13:20:45",
"fname": "evt_3456.mlg", units[unit_id] = {
"deployed": False, "id": unit_id,
"note": "Benched for maintenance" "status": status,
}, "age": age,
"BE7890": { "last": last_seen.isoformat() if last_seen else None,
"status": "OK", "fname": fname,
"age": "15m", "deployed": r.deployed,
"last": "2025-11-22 13:35:22", "note": r.note or "",
"fname": "evt_7890.mlg", "retired": r.retired,
"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"
} }
# --- 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 { return {
"units": mock_units, "timestamp": datetime.utcnow().isoformat(),
"timestamp": datetime.now().isoformat(), "units": units,
"total_units": len(mock_units), "active": active_units,
"deployed_units": sum(1 for u in mock_units.values() if u["deployed"]), "benched": benched_units,
"status_summary": { "retired": retired_units,
"OK": sum(1 for u in mock_units.values() if u["status"] == "OK"), "summary": {
"Pending": sum(1 for u in mock_units.values() if u["status"] == "Pending"), "total": len(units),
"Missing": sum(1 for u in mock_units.values() if u["status"] == "Missing") "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> Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span> <span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1> </h1>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v0.1.1</p>
</div> </div>
<!-- Navigation --> <!-- Navigation -->

View File

@@ -9,14 +9,21 @@
</div> </div>
<!-- Dashboard cards with auto-refresh --> <!-- 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"> <div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Fleet Summary Card --> <!-- Fleet Summary Card -->
<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">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2> <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"> <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> </svg>
</div> </div>
<div class="space-y-3"> <div class="space-y-3">
@@ -59,7 +66,9 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2> <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"> <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> </svg>
</div> </div>
<div id="alerts-list" class="space-y-3"> <div id="alerts-list" class="space-y-3">
@@ -72,56 +81,104 @@
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2> <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"> <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> </svg>
</div> </div>
<div class="text-center text-gray-500 dark:text-gray-400"> <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"> <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> </svg>
<p class="text-sm">No recent photos</p> <p class="text-sm">No recent photos</p>
</div> </div>
</div> </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="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2> <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"> <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"> <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> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg> </svg>
</a> </a>
</div> </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> <p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
</div> </div>
</div> </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> <script>
function updateDashboard(event) { function updateDashboard(event) {
try { try {
const data = JSON.parse(event.detail.xhr.response); const data = JSON.parse(event.detail.xhr.response);
// Update fleet summary // ===== Fleet summary numbers =====
document.getElementById('total-units').textContent = data.total_units || 0; document.getElementById('total-units').textContent = data.total_units ?? 0;
document.getElementById('deployed-units').textContent = data.deployed_units || 0; document.getElementById('deployed-units').textContent = data.deployed_units ?? 0;
document.getElementById('status-ok').textContent = data.status_summary.OK || 0; document.getElementById('status-ok').textContent = data.status_summary.OK ?? 0;
document.getElementById('status-pending').textContent = data.status_summary.Pending || 0; document.getElementById('status-pending').textContent = data.status_summary.Pending ?? 0;
document.getElementById('status-missing').textContent = data.status_summary.Missing || 0; document.getElementById('status-missing').textContent = data.status_summary.Missing ?? 0;
// Update alerts list // ===== Alerts =====
const alertsList = document.getElementById('alerts-list'); const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Missing'); const missingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Missing');
const pendingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Pending'); const pendingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Pending');
if (missingUnits.length === 0 && pendingUnits.length === 0) { if (!missingUnits.length && !pendingUnits.length) {
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>'; alertsList.innerHTML =
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
} else { } else {
let alertsHtml = ''; let alertsHtml = '';
missingUnits.slice(0, 3).forEach(([id, unit]) => { missingUnits.slice(0, 3).forEach(([id, unit]) => {
alertsHtml += ` alertsHtml += `
<div class="flex items-start space-x-2 text-sm"> <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> <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> <p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div> </div>
</div> </div>`;
`;
}); });
pendingUnits.slice(0, 2).forEach(([id, unit]) => { pendingUnits.slice(0, 2).forEach(([id, unit]) => {
alertsHtml += ` alertsHtml += `
<div class="flex items-start space-x-2 text-sm"> <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> <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> <p class="text-gray-600 dark:text-gray-400">Pending for ${unit.age}</p>
</div> </div>
</div> </div>`;
`;
}); });
alertsList.innerHTML = alertsHtml; alertsList.innerHTML = alertsHtml;
} }
// Update fleet preview } catch (err) {
const fleetPreview = document.getElementById('fleet-preview'); console.error("Dashboard update error:", err);
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);
} }
} }
</script> </script>
{% endblock %} {% 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 %}