v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1
63
CHANGELOG.md
Normal file
63
CHANGELOG.md
Normal 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
|
||||||
207
README.md
207
README.md
@@ -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",
|
|
||||||
"last_seen": "2025-11-20T10:30:00",
|
|
||||||
"last_file": "event_20251120_103000.dat",
|
|
||||||
"status": "OK",
|
|
||||||
"notes": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
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
|
## 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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
25
backend/routers/dashboard.py
Normal file
25
backend/routers/dashboard.py
Normal 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"]}
|
||||||
|
)
|
||||||
34
backend/routers/dashboard_tabs.py
Normal file
34
backend/routers/dashboard_tabs.py
Normal 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}
|
||||||
178
backend/routers/roster_edit.py
Normal file
178
backend/routers/roster_edit.py
Normal 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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
6
sample_roster.csv
Normal 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
|
||||||
|
@@ -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 -->
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
25
templates/partials/active_table.html
Normal file
25
templates/partials/active_table.html
Normal 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>
|
||||||
25
templates/partials/benched_table.html
Normal file
25
templates/partials/benched_table.html
Normal 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>
|
||||||
23
templates/partials/dashboard_active.html
Normal file
23
templates/partials/dashboard_active.html
Normal 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 %}
|
||||||
23
templates/partials/dashboard_benched.html
Normal file
23
templates/partials/dashboard_benched.html
Normal 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 %}
|
||||||
Reference in New Issue
Block a user