From 90ecada35f47c6a3aca2dcc789996250923f1fd6 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 2 Dec 2025 06:36:13 +0000 Subject: [PATCH 1/2] v0.1.1 update --- CHANGELOG.md | 63 +++++++ README.md | 215 +++++++++++++++------- backend/database.py | 5 + backend/main.py | 13 +- backend/models.py | 23 ++- backend/routers/dashboard.py | 25 +++ backend/routers/dashboard_tabs.py | 34 ++++ backend/routers/roster_edit.py | 178 ++++++++++++++++++ backend/routes.py | 79 ++++++++ backend/services/snapshot.py | 199 +++++++++++--------- sample_roster.csv | 6 + templates/base.html | 1 + templates/dashboard.html | 151 +++++++++------ templates/partials/active_table.html | 25 +++ templates/partials/benched_table.html | 25 +++ templates/partials/dashboard_active.html | 23 +++ templates/partials/dashboard_benched.html | 23 +++ 17 files changed, 867 insertions(+), 221 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 backend/routers/dashboard.py create mode 100644 backend/routers/dashboard_tabs.py create mode 100644 backend/routers/roster_edit.py create mode 100644 sample_roster.csv create mode 100644 templates/partials/active_table.html create mode 100644 templates/partials/benched_table.html create mode 100644 templates/partials/dashboard_active.html create mode 100644 templates/partials/dashboard_benched.html diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..858c54f --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index ee96d16..115d204 100644 --- a/README.md +++ b/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 -├── 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) +├── 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 +├── 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) diff --git a/backend/database.py b/backend/database.py index 49ad5d9..7889459 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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() diff --git a/backend/main.py b/backend/main.py index 9e75d26..35e5048 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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" } diff --git a/backend/models.py b/backend/models.py index 64490fc..19a0e92 100644 --- a/backend/models.py +++ b/backend/models.py @@ -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"" + +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) \ No newline at end of file diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..525edec --- /dev/null +++ b/backend/routers/dashboard.py @@ -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"]} + ) diff --git a/backend/routers/dashboard_tabs.py b/backend/routers/dashboard_tabs.py new file mode 100644 index 0000000..607ead2 --- /dev/null +++ b/backend/routers/dashboard_tabs.py @@ -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} diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py new file mode 100644 index 0000000..ca598ec --- /dev/null +++ b/backend/routers/roster_edit.py @@ -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 + } diff --git a/backend/routes.py b/backend/routes.py index e7e34da..fd39489 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -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 + } diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index 8d7e38d..cf01943 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -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()} - 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") + 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 { + "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() diff --git a/sample_roster.csv b/sample_roster.csv new file mode 100644 index 0000000..c54c894 --- /dev/null +++ b/sample_roster.csv @@ -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 diff --git a/templates/base.html b/templates/base.html index 155f6ac..eb72c35 100644 --- a/templates/base.html +++ b/templates/base.html @@ -68,6 +68,7 @@ Seismo
Fleet Manager +

v0.1.1

diff --git a/templates/dashboard.html b/templates/dashboard.html index 3430a4f..26e7095 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -9,14 +9,21 @@ -
+
+
+

Fleet Summary

- + +
@@ -59,7 +66,9 @@

Recent Alerts

- + +
@@ -72,107 +81,133 @@

Recent Photos

- + +
- + +

No recent photos

+
- +
+ -
+ + +
+ + + +
+ + +

Loading fleet data...

+
+ + + + + + {% endblock %} diff --git a/templates/partials/active_table.html b/templates/partials/active_table.html new file mode 100644 index 0000000..a680d88 --- /dev/null +++ b/templates/partials/active_table.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + {% for uid, u in units.items() %} + + + + + + + + + {% endfor %} + +
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
diff --git a/templates/partials/benched_table.html b/templates/partials/benched_table.html new file mode 100644 index 0000000..a680d88 --- /dev/null +++ b/templates/partials/benched_table.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + {% for uid, u in units.items() %} + + + + + + + + + {% endfor %} + +
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
diff --git a/templates/partials/dashboard_active.html b/templates/partials/dashboard_active.html new file mode 100644 index 0000000..3c1cb60 --- /dev/null +++ b/templates/partials/dashboard_active.html @@ -0,0 +1,23 @@ +{% for id, unit in units.items() %} + + +
+ + + + + + {{ id }} +
+ + {{ unit.age }} +
+{% endfor %} + +{% if units|length == 0 %} +

No active units

+{% endif %} diff --git a/templates/partials/dashboard_benched.html b/templates/partials/dashboard_benched.html new file mode 100644 index 0000000..4d7a025 --- /dev/null +++ b/templates/partials/dashboard_benched.html @@ -0,0 +1,23 @@ +{% for id, unit in units.items() %} + + +
+ + + + + + {{ id }} +
+ + {{ unit.age }} +
+{% endfor %} + +{% if units|length == 0 %} +

No benched units

+{% endif %} From e46f668c340d67ccffa3b7d07f7a888e69e476f1 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 2 Dec 2025 07:53:16 +0000 Subject: [PATCH 2/2] added frontend unit addition/editing --- backend/routers/roster_edit.py | 6 +- templates/roster.html | 203 ++++++++++++++++++++++++++++++++- 2 files changed, 206 insertions(+), 3 deletions(-) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index ca598ec..257f00f 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -24,8 +24,10 @@ def get_or_create_roster_unit(db: Session, unit_id: str): def add_roster_unit( id: str = Form(...), unit_type: str = Form("series3"), - deployed: bool = Form(True), + 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(): @@ -36,6 +38,8 @@ def add_roster_unit( unit_type=unit_type, deployed=deployed, note=note, + project_id=project_id, + location=location, last_updated=datetime.utcnow(), ) db.add(unit) diff --git a/templates/roster.html b/templates/roster.html index c26f2f5..78b8608 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -4,8 +4,26 @@ {% block content %}
-

Fleet Roster

-

Real-time status of all seismograph units

+
+
+

Fleet Roster

+

Real-time status of all seismograph units

+
+
+ + +
+
@@ -26,4 +44,185 @@
+ + + + + + + + {% endblock %}