Merge pull request #5 from serversdwn/claude/dev-015sto5mf2MpPCE57TbNKtaF
Merge to v0.1.1
This commit is contained in:
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
|
||||
209
README.md
209
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
|
||||
|
||||
- **Web Dashboard**: Modern, responsive UI with dark/light mode support
|
||||
- **Fleet Monitoring**: Track all seismograph units in one place
|
||||
- **Roster Management**: Full CRUD operations via API or CSV import *(New in v0.1.1)*
|
||||
- **Status Management**: Automatically mark units as OK, Pending (>12h), or Missing (>24h)
|
||||
- **Data Ingestion**: Accept reports from emitter scripts via REST API
|
||||
- **Photo Management**: Upload and view photos for each unit
|
||||
- **Interactive Maps**: Leaflet-based maps showing unit locations
|
||||
- **SQLite Storage**: Lightweight, file-based database for easy deployment
|
||||
|
||||
## Tech Stack
|
||||
@@ -14,6 +18,10 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
|
||||
- **FastAPI**: Modern, fast web framework
|
||||
- **SQLAlchemy**: SQL toolkit and ORM
|
||||
- **SQLite**: Lightweight database
|
||||
- **HTMX**: Dynamic updates without heavy JavaScript frameworks
|
||||
- **TailwindCSS**: Utility-first CSS framework
|
||||
- **Leaflet**: Interactive maps
|
||||
- **Jinja2**: Server-side templating
|
||||
- **uvicorn**: ASGI server
|
||||
- **Docker**: Containerization for easy deployment
|
||||
|
||||
@@ -39,11 +47,14 @@ Backend API for managing seismograph fleet status. Track multiple seismographs c
|
||||
docker compose down
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
The application will be available at:
|
||||
- **Web Interface**: http://localhost:8001
|
||||
- **API Documentation**: http://localhost:8001/docs
|
||||
- **Health Check**: http://localhost:8001/health
|
||||
|
||||
### Data Persistence
|
||||
|
||||
The SQLite database is stored in the `./data` directory, which is mounted as a volume. Your data will persist even if you restart or rebuild the container.
|
||||
The SQLite database and photos are stored in the `./data` directory, which is mounted as a volume. Your data will persist even if you restart or rebuild the container.
|
||||
|
||||
## Local Development (Without Docker)
|
||||
|
||||
@@ -60,65 +71,67 @@ The SQLite database is stored in the `./data` directory, which is mounted as a v
|
||||
|
||||
2. **Run the server:**
|
||||
```bash
|
||||
python main.py
|
||||
uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
|
||||
```
|
||||
|
||||
Or with auto-reload:
|
||||
```bash
|
||||
uvicorn main:app --reload
|
||||
```
|
||||
|
||||
The API will be available at `http://localhost:8000`
|
||||
The application will be available at http://localhost:8001
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Root
|
||||
- **GET** `/` - Health check
|
||||
### Web Pages
|
||||
- **GET** `/` - Dashboard home page
|
||||
- **GET** `/roster` - Fleet roster page
|
||||
- **GET** `/unit/{unit_id}` - Unit detail page
|
||||
|
||||
### Emitter Report
|
||||
- **POST** `/emitters/report`
|
||||
- Submit status report from a seismograph unit
|
||||
- **Request Body:**
|
||||
```json
|
||||
{
|
||||
"unit": "SEISMO-001",
|
||||
"unit_type": "series3",
|
||||
"timestamp": "2025-11-20T10:30:00",
|
||||
"file": "event_20251120_103000.dat",
|
||||
"status": "OK"
|
||||
}
|
||||
### Fleet Status & Monitoring
|
||||
- **GET** `/api/status-snapshot` - Complete fleet status snapshot
|
||||
- **GET** `/api/roster` - List of all units with metadata
|
||||
- **GET** `/api/unit/{unit_id}` - Detailed unit information
|
||||
- **GET** `/health` - Health check endpoint
|
||||
|
||||
### Roster Management *(New in v0.1.1)*
|
||||
- **POST** `/api/roster/add` - Add new unit to roster
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/add \
|
||||
-F "id=BE1234" \
|
||||
-F "unit_type=series3" \
|
||||
-F "deployed=true" \
|
||||
-F "note=Main site sensor"
|
||||
```
|
||||
- **Response:**
|
||||
```json
|
||||
{
|
||||
"message": "Emitter report received",
|
||||
"unit": "SEISMO-001",
|
||||
"status": "OK"
|
||||
}
|
||||
- **POST** `/api/roster/set-deployed/{unit_id}` - Toggle deployment status
|
||||
- **POST** `/api/roster/set-retired/{unit_id}` - Toggle retired status
|
||||
- **POST** `/api/roster/set-note/{unit_id}` - Update unit notes
|
||||
- **POST** `/api/roster/import-csv` - Bulk import from CSV
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/import-csv \
|
||||
-F "file=@roster.csv" \
|
||||
-F "update_existing=true"
|
||||
```
|
||||
|
||||
### Fleet Status
|
||||
- **GET** `/fleet/status`
|
||||
- Retrieve status of all seismograph units
|
||||
- **Response:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"id": "SEISMO-001",
|
||||
"unit_type": "series3",
|
||||
"last_seen": "2025-11-20T10:30:00",
|
||||
"last_file": "event_20251120_103000.dat",
|
||||
"status": "OK",
|
||||
"notes": null
|
||||
}
|
||||
]
|
||||
```
|
||||
### CSV Import Format
|
||||
Create a CSV file with the following columns (only `unit_id` is required):
|
||||
```csv
|
||||
unit_id,unit_type,deployed,retired,note,project_id,location
|
||||
BE1234,series3,true,false,Primary sensor,PROJ-001,San Francisco CA
|
||||
BE5678,series3,true,false,Backup sensor,PROJ-001,Los Angeles CA
|
||||
```
|
||||
|
||||
See [sample_roster.csv](sample_roster.csv) for a working example.
|
||||
|
||||
### Emitter Reporting
|
||||
- **POST** `/emitters/report` - Submit status report from a seismograph unit
|
||||
- **POST** `/api/series3/heartbeat` - Series3 multi-unit telemetry payload
|
||||
- **GET** `/fleet/status` - Retrieve status of all seismograph units (legacy)
|
||||
|
||||
### Photo Management
|
||||
- **GET** `/api/unit/{unit_id}/photos` - List photos for a unit
|
||||
- **GET** `/api/unit/{unit_id}/photo/{filename}` - Serve specific photo file
|
||||
|
||||
## API Documentation
|
||||
|
||||
Once running, interactive API documentation is available at:
|
||||
- **Swagger UI**: http://localhost:8000/docs
|
||||
- **ReDoc**: http://localhost:8000/redoc
|
||||
- **Swagger UI**: http://localhost:8001/docs
|
||||
- **ReDoc**: http://localhost:8001/redoc
|
||||
|
||||
## Testing the API
|
||||
|
||||
@@ -126,7 +139,7 @@ Once running, interactive API documentation is available at:
|
||||
|
||||
**Submit a report:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/emitters/report \
|
||||
curl -X POST http://localhost:8001/emitters/report \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"unit": "SEISMO-001",
|
||||
@@ -139,7 +152,14 @@ curl -X POST http://localhost:8000/emitters/report \
|
||||
|
||||
**Get fleet status:**
|
||||
```bash
|
||||
curl http://localhost:8000/fleet/status
|
||||
curl http://localhost:8001/api/roster
|
||||
```
|
||||
|
||||
**Import roster from CSV:**
|
||||
```bash
|
||||
curl -X POST http://localhost:8001/api/roster/import-csv \
|
||||
-F "file=@sample_roster.csv" \
|
||||
-F "update_existing=true"
|
||||
```
|
||||
|
||||
### Using Python
|
||||
@@ -150,7 +170,7 @@ from datetime import datetime
|
||||
|
||||
# Submit report
|
||||
response = requests.post(
|
||||
"http://localhost:8000/emitters/report",
|
||||
"http://localhost:8001/emitters/report",
|
||||
json={
|
||||
"unit": "SEISMO-001",
|
||||
"unit_type": "series3",
|
||||
@@ -162,13 +182,37 @@ response = requests.post(
|
||||
print(response.json())
|
||||
|
||||
# Get fleet status
|
||||
response = requests.get("http://localhost:8000/fleet/status")
|
||||
response = requests.get("http://localhost:8001/api/roster")
|
||||
print(response.json())
|
||||
|
||||
# Import CSV
|
||||
with open('roster.csv', 'rb') as f:
|
||||
files = {'file': f}
|
||||
data = {'update_existing': 'true'}
|
||||
response = requests.post(
|
||||
"http://localhost:8001/api/roster/import-csv",
|
||||
files=files,
|
||||
data=data
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
## Data Model
|
||||
|
||||
### Emitters Table
|
||||
### RosterUnit Table (Your Fleet Roster)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| id | string | Unit identifier (primary key) |
|
||||
| unit_type | string | Type of seismograph (default: "series3") |
|
||||
| deployed | boolean | Whether unit is deployed in field |
|
||||
| retired | boolean | Whether unit is retired from service |
|
||||
| note | string | Notes about the unit |
|
||||
| project_id | string | Associated project identifier |
|
||||
| location | string | Deployment location description |
|
||||
| last_updated | datetime | Last modification timestamp |
|
||||
|
||||
### Emitter Table (Device Check-ins)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
@@ -183,15 +227,34 @@ print(response.json())
|
||||
|
||||
```
|
||||
seismo-fleet-manager/
|
||||
├── main.py # FastAPI app entry point
|
||||
├── database.py # SQLAlchemy database configuration
|
||||
├── models.py # Database models
|
||||
├── routes.py # API endpoints
|
||||
├── backend/
|
||||
│ ├── main.py # FastAPI app entry point
|
||||
│ ├── database.py # SQLAlchemy database configuration
|
||||
│ ├── models.py # Database models (RosterUnit, Emitter)
|
||||
│ ├── routes.py # Legacy API endpoints
|
||||
│ ├── routers/ # Modular API routers
|
||||
│ │ ├── roster.py # Fleet status endpoints
|
||||
│ │ ├── roster_edit.py # Roster management & CSV import
|
||||
│ │ ├── units.py # Unit detail endpoints
|
||||
│ │ ├── photos.py # Photo management
|
||||
│ │ ├── dashboard.py # Dashboard partials
|
||||
│ │ └── dashboard_tabs.py # Dashboard tab endpoints
|
||||
│ ├── services/
|
||||
│ │ └── snapshot.py # Fleet status snapshot logic
|
||||
│ └── static/ # Static assets (CSS, etc.)
|
||||
├── templates/ # Jinja2 HTML templates
|
||||
│ ├── base.html # Base layout with sidebar
|
||||
│ ├── dashboard.html # Main dashboard
|
||||
│ ├── roster.html # Fleet roster table
|
||||
│ ├── unit_detail.html # Unit detail page
|
||||
│ └── partials/ # HTMX partial templates
|
||||
├── data/ # SQLite database & photos (persisted)
|
||||
├── requirements.txt # Python dependencies
|
||||
├── Dockerfile # Docker container definition
|
||||
├── docker-compose.yml # Docker Compose configuration
|
||||
├── .dockerignore # Docker ignore rules
|
||||
└── data/ # SQLite database directory (created at runtime)
|
||||
├── CHANGELOG.md # Version history
|
||||
├── FRONTEND_README.md # Frontend documentation
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
## Docker Commands
|
||||
@@ -221,6 +284,11 @@ docker compose logs -f seismo-backend
|
||||
docker compose restart
|
||||
```
|
||||
|
||||
**Rebuild and restart:**
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
**Stop and remove containers:**
|
||||
```bash
|
||||
docker compose down
|
||||
@@ -231,14 +299,25 @@ docker compose down
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## What's New in v0.1.1
|
||||
|
||||
- **Roster Editing API**: Full CRUD operations for managing your fleet roster
|
||||
- **CSV Import**: Bulk upload roster data from CSV files
|
||||
- **Enhanced Data Model**: Added project_id and location fields to roster
|
||||
- **Bug Fixes**: Improved database session management and error handling
|
||||
- **Dashboard Improvements**: Separate views for Active, Benched, and Retired units
|
||||
|
||||
See [CHANGELOG.md](CHANGELOG.md) for complete version history.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Automated status updates based on last_seen timestamps
|
||||
- Web-based dashboard for fleet monitoring
|
||||
- Email/SMS alerts for missing units
|
||||
- Historical data tracking and reporting
|
||||
- Multi-user authentication
|
||||
- PostgreSQL support for larger deployments
|
||||
- Advanced filtering and search
|
||||
- Export roster to various formats
|
||||
- Automated backup and restore
|
||||
|
||||
## License
|
||||
|
||||
@@ -246,4 +325,6 @@ MIT
|
||||
|
||||
## Version
|
||||
|
||||
0.1.0 - Initial Release
|
||||
**Current: 0.1.1** - Roster Management & CSV Import (2025-12-02)
|
||||
|
||||
Previous: 0.1.0 - Initial Release (2024-11-20)
|
||||
|
||||
@@ -24,3 +24,8 @@ def get_db():
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def get_db_session():
|
||||
"""Get a database session directly (not as a dependency)"""
|
||||
return SessionLocal()
|
||||
|
||||
@@ -5,7 +5,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from backend.database import engine, Base
|
||||
from backend.routers import roster, units, photos
|
||||
from backend.routers import roster, units, photos, roster_edit, dashboard, dashboard_tabs
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
# Create database tables
|
||||
@@ -15,7 +15,7 @@ Base.metadata.create_all(bind=engine)
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
version="0.1.0"
|
||||
version="0.1.1"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
@@ -37,6 +37,10 @@ templates = Jinja2Templates(directory="templates")
|
||||
app.include_router(roster.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(photos.router)
|
||||
app.include_router(roster_edit.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(dashboard_tabs.router)
|
||||
|
||||
|
||||
|
||||
# Legacy routes from the original backend
|
||||
@@ -98,8 +102,9 @@ async def roster_table_partial(request: Request):
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": "Seismo Fleet Manager v0.1",
|
||||
"status": "running"
|
||||
"message": "Seismo Fleet Manager v0.1.1",
|
||||
"status": "running",
|
||||
"version": "0.1.1"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,18 +1,31 @@
|
||||
from sqlalchemy import Column, String, DateTime
|
||||
from sqlalchemy import Column, String, DateTime, Boolean, Text
|
||||
from datetime import datetime
|
||||
from backend.database import Base
|
||||
|
||||
|
||||
class Emitter(Base):
|
||||
"""Emitter model representing a seismograph unit in the fleet"""
|
||||
__tablename__ = "emitters"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, nullable=False)
|
||||
last_seen = Column(DateTime, default=datetime.utcnow)
|
||||
last_file = Column(String, nullable=False)
|
||||
status = Column(String, nullable=False) # OK, Pending, Missing
|
||||
status = Column(String, nullable=False)
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Emitter(id={self.id}, type={self.unit_type}, status={self.status})>"
|
||||
|
||||
class RosterUnit(Base):
|
||||
"""
|
||||
Roster table: represents our *intended assignment* of a unit.
|
||||
This is editable from the GUI.
|
||||
"""
|
||||
__tablename__ = "roster"
|
||||
|
||||
id = Column(String, primary_key=True, index=True)
|
||||
unit_type = Column(String, default="series3")
|
||||
deployed = Column(Boolean, default=True)
|
||||
retired = Column(Boolean, default=False)
|
||||
note = Column(String, nullable=True)
|
||||
project_id = Column(String, nullable=True)
|
||||
location = Column(String, nullable=True)
|
||||
last_updated = Column(DateTime, default=datetime.utcnow)
|
||||
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}
|
||||
182
backend/routers/roster_edit.py
Normal file
182
backend/routers/roster_edit.py
Normal file
@@ -0,0 +1,182 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
import csv
|
||||
import io
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import RosterUnit
|
||||
|
||||
router = APIRouter(prefix="/api/roster", tags=["roster-edit"])
|
||||
|
||||
|
||||
def get_or_create_roster_unit(db: Session, unit_id: str):
|
||||
unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
if not unit:
|
||||
unit = RosterUnit(id=unit_id)
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
db.refresh(unit)
|
||||
return unit
|
||||
|
||||
|
||||
@router.post("/add")
|
||||
def add_roster_unit(
|
||||
id: str = Form(...),
|
||||
unit_type: str = Form("series3"),
|
||||
deployed: bool = Form(False),
|
||||
note: str = Form(""),
|
||||
project_id: str = Form(None),
|
||||
location: str = Form(None),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
if db.query(RosterUnit).filter(RosterUnit.id == id).first():
|
||||
raise HTTPException(status_code=400, detail="Unit already exists")
|
||||
|
||||
unit = RosterUnit(
|
||||
id=id,
|
||||
unit_type=unit_type,
|
||||
deployed=deployed,
|
||||
note=note,
|
||||
project_id=project_id,
|
||||
location=location,
|
||||
last_updated=datetime.utcnow(),
|
||||
)
|
||||
db.add(unit)
|
||||
db.commit()
|
||||
return {"message": "Unit added", "id": id}
|
||||
|
||||
|
||||
@router.post("/set-deployed/{unit_id}")
|
||||
def set_deployed(unit_id: str, deployed: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
unit.deployed = deployed
|
||||
unit.last_updated = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "deployed": deployed}
|
||||
|
||||
|
||||
@router.post("/set-retired/{unit_id}")
|
||||
def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
unit.retired = retired
|
||||
unit.last_updated = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "retired": retired}
|
||||
|
||||
|
||||
@router.post("/set-note/{unit_id}")
|
||||
def set_note(unit_id: str, note: str = Form(""), db: Session = Depends(get_db)):
|
||||
unit = get_or_create_roster_unit(db, unit_id)
|
||||
unit.note = note
|
||||
unit.last_updated = datetime.utcnow()
|
||||
db.commit()
|
||||
return {"message": "Updated", "id": unit_id, "note": note}
|
||||
|
||||
|
||||
@router.post("/import-csv")
|
||||
async def import_csv(
|
||||
file: UploadFile = File(...),
|
||||
update_existing: bool = Form(True),
|
||||
db: Session = Depends(get_db)
|
||||
):
|
||||
"""
|
||||
Import roster units from CSV file.
|
||||
|
||||
Expected CSV columns (unit_id is required, others are optional):
|
||||
- unit_id: Unique identifier for the unit
|
||||
- unit_type: Type of unit (default: "series3")
|
||||
- deployed: Boolean for deployment status (default: False)
|
||||
- retired: Boolean for retirement status (default: False)
|
||||
- note: Notes about the unit
|
||||
- project_id: Project identifier
|
||||
- location: Location description
|
||||
|
||||
Args:
|
||||
file: CSV file upload
|
||||
update_existing: If True, update existing units; if False, skip them
|
||||
"""
|
||||
|
||||
if not file.filename.endswith('.csv'):
|
||||
raise HTTPException(status_code=400, detail="File must be a CSV")
|
||||
|
||||
# Read file content
|
||||
contents = await file.read()
|
||||
csv_text = contents.decode('utf-8')
|
||||
csv_reader = csv.DictReader(io.StringIO(csv_text))
|
||||
|
||||
results = {
|
||||
"added": [],
|
||||
"updated": [],
|
||||
"skipped": [],
|
||||
"errors": []
|
||||
}
|
||||
|
||||
for row_num, row in enumerate(csv_reader, start=2): # Start at 2 to account for header
|
||||
try:
|
||||
# Validate required field
|
||||
unit_id = row.get('unit_id', '').strip()
|
||||
if not unit_id:
|
||||
results["errors"].append({
|
||||
"row": row_num,
|
||||
"error": "Missing required field: unit_id"
|
||||
})
|
||||
continue
|
||||
|
||||
# Check if unit exists
|
||||
existing_unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first()
|
||||
|
||||
if existing_unit:
|
||||
if not update_existing:
|
||||
results["skipped"].append(unit_id)
|
||||
continue
|
||||
|
||||
# Update existing unit
|
||||
existing_unit.unit_type = row.get('unit_type', existing_unit.unit_type or 'series3')
|
||||
existing_unit.deployed = row.get('deployed', '').lower() in ('true', '1', 'yes') if row.get('deployed') else existing_unit.deployed
|
||||
existing_unit.retired = row.get('retired', '').lower() in ('true', '1', 'yes') if row.get('retired') else existing_unit.retired
|
||||
existing_unit.note = row.get('note', existing_unit.note or '')
|
||||
existing_unit.project_id = row.get('project_id', existing_unit.project_id)
|
||||
existing_unit.location = row.get('location', existing_unit.location)
|
||||
existing_unit.last_updated = datetime.utcnow()
|
||||
|
||||
results["updated"].append(unit_id)
|
||||
else:
|
||||
# Create new unit
|
||||
new_unit = RosterUnit(
|
||||
id=unit_id,
|
||||
unit_type=row.get('unit_type', 'series3'),
|
||||
deployed=row.get('deployed', '').lower() in ('true', '1', 'yes'),
|
||||
retired=row.get('retired', '').lower() in ('true', '1', 'yes'),
|
||||
note=row.get('note', ''),
|
||||
project_id=row.get('project_id'),
|
||||
location=row.get('location'),
|
||||
last_updated=datetime.utcnow()
|
||||
)
|
||||
db.add(new_unit)
|
||||
results["added"].append(unit_id)
|
||||
|
||||
except Exception as e:
|
||||
results["errors"].append({
|
||||
"row": row_num,
|
||||
"unit_id": row.get('unit_id', 'unknown'),
|
||||
"error": str(e)
|
||||
})
|
||||
|
||||
# Commit all changes
|
||||
try:
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
|
||||
|
||||
return {
|
||||
"message": "CSV import completed",
|
||||
"summary": {
|
||||
"added": len(results["added"]),
|
||||
"updated": len(results["updated"]),
|
||||
"skipped": len(results["skipped"]),
|
||||
"errors": len(results["errors"])
|
||||
},
|
||||
"details": results
|
||||
}
|
||||
@@ -80,3 +80,82 @@ def get_fleet_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
emitters = db.query(Emitter).all()
|
||||
return emitters
|
||||
|
||||
# series3v1.1 Standardized Heartbeat Schema (multi-unit)
|
||||
from fastapi import Request
|
||||
|
||||
@router.post("/api/series3/heartbeat", status_code=200)
|
||||
async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Accepts a full telemetry payload from the Series3 emitter.
|
||||
Updates or inserts each unit into the database.
|
||||
"""
|
||||
payload = await request.json()
|
||||
|
||||
source = payload.get("source_id")
|
||||
units = payload.get("units", [])
|
||||
|
||||
print("\n=== Series 3 Heartbeat ===")
|
||||
print("Source:", source)
|
||||
print("Units received:", len(units))
|
||||
print("==========================\n")
|
||||
|
||||
results = []
|
||||
|
||||
for u in units:
|
||||
uid = u.get("unit_id")
|
||||
last_event_time = u.get("last_event_time")
|
||||
event_meta = u.get("event_metadata", {})
|
||||
age_minutes = u.get("age_minutes")
|
||||
|
||||
try:
|
||||
if last_event_time:
|
||||
ts = datetime.fromisoformat(last_event_time.replace("Z", "+00:00"))
|
||||
else:
|
||||
ts = None
|
||||
except:
|
||||
ts = None
|
||||
|
||||
# Pull from DB
|
||||
emitter = db.query(Emitter).filter(Emitter.id == uid).first()
|
||||
|
||||
# File name (from event_metadata)
|
||||
last_file = event_meta.get("file_name")
|
||||
status = "Unknown"
|
||||
|
||||
# Determine status based on age
|
||||
if age_minutes is None:
|
||||
status = "Missing"
|
||||
elif age_minutes > 24 * 60:
|
||||
status = "Missing"
|
||||
elif age_minutes > 12 * 60:
|
||||
status = "Pending"
|
||||
else:
|
||||
status = "OK"
|
||||
|
||||
if emitter:
|
||||
# Update existing
|
||||
emitter.last_seen = ts
|
||||
emitter.last_file = last_file
|
||||
emitter.status = status
|
||||
else:
|
||||
# Insert new
|
||||
emitter = Emitter(
|
||||
id=uid,
|
||||
unit_type="series3",
|
||||
last_seen=ts,
|
||||
last_file=last_file,
|
||||
status=status
|
||||
)
|
||||
db.add(emitter)
|
||||
|
||||
results.append({"unit": uid, "status": status})
|
||||
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
"message": "Heartbeat processed",
|
||||
"source": source,
|
||||
"units_processed": len(results),
|
||||
"results": results
|
||||
}
|
||||
|
||||
@@ -1,96 +1,121 @@
|
||||
"""
|
||||
Mock implementation of emit_status_snapshot().
|
||||
This will be replaced with real Series3 emitter logic by the user.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
from backend.database import get_db_session
|
||||
from backend.models import Emitter, RosterUnit
|
||||
|
||||
|
||||
def ensure_utc(dt):
|
||||
if dt is None:
|
||||
return None
|
||||
if dt.tzinfo is None:
|
||||
return dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_age(last_seen):
|
||||
if not last_seen:
|
||||
return "N/A"
|
||||
last_seen = ensure_utc(last_seen)
|
||||
now = datetime.now(timezone.utc)
|
||||
diff = now - last_seen
|
||||
hours = diff.total_seconds() // 3600
|
||||
mins = (diff.total_seconds() % 3600) // 60
|
||||
return f"{int(hours)}h {int(mins)}m"
|
||||
|
||||
|
||||
def emit_status_snapshot():
|
||||
"""
|
||||
Mock function that returns fleet status snapshot.
|
||||
In production, this will call the real Series3 emitter logic.
|
||||
|
||||
Returns a dictionary with unit statuses, ages, deployment status, etc.
|
||||
Merge roster (what we *intend*) with emitter data (what is *actually happening*).
|
||||
"""
|
||||
|
||||
# Mock data for demonstration
|
||||
mock_units = {
|
||||
"BE1234": {
|
||||
"status": "OK",
|
||||
"age": "1h 12m",
|
||||
"last": "2025-11-22 12:32:10",
|
||||
"fname": "evt_1234.mlg",
|
||||
"deployed": True,
|
||||
"note": "Bridge monitoring project - Golden Gate"
|
||||
},
|
||||
"BE5678": {
|
||||
"status": "Pending",
|
||||
"age": "2h 45m",
|
||||
"last": "2025-11-22 11:05:33",
|
||||
"fname": "evt_5678.mlg",
|
||||
"deployed": True,
|
||||
"note": "Dam structural analysis"
|
||||
},
|
||||
"BE9012": {
|
||||
"status": "Missing",
|
||||
"age": "5d 3h",
|
||||
"last": "2025-11-17 09:15:00",
|
||||
"fname": "evt_9012.mlg",
|
||||
"deployed": True,
|
||||
"note": "Tunnel excavation site"
|
||||
},
|
||||
"BE3456": {
|
||||
"status": "OK",
|
||||
"age": "30m",
|
||||
"last": "2025-11-22 13:20:45",
|
||||
"fname": "evt_3456.mlg",
|
||||
"deployed": False,
|
||||
"note": "Benched for maintenance"
|
||||
},
|
||||
"BE7890": {
|
||||
"status": "OK",
|
||||
"age": "15m",
|
||||
"last": "2025-11-22 13:35:22",
|
||||
"fname": "evt_7890.mlg",
|
||||
"deployed": True,
|
||||
"note": "Pipeline monitoring"
|
||||
},
|
||||
"BE2468": {
|
||||
"status": "Pending",
|
||||
"age": "4h 20m",
|
||||
"last": "2025-11-22 09:30:15",
|
||||
"fname": "evt_2468.mlg",
|
||||
"deployed": True,
|
||||
"note": "Building foundation survey"
|
||||
},
|
||||
"BE1357": {
|
||||
"status": "OK",
|
||||
"age": "45m",
|
||||
"last": "2025-11-22 13:05:00",
|
||||
"fname": "evt_1357.mlg",
|
||||
"deployed": False,
|
||||
"note": "Awaiting deployment"
|
||||
},
|
||||
"BE8642": {
|
||||
"status": "Missing",
|
||||
"age": "2d 12h",
|
||||
"last": "2025-11-20 01:30:00",
|
||||
"fname": "evt_8642.mlg",
|
||||
"deployed": True,
|
||||
"note": "Offshore platform - comms issue suspected"
|
||||
db = get_db_session()
|
||||
try:
|
||||
roster = {r.id: r for r in db.query(RosterUnit).all()}
|
||||
emitters = {e.id: e for e in db.query(Emitter).all()}
|
||||
|
||||
units = {}
|
||||
|
||||
# --- Merge roster entries first ---
|
||||
for unit_id, r in roster.items():
|
||||
e = emitters.get(unit_id)
|
||||
|
||||
if r.retired:
|
||||
# Retired units get separated later
|
||||
status = "Retired"
|
||||
age = "N/A"
|
||||
last_seen = None
|
||||
fname = ""
|
||||
else:
|
||||
if e:
|
||||
status = e.status
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
age = format_age(last_seen)
|
||||
fname = e.last_file
|
||||
else:
|
||||
# Rostered but no emitter data
|
||||
status = "Missing"
|
||||
last_seen = None
|
||||
age = "N/A"
|
||||
fname = ""
|
||||
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": status,
|
||||
"age": age,
|
||||
"last": last_seen.isoformat() if last_seen else None,
|
||||
"fname": fname,
|
||||
"deployed": r.deployed,
|
||||
"note": r.note or "",
|
||||
"retired": r.retired,
|
||||
}
|
||||
|
||||
# --- Add unexpected emitter-only units ---
|
||||
for unit_id, e in emitters.items():
|
||||
if unit_id not in roster:
|
||||
last_seen = ensure_utc(e.last_seen)
|
||||
units[unit_id] = {
|
||||
"id": unit_id,
|
||||
"status": e.status,
|
||||
"age": format_age(last_seen),
|
||||
"last": last_seen.isoformat(),
|
||||
"fname": e.last_file,
|
||||
"deployed": False, # default
|
||||
"note": "",
|
||||
"retired": False,
|
||||
}
|
||||
|
||||
# Separate buckets for UI
|
||||
active_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and u["deployed"]
|
||||
}
|
||||
|
||||
benched_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if not u["retired"] and not u["deployed"]
|
||||
}
|
||||
|
||||
retired_units = {
|
||||
uid: u for uid, u in units.items()
|
||||
if u["retired"]
|
||||
}
|
||||
|
||||
return {
|
||||
"units": mock_units,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total_units": len(mock_units),
|
||||
"deployed_units": sum(1 for u in mock_units.values() if u["deployed"]),
|
||||
"status_summary": {
|
||||
"OK": sum(1 for u in mock_units.values() if u["status"] == "OK"),
|
||||
"Pending": sum(1 for u in mock_units.values() if u["status"] == "Pending"),
|
||||
"Missing": sum(1 for u in mock_units.values() if u["status"] == "Missing")
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"units": units,
|
||||
"active": active_units,
|
||||
"benched": benched_units,
|
||||
"retired": retired_units,
|
||||
"summary": {
|
||||
"total": len(units),
|
||||
"active": len(active_units),
|
||||
"benched": len(benched_units),
|
||||
"retired": len(retired_units),
|
||||
"ok": sum(1 for u in units.values() if u["status"] == "OK"),
|
||||
"pending": sum(1 for u in units.values() if u["status"] == "Pending"),
|
||||
"missing": sum(1 for u in units.values() if u["status"] == "Missing"),
|
||||
}
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
6
sample_roster.csv
Normal file
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>
|
||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v0.1.1</p>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
|
||||
@@ -9,14 +9,21 @@
|
||||
</div>
|
||||
|
||||
<!-- Dashboard cards with auto-refresh -->
|
||||
<div hx-get="/api/status-snapshot" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateDashboard(event)">
|
||||
<div hx-get="/api/status-snapshot"
|
||||
hx-trigger="load, every 10s"
|
||||
hx-swap="none"
|
||||
hx-on::after-request="updateDashboard(event)">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
|
||||
<!-- Fleet Summary Card -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
|
||||
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
@@ -59,7 +66,9 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
|
||||
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<div id="alerts-list" class="space-y-3">
|
||||
@@ -72,56 +81,104 @@
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
|
||||
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="text-center text-gray-500 dark:text-gray-400">
|
||||
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z">
|
||||
</path>
|
||||
</svg>
|
||||
<p class="text-sm">No recent photos</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Quick Access to Fleet Roster -->
|
||||
<!-- Fleet Status Section with Tabs -->
|
||||
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
|
||||
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
|
||||
|
||||
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center">
|
||||
View All
|
||||
Full Roster
|
||||
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
<div id="fleet-preview" class="space-y-2">
|
||||
|
||||
<!-- Tab Bar -->
|
||||
<div class="flex border-b border-gray-200 dark:border-gray-700 mb-4">
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button active-tab"
|
||||
hx-get="/dashboard/active"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Active
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="px-4 py-2 text-sm font-medium tab-button"
|
||||
hx-get="/dashboard/benched"
|
||||
hx-target="#fleet-table"
|
||||
hx-swap="innerHTML">
|
||||
Benched
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Tab Content Target -->
|
||||
<div id="fleet-table" class="space-y-2">
|
||||
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- TAB STYLE -->
|
||||
<style>
|
||||
.tab-button {
|
||||
color: #6b7280; /* gray-500 */
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
.tab-button:hover {
|
||||
color: #374151; /* gray-700 */
|
||||
}
|
||||
.active-tab {
|
||||
color: #b84a12 !important; /* seismo orange */
|
||||
border-bottom: 2px solid #b84a12 !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
||||
<script>
|
||||
function updateDashboard(event) {
|
||||
try {
|
||||
const data = JSON.parse(event.detail.xhr.response);
|
||||
|
||||
// Update fleet summary
|
||||
document.getElementById('total-units').textContent = data.total_units || 0;
|
||||
document.getElementById('deployed-units').textContent = data.deployed_units || 0;
|
||||
document.getElementById('status-ok').textContent = data.status_summary.OK || 0;
|
||||
document.getElementById('status-pending').textContent = data.status_summary.Pending || 0;
|
||||
document.getElementById('status-missing').textContent = data.status_summary.Missing || 0;
|
||||
// ===== Fleet summary numbers =====
|
||||
document.getElementById('total-units').textContent = data.total_units ?? 0;
|
||||
document.getElementById('deployed-units').textContent = data.deployed_units ?? 0;
|
||||
document.getElementById('status-ok').textContent = data.status_summary.OK ?? 0;
|
||||
document.getElementById('status-pending').textContent = data.status_summary.Pending ?? 0;
|
||||
document.getElementById('status-missing').textContent = data.status_summary.Missing ?? 0;
|
||||
|
||||
// Update alerts list
|
||||
// ===== Alerts =====
|
||||
const alertsList = document.getElementById('alerts-list');
|
||||
const missingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Missing');
|
||||
const pendingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Pending');
|
||||
const missingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Missing');
|
||||
const pendingUnits = Object.entries(data.units).filter(([_, u]) => u.status === 'Pending');
|
||||
|
||||
if (missingUnits.length === 0 && pendingUnits.length === 0) {
|
||||
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||
if (!missingUnits.length && !pendingUnits.length) {
|
||||
alertsList.innerHTML =
|
||||
'<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
|
||||
} else {
|
||||
let alertsHtml = '';
|
||||
|
||||
missingUnits.slice(0, 3).forEach(([id, unit]) => {
|
||||
alertsHtml += `
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
@@ -130,9 +187,9 @@ function updateDashboard(event) {
|
||||
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
|
||||
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
});
|
||||
|
||||
pendingUnits.slice(0, 2).forEach(([id, unit]) => {
|
||||
alertsHtml += `
|
||||
<div class="flex items-start space-x-2 text-sm">
|
||||
@@ -141,38 +198,16 @@ function updateDashboard(event) {
|
||||
<a href="/unit/${id}" class="font-medium text-yellow-600 dark:text-yellow-400 hover:underline">${id}</a>
|
||||
<p class="text-gray-600 dark:text-gray-400">Pending for ${unit.age}</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
</div>`;
|
||||
});
|
||||
|
||||
alertsList.innerHTML = alertsHtml;
|
||||
}
|
||||
|
||||
// Update fleet preview
|
||||
const fleetPreview = document.getElementById('fleet-preview');
|
||||
const unitsList = Object.entries(data.units).slice(0, 5);
|
||||
let previewHtml = '';
|
||||
|
||||
unitsList.forEach(([id, unit]) => {
|
||||
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'yellow' : 'red';
|
||||
const deployedDot = unit.deployed ? '<span class="w-2 h-2 rounded-full bg-blue-500"></span>' : '';
|
||||
|
||||
previewHtml += `
|
||||
<a href="/unit/${id}" class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<div class="flex items-center space-x-3">
|
||||
<span class="w-3 h-3 rounded-full bg-${statusColor}-500"></span>
|
||||
${deployedDot}
|
||||
<span class="font-medium">${id}</span>
|
||||
</div>
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">${unit.age}</span>
|
||||
</a>
|
||||
`;
|
||||
});
|
||||
|
||||
fleetPreview.innerHTML = previewHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error updating dashboard:', error);
|
||||
} catch (err) {
|
||||
console.error("Dashboard update error:", err);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
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 %}
|
||||
@@ -4,8 +4,26 @@
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-8">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button onclick="openAddUnitModal()" class="px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg flex items-center gap-2 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
|
||||
</svg>
|
||||
Add Unit
|
||||
</button>
|
||||
<button onclick="openImportModal()" class="px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg flex items-center gap-2 transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
Import CSV
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto-refresh roster every 10 seconds -->
|
||||
@@ -26,4 +44,185 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Unit Modal -->
|
||||
<div id="addUnitModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4 max-h-[90vh] overflow-y-auto">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Add Unit</h2>
|
||||
<button onclick="closeAddUnitModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="addUnitForm" hx-post="/api/roster/add" hx-swap="none" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit ID *</label>
|
||||
<input type="text" name="id" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="BE1234">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Unit Type</label>
|
||||
<input type="text" name="unit_type" value="series3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project ID</label>
|
||||
<input type="text" name="project_id"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="PROJ-001">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Location</label>
|
||||
<input type="text" name="location"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="San Francisco, CA">
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" name="deployed" value="true" checked
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea name="note" rows="3"
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange"
|
||||
placeholder="Additional notes..."></textarea>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors">
|
||||
Add Unit
|
||||
</button>
|
||||
<button type="button" onclick="closeAddUnitModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import CSV Modal -->
|
||||
<div id="importModal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl max-w-lg w-full mx-4">
|
||||
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex justify-between items-center">
|
||||
<h2 class="text-2xl font-bold text-gray-900 dark:text-white">Import CSV</h2>
|
||||
<button onclick="closeImportModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form id="importForm" class="p-6 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSV File *</label>
|
||||
<input type="file" name="file" accept=".csv" required
|
||||
class="w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Format: unit_id,unit_type,deployed,retired,note,project_id,location</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<input type="checkbox" name="update_existing" id="updateExisting" checked
|
||||
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
|
||||
<label for="updateExisting" class="text-sm text-gray-700 dark:text-gray-300 cursor-pointer">Update existing units</label>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button type="submit" class="flex-1 px-4 py-2 bg-seismo-navy hover:bg-blue-800 text-white rounded-lg transition-colors">
|
||||
Import
|
||||
</button>
|
||||
<button type="button" onclick="closeImportModal()" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 hover:bg-gray-400 dark:hover:bg-gray-500 text-gray-700 dark:text-white rounded-lg transition-colors">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
<div id="importResult" class="hidden mt-4 p-4 rounded-lg"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Add Unit Modal
|
||||
function openAddUnitModal() {
|
||||
document.getElementById('addUnitModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeAddUnitModal() {
|
||||
document.getElementById('addUnitModal').classList.add('hidden');
|
||||
document.getElementById('addUnitForm').reset();
|
||||
}
|
||||
|
||||
// Import Modal
|
||||
function openImportModal() {
|
||||
document.getElementById('importModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeImportModal() {
|
||||
document.getElementById('importModal').classList.add('hidden');
|
||||
document.getElementById('importForm').reset();
|
||||
document.getElementById('importResult').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Handle Add Unit form submission
|
||||
document.getElementById('addUnitForm').addEventListener('htmx:afterRequest', function(event) {
|
||||
if (event.detail.successful) {
|
||||
closeAddUnitModal();
|
||||
// Trigger roster refresh
|
||||
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
|
||||
// Show success message
|
||||
alert('Unit added successfully!');
|
||||
} else {
|
||||
alert('Error adding unit. Please check the form and try again.');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle CSV Import
|
||||
document.getElementById('importForm').addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const resultDiv = document.getElementById('importResult');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/roster/import-csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
resultDiv.className = 'mt-4 p-4 rounded-lg bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200';
|
||||
resultDiv.innerHTML = `
|
||||
<p class="font-semibold mb-2">Import Successful!</p>
|
||||
<ul class="text-sm space-y-1">
|
||||
<li>✅ Added: ${result.summary.added}</li>
|
||||
<li>🔄 Updated: ${result.summary.updated}</li>
|
||||
<li>⏭️ Skipped: ${result.summary.skipped}</li>
|
||||
<li>❌ Errors: ${result.summary.errors}</li>
|
||||
</ul>
|
||||
`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
|
||||
// Trigger roster refresh
|
||||
htmx.trigger(document.querySelector('[hx-get="/partials/roster-table"]'), 'load');
|
||||
|
||||
// Close modal after 2 seconds
|
||||
setTimeout(() => closeImportModal(), 2000);
|
||||
} else {
|
||||
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${result.detail || 'Unknown error'}</p>`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
}
|
||||
} catch (error) {
|
||||
resultDiv.className = 'mt-4 p-4 rounded-lg bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200';
|
||||
resultDiv.innerHTML = `<p class="font-semibold">Import Failed</p><p class="text-sm">${error.message}</p>`;
|
||||
resultDiv.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user