From f976e4e893b7c4d96029e472dda9980cd0fa0f98 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 18:14:29 +0000 Subject: [PATCH 01/29] Add Seismo Fleet Manager backend v0.1 Implemented FastAPI backend with SQLite database for tracking seismograph fleet status: - database.py: SQLAlchemy setup with SQLite - models.py: Emitter model with id, unit_type, last_seen, last_file, status, notes - routes.py: POST /emitters/report and GET /fleet/status endpoints - main.py: FastAPI app initialization with CORS support - requirements.txt: Dependencies (FastAPI, SQLAlchemy, uvicorn) --- database.py | 22 +++++++++++++ main.py | 41 ++++++++++++++++++++++++ models.py | 18 +++++++++++ requirements.txt | 5 +++ routes.py | 82 ++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 168 insertions(+) create mode 100644 database.py create mode 100644 main.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routes.py diff --git a/database.py b/database.py new file mode 100644 index 0000000..bb191d9 --- /dev/null +++ b/database.py @@ -0,0 +1,22 @@ +from sqlalchemy import create_engine +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import sessionmaker + +SQLALCHEMY_DATABASE_URL = "sqlite:///./seismo_fleet.db" + +engine = create_engine( + SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} +) + +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) + +Base = declarative_base() + + +def get_db(): + """Dependency for database sessions""" + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/main.py b/main.py new file mode 100644 index 0000000..9458fd8 --- /dev/null +++ b/main.py @@ -0,0 +1,41 @@ +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from database import engine, Base +from routes import router + +# Create database tables +Base.metadata.create_all(bind=engine) + +# Initialize FastAPI app +app = FastAPI( + title="Seismo Fleet Manager", + description="Backend API for managing seismograph fleet status", + version="0.1.0" +) + +# Configure CORS (adjust origins as needed for your deployment) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], # In production, specify exact origins + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Include API routes +app.include_router(router) + + +@app.get("/") +def root(): + """Root endpoint - health check""" + return { + "message": "Seismo Fleet Manager API v0.1", + "status": "running" + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/models.py b/models.py new file mode 100644 index 0000000..9ae42c1 --- /dev/null +++ b/models.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, String, DateTime +from datetime import datetime +from 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 + notes = Column(String, nullable=True) + + def __repr__(self): + return f"" diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..4ab590c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +sqlalchemy==2.0.23 +pydantic==2.5.0 +python-multipart==0.0.6 diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..4cdb4fa --- /dev/null +++ b/routes.py @@ -0,0 +1,82 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from datetime import datetime +from typing import Optional, List + +from database import get_db +from models import Emitter + +router = APIRouter() + + +# Pydantic schemas for request/response validation +class EmitterReport(BaseModel): + unit: str + unit_type: str + timestamp: str + file: str + status: str + + +class EmitterResponse(BaseModel): + id: str + unit_type: str + last_seen: datetime + last_file: str + status: str + notes: Optional[str] = None + + class Config: + from_attributes = True + + +@router.post("/emitters/report", status_code=200) +def report_emitter(report: EmitterReport, db: Session = Depends(get_db)): + """ + Endpoint for emitters to report their status. + Creates a new emitter if it doesn't exist, or updates an existing one. + """ + try: + # Parse the timestamp + timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00')) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid timestamp format") + + # Check if emitter already exists + emitter = db.query(Emitter).filter(Emitter.id == report.unit).first() + + if emitter: + # Update existing emitter + emitter.unit_type = report.unit_type + emitter.last_seen = timestamp + emitter.last_file = report.file + emitter.status = report.status + else: + # Create new emitter + emitter = Emitter( + id=report.unit, + unit_type=report.unit_type, + last_seen=timestamp, + last_file=report.file, + status=report.status + ) + db.add(emitter) + + db.commit() + db.refresh(emitter) + + return { + "message": "Emitter report received", + "unit": emitter.id, + "status": emitter.status + } + + +@router.get("/fleet/status", response_model=List[EmitterResponse]) +def get_fleet_status(db: Session = Depends(get_db)): + """ + Returns a list of all emitters and their current status. + """ + emitters = db.query(Emitter).all() + return emitters -- 2.49.1 From 05c63367c8f4e60f250b3357b246c2292b581905 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 18:46:46 +0000 Subject: [PATCH 02/29] Containerize backend with Docker Compose Added Docker support for easy deployment: - Dockerfile: Python 3.11 slim image with FastAPI app - docker-compose.yml: Service definition with volume mounting for data persistence - .dockerignore: Exclude unnecessary files from Docker build - database.py: Updated to store SQLite DB in ./data directory for volume persistence - .gitignore: Added entries for database files and data directory - README.md: Comprehensive documentation with Docker and local setup instructions The application can now be run with: docker compose up -d Database persists in ./data directory mounted as a volume --- .dockerignore | 19 ++++ .gitignore | 6 ++ Dockerfile | 19 ++++ README.md | 251 ++++++++++++++++++++++++++++++++++++++++++++- database.py | 6 +- docker-compose.yml | 23 +++++ 6 files changed, 321 insertions(+), 3 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e809327 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +*.so +*.egg +*.egg-info +dist +build +.git +.gitignore +*.db +*.db-journal +.env +.venv +venv/ +ENV/ +data/ diff --git a/.gitignore b/.gitignore index b7faf40..b697ede 100644 --- a/.gitignore +++ b/.gitignore @@ -205,3 +205,9 @@ cython_debug/ marimo/_static/ marimo/_lsp/ __marimo__/ + +# Seismo Fleet Manager +# SQLite database files +*.db +*.db-journal +data/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3533224 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,19 @@ +FROM python:3.11-slim + +# Set working directory +WORKDIR /app + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Expose port +EXPOSE 8000 + +# Run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md index 661646a..ee96d16 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,249 @@ -# seismo-fleet-manager -Web app and backend for tracking deployed units. +# Seismo Fleet Manager - Backend v0.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. + +## Features + +- **Fleet Monitoring**: Track all seismograph units in one place +- **Status Management**: Automatically mark units as OK, Pending (>12h), or Missing (>24h) +- **Data Ingestion**: Accept reports from emitter scripts via REST API +- **SQLite Storage**: Lightweight, file-based database for easy deployment + +## Tech Stack + +- **FastAPI**: Modern, fast web framework +- **SQLAlchemy**: SQL toolkit and ORM +- **SQLite**: Lightweight database +- **uvicorn**: ASGI server +- **Docker**: Containerization for easy deployment + +## Quick Start with Docker Compose (Recommended) + +### Prerequisites +- Docker and Docker Compose installed + +### Running the Application + +1. **Start the service:** + ```bash + docker compose up -d + ``` + +2. **Check logs:** + ```bash + docker compose logs -f + ``` + +3. **Stop the service:** + ```bash + docker compose down + ``` + +The API will be available at `http://localhost:8000` + +### 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. + +## Local Development (Without Docker) + +### Prerequisites +- Python 3.11+ +- pip + +### Setup + +1. **Install dependencies:** + ```bash + pip install -r requirements.txt + ``` + +2. **Run the server:** + ```bash + python main.py + ``` + + Or with auto-reload: + ```bash + uvicorn main:app --reload + ``` + +The API will be available at `http://localhost:8000` + +## API Endpoints + +### Root +- **GET** `/` - Health check + +### 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" + } + ``` +- **Response:** + ```json + { + "message": "Emitter report received", + "unit": "SEISMO-001", + "status": "OK" + } + ``` + +### 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 + } + ] + ``` + +## API Documentation + +Once running, interactive API documentation is available at: +- **Swagger UI**: http://localhost:8000/docs +- **ReDoc**: http://localhost:8000/redoc + +## Testing the API + +### Using curl + +**Submit a report:** +```bash +curl -X POST http://localhost:8000/emitters/report \ + -H "Content-Type: application/json" \ + -d '{ + "unit": "SEISMO-001", + "unit_type": "series3", + "timestamp": "2025-11-20T10:30:00", + "file": "event_20251120_103000.dat", + "status": "OK" + }' +``` + +**Get fleet status:** +```bash +curl http://localhost:8000/fleet/status +``` + +### Using Python + +```python +import requests +from datetime import datetime + +# Submit report +response = requests.post( + "http://localhost:8000/emitters/report", + json={ + "unit": "SEISMO-001", + "unit_type": "series3", + "timestamp": datetime.utcnow().isoformat(), + "file": "event_20251120_103000.dat", + "status": "OK" + } +) +print(response.json()) + +# Get fleet status +response = requests.get("http://localhost:8000/fleet/status") +print(response.json()) +``` + +## Data Model + +### Emitters Table + +| Field | Type | Description | +|-------|------|-------------| +| id | string | Unit identifier (primary key) | +| unit_type | string | Type of seismograph (e.g., "series3") | +| last_seen | datetime | Last report timestamp | +| last_file | string | Last file processed | +| status | string | Current status: OK, Pending, Missing | +| notes | string | Optional notes (nullable) | + +## Project Structure + +``` +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) +``` + +## Docker Commands + +**Build the image:** +```bash +docker compose build +``` + +**Start in foreground:** +```bash +docker compose up +``` + +**Start in background:** +```bash +docker compose up -d +``` + +**View logs:** +```bash +docker compose logs -f seismo-backend +``` + +**Restart service:** +```bash +docker compose restart +``` + +**Stop and remove containers:** +```bash +docker compose down +``` + +**Remove containers and volumes:** +```bash +docker compose down -v +``` + +## 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 + +## License + +MIT + +## Version + +0.1.0 - Initial Release diff --git a/database.py b/database.py index bb191d9..49ad5d9 100644 --- a/database.py +++ b/database.py @@ -1,8 +1,12 @@ from sqlalchemy import create_engine from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker +import os -SQLALCHEMY_DATABASE_URL = "sqlite:///./seismo_fleet.db" +# Ensure data directory exists +os.makedirs("data", exist_ok=True) + +SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db" engine = create_engine( SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b84fce --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +version: '3.8' + +services: + seismo-backend: + build: . + container_name: seismo-fleet-manager + ports: + - "8000:8000" + volumes: + # Persist SQLite database + - ./data:/app/data + environment: + - PYTHONUNBUFFERED=1 + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + +volumes: + data: -- 2.49.1 From 36ce63feb176181046e1a425343f57ed1b86d434 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Nov 2025 19:33:39 +0000 Subject: [PATCH 03/29] Change exposed port from 8000 to 8001 to avoid port conflict --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 0b84fce..6214c26 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: build: . container_name: seismo-fleet-manager ports: - - "8000:8000" + - "8001:8000" volumes: # Persist SQLite database - ./data:/app/data -- 2.49.1 From 247405c361f6a808a222563ab2215fec35d23d90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 22 Nov 2025 00:16:26 +0000 Subject: [PATCH 04/29] Add MVP frontend scaffold with FastAPI + HTMX + TailwindCSS - Created complete frontend structure with Jinja2 templates - Implemented three main pages: Dashboard, Fleet Roster, and Unit Detail - Added HTMX auto-refresh for real-time updates (10s interval) - Integrated dark/light mode toggle with localStorage persistence - Built responsive card-based UI with sidebar navigation - Created API endpoints for status snapshot, roster, unit details, and photos - Added mock data service for development (emit_status_snapshot) - Implemented tabbed interface on unit detail page (Photos, Map, History) - Integrated Leaflet maps for unit location visualization - Configured static file serving and photo management - Updated requirements.txt with Jinja2 and aiofiles - Reorganized backend structure into routers and services - Added comprehensive FRONTEND_README.md documentation Frontend features: - Auto-refreshing dashboard with fleet summary and alerts - Sortable fleet roster table (prioritizes Missing > Pending > OK) - Unit detail view with status, deployment info, and notes - Photo gallery with thumbnail navigation - Interactive maps showing unit coordinates - Consistent styling with brand colors (orange, navy, burgundy) Ready for integration with real Series3 emitter data. --- FRONTEND_README.md | 303 +++++++++++++++++++++++++++ database.py => backend/database.py | 0 backend/main.py | 108 ++++++++++ models.py => backend/models.py | 2 +- backend/routers/photos.py | 64 ++++++ backend/routers/roster.py | 46 ++++ backend/routers/units.py | 44 ++++ routes.py => backend/routes.py | 4 +- backend/services/snapshot.py | 96 +++++++++ backend/static/style.css | 12 ++ requirements.txt | 2 + templates/base.html | 150 +++++++++++++ templates/dashboard.html | 178 ++++++++++++++++ templates/partials/roster_table.html | 87 ++++++++ templates/roster.html | 29 +++ templates/unit_detail.html | 268 +++++++++++++++++++++++ 16 files changed, 1390 insertions(+), 3 deletions(-) create mode 100644 FRONTEND_README.md rename database.py => backend/database.py (100%) create mode 100644 backend/main.py rename models.py => backend/models.py (94%) create mode 100644 backend/routers/photos.py create mode 100644 backend/routers/roster.py create mode 100644 backend/routers/units.py rename routes.py => backend/routes.py (96%) create mode 100644 backend/services/snapshot.py create mode 100644 backend/static/style.css create mode 100644 templates/base.html create mode 100644 templates/dashboard.html create mode 100644 templates/partials/roster_table.html create mode 100644 templates/roster.html create mode 100644 templates/unit_detail.html diff --git a/FRONTEND_README.md b/FRONTEND_README.md new file mode 100644 index 0000000..b9e2fdb --- /dev/null +++ b/FRONTEND_README.md @@ -0,0 +1,303 @@ +# Seismo Fleet Manager - Frontend Documentation + +## Overview + +This is the MVP frontend scaffold for **Seismo Fleet Manager**, built with: +- **FastAPI** (backend framework) +- **HTMX** (dynamic updates without JavaScript frameworks) +- **TailwindCSS** (utility-first styling) +- **Jinja2** (server-side templating) +- **Leaflet** (interactive maps) + +No React, Vue, or other frontend frameworks are used. + +## Project Structure + +``` +seismo-fleet-manager/ +├── backend/ +│ ├── main.py # FastAPI app entry point +│ ├── routers/ +│ │ ├── roster.py # Fleet roster endpoints +│ │ ├── units.py # Individual unit endpoints +│ │ └── photos.py # Photo management endpoints +│ ├── services/ +│ │ └── snapshot.py # Mock status snapshot (replace with real logic) +│ ├── static/ +│ │ └── style.css # Custom CSS +│ ├── database.py # SQLAlchemy database setup +│ ├── models.py # Database models +│ └── routes.py # Legacy API routes +├── templates/ +│ ├── base.html # Base layout with sidebar & dark mode +│ ├── dashboard.html # Main dashboard page +│ ├── roster.html # Fleet roster page +│ ├── unit_detail.html # Unit detail page +│ └── partials/ +│ └── roster_table.html # HTMX partial for roster table +├── data/ +│ └── photos/ # Photo storage (organized by unit_id) +└── requirements.txt +``` + +## Running the Application + +### Install Dependencies + +```bash +pip install -r requirements.txt +``` + +### Run the Server + +```bash +uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload +``` + +The application will be available at: +- **Web UI**: http://localhost:8001/ +- **API Docs**: http://localhost:8001/docs +- **Health Check**: http://localhost:8001/health + +## Features + +### 1. Dashboard (`/`) + +The main dashboard provides an at-a-glance view of the fleet: + +- **Fleet Summary Card**: Total units, deployed units, status breakdown +- **Recent Alerts Card**: Shows units with Missing or Pending status +- **Recent Photos Card**: Placeholder for photo gallery +- **Fleet Status Preview**: Quick view of first 5 units + +**Auto-refresh**: Dashboard updates every 10 seconds via HTMX + +### 2. Fleet Roster (`/roster`) + +A comprehensive table view of all seismograph units: + +**Columns**: +- Status indicator (colored dot: green=OK, yellow=Pending, red=Missing) +- Deployment indicator (blue dot if deployed) +- Unit ID +- Last seen timestamp +- Age since last contact +- Notes +- Actions (View detail button) + +**Features**: +- Auto-refresh every 10 seconds +- Sorted by priority (Missing > Pending > OK) +- Click any row to view unit details + +### 3. Unit Detail Page (`/unit/{unit_id}`) + +Split-screen layout with detailed information: + +**Left Column**: +- Status card with real-time updates +- Deployment status +- Last contact time and file +- Notes section +- Editable metadata (mock form) + +**Right Column - Tabbed Interface**: +- **Photos Tab**: Primary photo with thumbnail gallery +- **Map Tab**: Interactive Leaflet map showing unit location +- **History Tab**: Placeholder for event history + +**Auto-refresh**: Unit data updates every 10 seconds + +### 4. Dark/Light Mode + +Toggle button in sidebar switches between themes: +- Uses Tailwind's `dark:` classes +- Preference saved to localStorage +- Smooth transitions on theme change + +## API Endpoints + +### Status & Fleet Data + +```http +GET /api/status-snapshot +``` +Returns complete fleet status snapshot with statistics. + +```http +GET /api/roster +``` +Returns sorted list of all units for roster table. + +```http +GET /api/unit/{unit_id} +``` +Returns detailed information for a single unit including coordinates. + +### Photo Management + +```http +GET /api/unit/{unit_id}/photos +``` +Returns list of photos for a unit, sorted by recency. + +```http +GET /api/unit/{unit_id}/photo/{filename} +``` +Serves a specific photo file. + +### Legacy Endpoints + +```http +POST /emitters/report +``` +Endpoint for emitters to report status (from original backend). + +```http +GET /fleet/status +``` +Returns database-backed fleet status (from original backend). + +## Mock Data + +### Location: `backend/services/snapshot.py` + +The `emit_status_snapshot()` function currently returns mock data with 8 units: + +- **BE1234**: OK, deployed (San Francisco) +- **BE5678**: Pending, deployed (Los Angeles) +- **BE9012**: Missing, deployed (New York) +- **BE3456**: OK, benched (Chicago) +- **BE7890**: OK, deployed (Houston) +- **BE2468**: Pending, deployed +- **BE1357**: OK, benched +- **BE8642**: Missing, deployed + +**To replace with real data**: Update the `emit_status_snapshot()` function to call your Series3 emitter logic. + +## Styling + +### Color Palette + +The application uses your brand colors: + +```css +orange: #f48b1c +navy: #142a66 +burgundy: #7d234d +``` + +These are configured in the Tailwind config as `seismo-orange`, `seismo-navy`, `seismo-burgundy`. + +### Cards + +All cards use the consistent styling: +```html +
+``` + +### Status Indicators + +- Green dot: OK status +- Yellow dot: Pending status +- Red dot: Missing status +- Blue dot: Deployed +- Gray dot: Benched + +## HTMX Usage + +HTMX enables dynamic updates without writing JavaScript: + +### Auto-refresh Example (Dashboard) + +```html +
+``` + +This fetches the snapshot on page load and every 10 seconds, then calls a JavaScript function to update the DOM. + +### Partial Template Loading (Roster) + +```html +
+``` + +This replaces the entire inner HTML with the server-rendered roster table every 10 seconds. + +## Adding Photos + +To add photos for a unit: + +1. Create a directory: `data/photos/{unit_id}/` +2. Add image files (jpg, jpeg, png, gif, webp) +3. Photos will automatically appear on the unit detail page +4. Most recent file becomes the primary photo + +Example: +```bash +mkdir -p data/photos/BE1234 +cp my-photo.jpg data/photos/BE1234/deployment-site.jpg +``` + +## Customization + +### Adding New Pages + +1. Create a template in `templates/` +2. Add a route in `backend/main.py`: + +```python +@app.get("/my-page", response_class=HTMLResponse) +async def my_page(request: Request): + return templates.TemplateResponse("my_page.html", {"request": request}) +``` + +3. Add a navigation link in `templates/base.html` + +### Adding New API Endpoints + +1. Create a router file in `backend/routers/` +2. Include the router in `backend/main.py`: + +```python +from backend.routers import my_router +app.include_router(my_router.router) +``` + +## Docker Deployment + +The project includes Docker configuration: + +```bash +docker-compose up +``` + +This will start the application on port 8001 (configured to avoid conflicts with port 8000). + +## Next Steps + +1. **Replace Mock Data**: Update `backend/services/snapshot.py` with real Series3 emitter logic +2. **Database Integration**: The existing SQLAlchemy models can store historical data +3. **Photo Upload**: Add a form to upload photos from the UI +4. **Projects Management**: Implement the "Projects" page +5. **Settings**: Add user preferences and configuration +6. **Event History**: Populate the History tab with real event data +7. **Authentication**: Add user login/authentication if needed +8. **Notifications**: Add real-time alerts for critical status changes + +## Development Tips + +- The `--reload` flag auto-reloads the server when code changes +- Use browser DevTools to debug HTMX requests (look for `HX-Request` headers) +- Check `/docs` for interactive API documentation (Swagger UI) +- Dark mode state persists in browser localStorage +- All timestamps are currently mock data - replace with real values + +## License + +See main README.md for license information. diff --git a/database.py b/backend/database.py similarity index 100% rename from database.py rename to backend/database.py diff --git a/backend/main.py b/backend/main.py new file mode 100644 index 0000000..9e75d26 --- /dev/null +++ b/backend/main.py @@ -0,0 +1,108 @@ +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +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.services.snapshot import emit_status_snapshot + +# Create database tables +Base.metadata.create_all(bind=engine) + +# Initialize FastAPI app +app = FastAPI( + title="Seismo Fleet Manager", + description="Backend API for managing seismograph fleet status", + version="0.1.0" +) + +# Configure CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Mount static files +app.mount("/static", StaticFiles(directory="backend/static"), name="static") + +# Setup Jinja2 templates +templates = Jinja2Templates(directory="templates") + +# Include API routers +app.include_router(roster.router) +app.include_router(units.router) +app.include_router(photos.router) + + +# Legacy routes from the original backend +from backend import routes as legacy_routes +app.include_router(legacy_routes.router) + + +# HTML page routes +@app.get("/", response_class=HTMLResponse) +async def dashboard(request: Request): + """Dashboard home page""" + return templates.TemplateResponse("dashboard.html", {"request": request}) + + +@app.get("/roster", response_class=HTMLResponse) +async def roster_page(request: Request): + """Fleet roster page""" + return templates.TemplateResponse("roster.html", {"request": request}) + + +@app.get("/unit/{unit_id}", response_class=HTMLResponse) +async def unit_detail_page(request: Request, unit_id: str): + """Unit detail page""" + return templates.TemplateResponse("unit_detail.html", { + "request": request, + "unit_id": unit_id + }) + + +@app.get("/partials/roster-table", response_class=HTMLResponse) +async def roster_table_partial(request: Request): + """Partial template for roster table (HTMX)""" + from datetime import datetime + snapshot = emit_status_snapshot() + + units_list = [] + for unit_id, unit_data in snapshot["units"].items(): + units_list.append({ + "id": unit_id, + "status": unit_data["status"], + "age": unit_data["age"], + "last_seen": unit_data["last"], + "deployed": unit_data["deployed"], + "note": unit_data.get("note", ""), + }) + + # Sort by status priority (Missing > Pending > OK) then by ID + status_priority = {"Missing": 0, "Pending": 1, "OK": 2} + units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"])) + + return templates.TemplateResponse("partials/roster_table.html", { + "request": request, + "units": units_list, + "timestamp": datetime.now().strftime("%H:%M:%S") + }) + + +@app.get("/health") +def health_check(): + """Health check endpoint""" + return { + "message": "Seismo Fleet Manager v0.1", + "status": "running" + } + + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/models.py b/backend/models.py similarity index 94% rename from models.py rename to backend/models.py index 9ae42c1..64490fc 100644 --- a/models.py +++ b/backend/models.py @@ -1,6 +1,6 @@ from sqlalchemy import Column, String, DateTime from datetime import datetime -from database import Base +from backend.database import Base class Emitter(Base): diff --git a/backend/routers/photos.py b/backend/routers/photos.py new file mode 100644 index 0000000..0d083bc --- /dev/null +++ b/backend/routers/photos.py @@ -0,0 +1,64 @@ +from fastapi import APIRouter, HTTPException +from fastapi.responses import FileResponse +from pathlib import Path +from typing import List +import os + +router = APIRouter(prefix="/api", tags=["photos"]) + +PHOTOS_BASE_DIR = Path("data/photos") + + +@router.get("/unit/{unit_id}/photos") +def get_unit_photos(unit_id: str): + """ + Reads /data/photos// and returns list of image filenames. + Primary photo = most recent file. + """ + unit_photo_dir = PHOTOS_BASE_DIR / unit_id + + if not unit_photo_dir.exists(): + # Return empty list if no photos directory exists + return { + "unit_id": unit_id, + "photos": [], + "primary_photo": None + } + + # Get all image files + image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"} + photos = [] + + for file_path in unit_photo_dir.iterdir(): + if file_path.is_file() and file_path.suffix.lower() in image_extensions: + photos.append({ + "filename": file_path.name, + "path": f"/api/unit/{unit_id}/photo/{file_path.name}", + "modified": file_path.stat().st_mtime + }) + + # Sort by modification time (most recent first) + photos.sort(key=lambda x: x["modified"], reverse=True) + + # Primary photo is the most recent + primary_photo = photos[0]["filename"] if photos else None + + return { + "unit_id": unit_id, + "photos": [p["filename"] for p in photos], + "primary_photo": primary_photo, + "photo_urls": [p["path"] for p in photos] + } + + +@router.get("/unit/{unit_id}/photo/{filename}") +def get_photo(unit_id: str, filename: str): + """ + Serves a specific photo file. + """ + file_path = PHOTOS_BASE_DIR / unit_id / filename + + if not file_path.exists() or not file_path.is_file(): + raise HTTPException(status_code=404, detail="Photo not found") + + return FileResponse(file_path) diff --git a/backend/routers/roster.py b/backend/routers/roster.py new file mode 100644 index 0000000..d2792e1 --- /dev/null +++ b/backend/routers/roster.py @@ -0,0 +1,46 @@ +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session +from datetime import datetime, timedelta +from typing import Dict, Any +import random + +from backend.database import get_db +from backend.services.snapshot import emit_status_snapshot + +router = APIRouter(prefix="/api", tags=["roster"]) + + +@router.get("/status-snapshot") +def get_status_snapshot(db: Session = Depends(get_db)): + """ + Calls emit_status_snapshot() to get current fleet status. + This will be replaced with real Series3 emitter logic later. + """ + return emit_status_snapshot() + + +@router.get("/roster") +def get_roster(db: Session = Depends(get_db)): + """ + Returns list of units with their metadata and status. + Uses mock data for now. + """ + snapshot = emit_status_snapshot() + units_list = [] + + for unit_id, unit_data in snapshot["units"].items(): + units_list.append({ + "id": unit_id, + "status": unit_data["status"], + "age": unit_data["age"], + "last_seen": unit_data["last"], + "deployed": unit_data["deployed"], + "note": unit_data.get("note", ""), + "last_file": unit_data.get("fname", "") + }) + + # Sort by status priority (Missing > Pending > OK) then by ID + status_priority = {"Missing": 0, "Pending": 1, "OK": 2} + units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"])) + + return {"units": units_list} diff --git a/backend/routers/units.py b/backend/routers/units.py new file mode 100644 index 0000000..654fa2c --- /dev/null +++ b/backend/routers/units.py @@ -0,0 +1,44 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from datetime import datetime +from typing import Dict, Any + +from backend.database import get_db +from backend.services.snapshot import emit_status_snapshot + +router = APIRouter(prefix="/api", tags=["units"]) + + +@router.get("/unit/{unit_id}") +def get_unit_detail(unit_id: str, db: Session = Depends(get_db)): + """ + Returns detailed data for a single unit. + """ + snapshot = emit_status_snapshot() + + if unit_id not in snapshot["units"]: + raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found") + + unit_data = snapshot["units"][unit_id] + + # Mock coordinates for now (will be replaced with real data) + mock_coords = { + "BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"}, + "BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"}, + "BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"}, + "BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"}, + "BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"}, + } + + coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"}) + + return { + "id": unit_id, + "status": unit_data["status"], + "age": unit_data["age"], + "last_seen": unit_data["last"], + "last_file": unit_data.get("fname", ""), + "deployed": unit_data["deployed"], + "note": unit_data.get("note", ""), + "coordinates": coords + } diff --git a/routes.py b/backend/routes.py similarity index 96% rename from routes.py rename to backend/routes.py index 4cdb4fa..e7e34da 100644 --- a/routes.py +++ b/backend/routes.py @@ -4,8 +4,8 @@ from pydantic import BaseModel from datetime import datetime from typing import Optional, List -from database import get_db -from models import Emitter +from backend.database import get_db +from backend.models import Emitter router = APIRouter() diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py new file mode 100644 index 0000000..8d7e38d --- /dev/null +++ b/backend/services/snapshot.py @@ -0,0 +1,96 @@ +""" +Mock implementation of emit_status_snapshot(). +This will be replaced with real Series3 emitter logic by the user. +""" + +from datetime import datetime, timedelta +import random + + +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. + """ + + # 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" + } + } + + 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") + } + } diff --git a/backend/static/style.css b/backend/static/style.css new file mode 100644 index 0000000..e05260a --- /dev/null +++ b/backend/static/style.css @@ -0,0 +1,12 @@ +/* Custom styles for Seismo Fleet Manager */ + +/* Additional custom styles can go here */ + +.card-hover { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.card-hover:hover { + transform: translateY(-2px); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); +} diff --git a/requirements.txt b/requirements.txt index 4ab590c..f526daf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ uvicorn[standard]==0.24.0 sqlalchemy==2.0.23 pydantic==2.5.0 python-multipart==0.0.6 +jinja2==3.1.2 +aiofiles==23.2.1 diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..155f6ac --- /dev/null +++ b/templates/base.html @@ -0,0 +1,150 @@ + + + + + + {% block title %}Seismo Fleet Manager{% endblock %} + + + + + + + + + + + + + + + + + {% block extra_head %}{% endblock %} + + +
+ + + + +
+
+ {% block content %}{% endblock %} +
+
+
+ + + + {% block extra_scripts %}{% endblock %} + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..3430a4f --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,178 @@ +{% extends "base.html" %} + +{% block title %}Dashboard - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+

Dashboard

+

Fleet overview and recent activity

+
+ + +
+
+ +
+
+

Fleet Summary

+ + + +
+
+
+ Total Units + -- +
+
+ Deployed + -- +
+
+
+
+ + OK +
+ -- +
+
+
+ + Pending +
+ -- +
+
+
+ + Missing +
+ -- +
+
+
+
+ + +
+
+

Recent Alerts

+ + + +
+
+

Loading alerts...

+
+
+ + +
+
+

Recent Photos

+ + + +
+
+ + + +

No recent photos

+
+
+
+ + +
+
+

Fleet Status

+ + View All + + + + +
+
+

Loading fleet data...

+
+
+
+ + +{% endblock %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html new file mode 100644 index 0000000..1b33fb3 --- /dev/null +++ b/templates/partials/roster_table.html @@ -0,0 +1,87 @@ +
+ + + + + + + + + + + + + {% for unit in units %} + + + + + + + + + {% endfor %} + +
+ Status + + Unit ID + + Last Seen + + Age + + Note + + Actions +
+
+ {% if unit.status == 'OK' %} + + {% elif unit.status == 'Pending' %} + + {% else %} + + {% endif %} + + {% if unit.deployed %} + + {% else %} + + {% endif %} +
+
+
{{ unit.id }}
+
+
{{ unit.last_seen }}
+
+
+ {{ unit.age }} +
+
+
+ {{ unit.note if unit.note else '-' }} +
+
+ + View + + + + +
+ + +
+ Last updated: {{ timestamp }} +
+
+ + diff --git a/templates/roster.html b/templates/roster.html new file mode 100644 index 0000000..c26f2f5 --- /dev/null +++ b/templates/roster.html @@ -0,0 +1,29 @@ +{% extends "base.html" %} + +{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+

Fleet Roster

+

Real-time status of all seismograph units

+
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +{% endblock %} diff --git a/templates/unit_detail.html b/templates/unit_detail.html new file mode 100644 index 0000000..193c0f0 --- /dev/null +++ b/templates/unit_detail.html @@ -0,0 +1,268 @@ +{% extends "base.html" %} + +{% block title %}Unit {{ unit_id }} - Seismo Fleet Manager{% endblock %} + +{% block content %} +
+ + + + + Back to Fleet Roster + +

Unit {{ unit_id }}

+
+ + +
+
+ +
+ +
+

Unit Status

+
+
+ Status +
+ + Loading... +
+
+
+ Deployed + -- +
+
+ Age + -- +
+
+ Last Seen + -- +
+
+ Last File + -- +
+
+
+ + +
+

Notes

+
+ Loading... +
+
+ + +
+

Edit Metadata

+
+
+ + +
+ +
+
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + +
+
+
+
+
+ + +{% endblock %} -- 2.49.1 From 02a99ea47d9edb73fcf46a9473d5c403db13a7eb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 24 Nov 2025 23:49:21 +0000 Subject: [PATCH 05/29] Fix Docker configuration for new backend structure - Update Dockerfile to use backend.main:app instead of main:app - Change exposed port from 8000 to 8001 - Fix docker-compose.yml port mapping to 8001:8001 - Update healthcheck to use correct port and /health endpoint - Remove old main.py from root directory Docker now correctly runs the new frontend + backend structure. --- Dockerfile | 6 +++--- docker-compose.yml | 6 +++--- main.py | 41 ----------------------------------------- 3 files changed, 6 insertions(+), 47 deletions(-) delete mode 100644 main.py diff --git a/Dockerfile b/Dockerfile index 3533224..d567991 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . # Expose port -EXPOSE 8000 +EXPOSE 8001 -# Run the application -CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] +# Run the application using the new backend structure +CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/docker-compose.yml b/docker-compose.yml index 6214c26..410d866 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,15 +5,15 @@ services: build: . container_name: seismo-fleet-manager ports: - - "8001:8000" + - "8001:8001" volumes: - # Persist SQLite database + # Persist SQLite database and photos - ./data:/app/data environment: - PYTHONUNBUFFERED=1 restart: unless-stopped healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:8000/"] + test: ["CMD", "curl", "-f", "http://localhost:8001/health"] interval: 30s timeout: 10s retries: 3 diff --git a/main.py b/main.py deleted file mode 100644 index 9458fd8..0000000 --- a/main.py +++ /dev/null @@ -1,41 +0,0 @@ -from fastapi import FastAPI -from fastapi.middleware.cors import CORSMiddleware - -from database import engine, Base -from routes import router - -# Create database tables -Base.metadata.create_all(bind=engine) - -# Initialize FastAPI app -app = FastAPI( - title="Seismo Fleet Manager", - description="Backend API for managing seismograph fleet status", - version="0.1.0" -) - -# Configure CORS (adjust origins as needed for your deployment) -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], # In production, specify exact origins - allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], -) - -# Include API routes -app.include_router(router) - - -@app.get("/") -def root(): - """Root endpoint - health check""" - return { - "message": "Seismo Fleet Manager API v0.1", - "status": "running" - } - - -if __name__ == "__main__": - import uvicorn - uvicorn.run(app, host="0.0.0.0", port=8000) -- 2.49.1 From 90ecada35f47c6a3aca2dcc789996250923f1fd6 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 2 Dec 2025 06:36:13 +0000 Subject: [PATCH 06/29] 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 %} -- 2.49.1 From e46f668c340d67ccffa3b7d07f7a888e69e476f1 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Tue, 2 Dec 2025 07:53:16 +0000 Subject: [PATCH 07/29] 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 %} -- 2.49.1 From 802601ae8d435602a0e51bf4ffb08061bd3bc25a Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 3 Dec 2025 00:51:18 +0000 Subject: [PATCH 08/29] pre refactor --- backend/main.py | 23 +++++++++ backend/models.py | 14 +++++- backend/routers/roster_edit.py | 53 +++++++++++++++++++- backend/services/snapshot.py | 11 ++++- templates/dashboard.html | 10 ++-- templates/partials/unknown_emitters.html | 61 ++++++++++++++++++++++++ templates/roster.html | 41 ++++++++++++++++ 7 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 templates/partials/unknown_emitters.html diff --git a/backend/main.py b/backend/main.py index 35e5048..3e0003a 100644 --- a/backend/main.py +++ b/backend/main.py @@ -98,6 +98,29 @@ async def roster_table_partial(request: Request): }) +@app.get("/partials/unknown-emitters", response_class=HTMLResponse) +async def unknown_emitters_partial(request: Request): + """Partial template for unknown emitters (HTMX)""" + snapshot = emit_status_snapshot() + + unknown_list = [] + for unit_id, unit_data in snapshot.get("unknown", {}).items(): + unknown_list.append({ + "id": unit_id, + "status": unit_data["status"], + "age": unit_data["age"], + "fname": unit_data.get("fname", ""), + }) + + # Sort by ID + unknown_list.sort(key=lambda x: x["id"]) + + return templates.TemplateResponse("partials/unknown_emitters.html", { + "request": request, + "unknown_units": unknown_list + }) + + @app.get("/health") def health_check(): """Health check endpoint""" diff --git a/backend/models.py b/backend/models.py index 19a0e92..6f1f9d2 100644 --- a/backend/models.py +++ b/backend/models.py @@ -28,4 +28,16 @@ class RosterUnit(Base): 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 + last_updated = Column(DateTime, default=datetime.utcnow) + + +class IgnoredUnit(Base): + """ + Ignored units: units that report but should be filtered out from unknown emitters. + Used to suppress noise from old projects. + """ + __tablename__ = "ignored_units" + + id = Column(String, primary_key=True, index=True) + reason = Column(String, nullable=True) + ignored_at = Column(DateTime, default=datetime.utcnow) \ No newline at end of file diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 257f00f..1922942 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -5,7 +5,7 @@ import csv import io from backend.database import get_db -from backend.models import RosterUnit +from backend.models import RosterUnit, IgnoredUnit router = APIRouter(prefix="/api/roster", tags=["roster-edit"]) @@ -180,3 +180,54 @@ async def import_csv( }, "details": results } + + +@router.post("/ignore/{unit_id}") +def ignore_unit(unit_id: str, reason: str = Form(""), db: Session = Depends(get_db)): + """ + Add a unit to the ignore list to suppress it from unknown emitters. + """ + # Check if already ignored + if db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first(): + raise HTTPException(status_code=400, detail="Unit already ignored") + + ignored = IgnoredUnit( + id=unit_id, + reason=reason, + ignored_at=datetime.utcnow() + ) + db.add(ignored) + db.commit() + return {"message": "Unit ignored", "id": unit_id} + + +@router.delete("/ignore/{unit_id}") +def unignore_unit(unit_id: str, db: Session = Depends(get_db)): + """ + Remove a unit from the ignore list. + """ + ignored = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first() + if not ignored: + raise HTTPException(status_code=404, detail="Unit not in ignore list") + + db.delete(ignored) + db.commit() + return {"message": "Unit unignored", "id": unit_id} + + +@router.get("/ignored") +def list_ignored_units(db: Session = Depends(get_db)): + """ + Get list of all ignored units. + """ + ignored_units = db.query(IgnoredUnit).all() + return { + "ignored": [ + { + "id": unit.id, + "reason": unit.reason, + "ignored_at": unit.ignored_at.isoformat() + } + for unit in ignored_units + ] + } diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index cf01943..f71b2f5 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone from sqlalchemy.orm import Session from backend.database import get_db_session -from backend.models import Emitter, RosterUnit +from backend.models import Emitter, RosterUnit, IgnoredUnit def ensure_utc(dt): @@ -33,6 +33,7 @@ def emit_status_snapshot(): try: roster = {r.id: r for r in db.query(RosterUnit).all()} emitters = {e.id: e for e in db.query(Emitter).all()} + ignored = {i.id for i in db.query(IgnoredUnit).all()} units = {} @@ -101,17 +102,25 @@ def emit_status_snapshot(): if u["retired"] } + # Unknown units - emitters that aren't in the roster and aren't ignored + unknown_units = { + uid: u for uid, u in units.items() + if uid not in roster and uid not in ignored + } + return { "timestamp": datetime.utcnow().isoformat(), "units": units, "active": active_units, "benched": benched_units, "retired": retired_units, + "unknown": unknown_units, "summary": { "total": len(units), "active": len(active_units), "benched": len(benched_units), "retired": len(retired_units), + "unknown": len(unknown_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"), diff --git a/templates/dashboard.html b/templates/dashboard.html index 26e7095..a52a101 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -162,11 +162,11 @@ function updateDashboard(event) { const data = JSON.parse(event.detail.xhr.response); // ===== 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; + document.getElementById('total-units').textContent = data.summary?.total ?? 0; + document.getElementById('deployed-units').textContent = data.summary?.active ?? 0; + document.getElementById('status-ok').textContent = data.summary?.ok ?? 0; + document.getElementById('status-pending').textContent = data.summary?.pending ?? 0; + document.getElementById('status-missing').textContent = data.summary?.missing ?? 0; // ===== Alerts ===== const alertsList = document.getElementById('alerts-list'); diff --git a/templates/partials/unknown_emitters.html b/templates/partials/unknown_emitters.html new file mode 100644 index 0000000..584b3d7 --- /dev/null +++ b/templates/partials/unknown_emitters.html @@ -0,0 +1,61 @@ +{% if unknown_units %} +
+
+ + + +
+

Unknown Emitters Detected

+

+ {{ unknown_units|length }} unit(s) are reporting but not in the roster. Add them to track them properly. +

+
+
+ +
+ {% for unit in unknown_units %} +
+
+
+ {{ unit.id }} +
+
+ + {{ unit.status }} + + + Last seen: {{ unit.age }} + + {% if unit.fname %} + + {{ unit.fname }} + + {% endif %} +
+
+
+ + +
+
+ {% endfor %} +
+
+{% endif %} diff --git a/templates/roster.html b/templates/roster.html index 78b8608..29bc0dd 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -26,6 +26,11 @@
+ +
+ +
+
@@ -155,6 +160,42 @@ document.getElementById('addUnitForm').reset(); } + // Add unknown unit to roster + function addUnknownUnit(unitId) { + openAddUnitModal(); + // Pre-fill the unit ID + document.querySelector('#addUnitForm input[name="id"]').value = unitId; + // Set deployed to true by default + document.querySelector('#addUnitForm input[name="deployed"]').checked = true; + } + + // Ignore unknown unit + async function ignoreUnknownUnit(unitId) { + if (!confirm(`Ignore unit ${unitId}? It will no longer appear in the unknown emitters list.`)) { + return; + } + + try { + const formData = new FormData(); + formData.append('reason', 'Ignored from unknown emitters'); + + const response = await fetch(`/api/roster/ignore/${unitId}`, { + method: 'POST', + body: formData + }); + + if (response.ok) { + // Trigger refresh of unknown emitters + htmx.trigger(document.querySelector('[hx-get="/partials/unknown-emitters"]'), 'load'); + } else { + const result = await response.json(); + alert(`Error ignoring unit: ${result.detail || 'Unknown error'}`); + } + } catch (error) { + alert(`Error ignoring unit: ${error.message}`); + } + } + // Import Modal function openImportModal() { document.getElementById('importModal').classList.remove('hidden'); -- 2.49.1 From dc853806bb20b94ad761c1d3cf1025fe521266d0 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Wed, 3 Dec 2025 07:57:25 +0000 Subject: [PATCH 09/29] v0.2 fleet overhaul --- backend/main.py | 7 + backend/migrate_add_device_types.py | 84 ++++ backend/models.py | 18 +- backend/routers/roster_edit.py | 140 ++++++- backend/services/snapshot.py | 18 + create_test_db.py | 115 ++++++ templates/dashboard.html | 120 +++++- templates/partials/active_table.html | 73 ++-- templates/partials/benched_table.html | 73 ++-- templates/partials/roster_table.html | 92 ++++- templates/roster.html | 375 ++++++++++++++++- templates/unit_detail.html | 567 ++++++++++++++++---------- 12 files changed, 1400 insertions(+), 282 deletions(-) create mode 100644 backend/migrate_add_device_types.py create mode 100644 create_test_db.py diff --git a/backend/main.py b/backend/main.py index 3e0003a..a61f2b5 100644 --- a/backend/main.py +++ b/backend/main.py @@ -85,6 +85,13 @@ async def roster_table_partial(request: Request): "last_seen": unit_data["last"], "deployed": unit_data["deployed"], "note": unit_data.get("note", ""), + "device_type": unit_data.get("device_type", "seismograph"), + "last_calibrated": unit_data.get("last_calibrated"), + "next_calibration_due": unit_data.get("next_calibration_due"), + "deployed_with_modem_id": unit_data.get("deployed_with_modem_id"), + "ip_address": unit_data.get("ip_address"), + "phone_number": unit_data.get("phone_number"), + "hardware_model": unit_data.get("hardware_model"), }) # Sort by status priority (Missing > Pending > OK) then by ID diff --git a/backend/migrate_add_device_types.py b/backend/migrate_add_device_types.py new file mode 100644 index 0000000..f923f34 --- /dev/null +++ b/backend/migrate_add_device_types.py @@ -0,0 +1,84 @@ +""" +Migration script to add device type support to the roster table. + +This adds columns for: +- device_type (seismograph/modem discriminator) +- Seismograph-specific fields (calibration dates, modem pairing) +- Modem-specific fields (IP address, phone number, hardware model) + +Run this script once to migrate an existing database. +""" + +import sqlite3 +import os + +# Database path +DB_PATH = "./data/seismo_fleet.db" + +def migrate_database(): + """Add new columns to the roster table""" + + if not os.path.exists(DB_PATH): + print(f"Database not found at {DB_PATH}") + print("The database will be created automatically when you run the application.") + return + + print(f"Migrating database: {DB_PATH}") + + conn = sqlite3.connect(DB_PATH) + cursor = conn.cursor() + + # Check if device_type column already exists + cursor.execute("PRAGMA table_info(roster)") + columns = [col[1] for col in cursor.fetchall()] + + if "device_type" in columns: + print("Migration already applied - device_type column exists") + conn.close() + return + + print("Adding new columns to roster table...") + + try: + # Add device type discriminator + cursor.execute("ALTER TABLE roster ADD COLUMN device_type TEXT DEFAULT 'seismograph'") + print(" ✓ Added device_type column") + + # Add seismograph-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN last_calibrated DATE") + print(" ✓ Added last_calibrated column") + + cursor.execute("ALTER TABLE roster ADD COLUMN next_calibration_due DATE") + print(" ✓ Added next_calibration_due column") + + cursor.execute("ALTER TABLE roster ADD COLUMN deployed_with_modem_id TEXT") + print(" ✓ Added deployed_with_modem_id column") + + # Add modem-specific fields + cursor.execute("ALTER TABLE roster ADD COLUMN ip_address TEXT") + print(" ✓ Added ip_address column") + + cursor.execute("ALTER TABLE roster ADD COLUMN phone_number TEXT") + print(" ✓ Added phone_number column") + + cursor.execute("ALTER TABLE roster ADD COLUMN hardware_model TEXT") + print(" ✓ Added hardware_model column") + + # Set all existing units to seismograph type + cursor.execute("UPDATE roster SET device_type = 'seismograph' WHERE device_type IS NULL") + print(" ✓ Set existing units to seismograph type") + + conn.commit() + print("\nMigration completed successfully!") + + except sqlite3.Error as e: + print(f"\nError during migration: {e}") + conn.rollback() + raise + + finally: + conn.close() + + +if __name__ == "__main__": + migrate_database() diff --git a/backend/models.py b/backend/models.py index 6f1f9d2..6474d80 100644 --- a/backend/models.py +++ b/backend/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, DateTime, Boolean, Text +from sqlalchemy import Column, String, DateTime, Boolean, Text, Date from datetime import datetime from backend.database import Base @@ -18,11 +18,15 @@ class RosterUnit(Base): """ Roster table: represents our *intended assignment* of a unit. This is editable from the GUI. + + Supports multiple device types (seismograph, modem) with type-specific fields. """ __tablename__ = "roster" + # Core fields (all device types) id = Column(String, primary_key=True, index=True) - unit_type = Column(String, default="series3") + unit_type = Column(String, default="series3") # Backward compatibility + device_type = Column(String, default="seismograph") # "seismograph" | "modem" deployed = Column(Boolean, default=True) retired = Column(Boolean, default=False) note = Column(String, nullable=True) @@ -30,6 +34,16 @@ class RosterUnit(Base): location = Column(String, nullable=True) last_updated = Column(DateTime, default=datetime.utcnow) + # Seismograph-specific fields (nullable for modems) + last_calibrated = Column(Date, nullable=True) + next_calibration_due = Column(Date, nullable=True) + deployed_with_modem_id = Column(String, nullable=True) # FK to another RosterUnit + + # Modem-specific fields (nullable for seismographs) + ip_address = Column(String, nullable=True) + phone_number = Column(String, nullable=True) + hardware_model = Column(String, nullable=True) + class IgnoredUnit(Base): """ diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 1922942..a29ab89 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -1,6 +1,6 @@ from fastapi import APIRouter, Depends, HTTPException, Form, UploadFile, File from sqlalchemy.orm import Session -from datetime import datetime +from datetime import datetime, date import csv import io @@ -23,28 +23,149 @@ def get_or_create_roster_unit(db: Session, unit_id: str): @router.post("/add") def add_roster_unit( id: str = Form(...), + device_type: str = Form("seismograph"), unit_type: str = Form("series3"), deployed: bool = Form(False), note: str = Form(""), project_id: str = Form(None), location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: 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") + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + unit = RosterUnit( id=id, + device_type=device_type, unit_type=unit_type, deployed=deployed, note=note, project_id=project_id, location=location, last_updated=datetime.utcnow(), + # Seismograph-specific fields + last_calibrated=last_cal_date, + next_calibration_due=next_cal_date, + deployed_with_modem_id=deployed_with_modem_id if deployed_with_modem_id else None, + # Modem-specific fields + ip_address=ip_address if ip_address else None, + phone_number=phone_number if phone_number else None, + hardware_model=hardware_model if hardware_model else None, ) db.add(unit) db.commit() - return {"message": "Unit added", "id": id} + return {"message": "Unit added", "id": id, "device_type": device_type} + + +@router.get("/{unit_id}") +def get_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """Get a single roster unit by ID""" + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + return { + "id": unit.id, + "device_type": unit.device_type or "seismograph", + "unit_type": unit.unit_type, + "deployed": unit.deployed, + "retired": unit.retired, + "note": unit.note or "", + "project_id": unit.project_id or "", + "location": unit.location or "", + "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else "", + "next_calibration_due": unit.next_calibration_due.isoformat() if unit.next_calibration_due else "", + "deployed_with_modem_id": unit.deployed_with_modem_id or "", + "ip_address": unit.ip_address or "", + "phone_number": unit.phone_number or "", + "hardware_model": unit.hardware_model or "", + } + + +@router.post("/edit/{unit_id}") +def edit_roster_unit( + unit_id: str, + device_type: str = Form("seismograph"), + unit_type: str = Form("series3"), + deployed: bool = Form(False), + retired: bool = Form(False), + note: str = Form(""), + project_id: str = Form(None), + location: str = Form(None), + # Seismograph-specific fields + last_calibrated: str = Form(None), + next_calibration_due: str = Form(None), + deployed_with_modem_id: str = Form(None), + # Modem-specific fields + ip_address: str = Form(None), + phone_number: str = Form(None), + hardware_model: str = Form(None), + db: Session = Depends(get_db) +): + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + # Parse date fields if provided + last_cal_date = None + if last_calibrated: + try: + last_cal_date = datetime.strptime(last_calibrated, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid last_calibrated date format. Use YYYY-MM-DD") + + next_cal_date = None + if next_calibration_due: + try: + next_cal_date = datetime.strptime(next_calibration_due, "%Y-%m-%d").date() + except ValueError: + raise HTTPException(status_code=400, detail="Invalid next_calibration_due date format. Use YYYY-MM-DD") + + # Update all fields + unit.device_type = device_type + unit.unit_type = unit_type + unit.deployed = deployed + unit.retired = retired + unit.note = note + unit.project_id = project_id + unit.location = location + unit.last_updated = datetime.utcnow() + + # Seismograph-specific fields + unit.last_calibrated = last_cal_date + unit.next_calibration_due = next_cal_date + unit.deployed_with_modem_id = deployed_with_modem_id if deployed_with_modem_id else None + + # Modem-specific fields + unit.ip_address = ip_address if ip_address else None + unit.phone_number = phone_number if phone_number else None + unit.hardware_model = hardware_model if hardware_model else None + + db.commit() + return {"message": "Unit updated", "id": unit_id, "device_type": device_type} @router.post("/set-deployed/{unit_id}") @@ -65,6 +186,21 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g return {"message": "Updated", "id": unit_id, "retired": retired} +@router.delete("/{unit_id}") +def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): + """ + Permanently delete a unit from the roster database. + This is different from ignoring - the unit is completely removed. + """ + unit = db.query(RosterUnit).filter(RosterUnit.id == unit_id).first() + if not unit: + raise HTTPException(status_code=404, detail="Unit not found") + + db.delete(unit) + db.commit() + return {"message": "Unit deleted", "id": unit_id} + + @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) diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py index f71b2f5..fa10bdb 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -69,6 +69,16 @@ def emit_status_snapshot(): "deployed": r.deployed, "note": r.note or "", "retired": r.retired, + # Device type and type-specific fields + "device_type": r.device_type or "seismograph", + "last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None, + "next_calibration_due": r.next_calibration_due.isoformat() if r.next_calibration_due else None, + "deployed_with_modem_id": r.deployed_with_modem_id, + "ip_address": r.ip_address, + "phone_number": r.phone_number, + "hardware_model": r.hardware_model, + # Location for mapping + "location": r.location or "", } # --- Add unexpected emitter-only units --- @@ -84,6 +94,14 @@ def emit_status_snapshot(): "deployed": False, # default "note": "", "retired": False, + # Device type and type-specific fields (defaults for unknown units) + "device_type": "seismograph", # default + "last_calibrated": None, + "next_calibration_due": None, + "deployed_with_modem_id": None, + "ip_address": None, + "phone_number": None, + "hardware_model": None, } # Separate buckets for UI diff --git a/create_test_db.py b/create_test_db.py new file mode 100644 index 0000000..11b44fc --- /dev/null +++ b/create_test_db.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +""" +Create a fresh test database with the new schema and some sample data. +""" + +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from datetime import datetime, date, timedelta +from backend.models import Base, RosterUnit, Emitter + +# Create a new test database +TEST_DB_PATH = "/tmp/sfm_test.db" +engine = create_engine(f"sqlite:///{TEST_DB_PATH}", connect_args={"check_same_thread": False}) + +# Drop all tables and recreate them with the new schema +Base.metadata.drop_all(bind=engine) +Base.metadata.create_all(bind=engine) + +# Create a session +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +db = SessionLocal() + +try: + # Add some test seismographs + seismo1 = RosterUnit( + id="BE9449", + device_type="seismograph", + unit_type="series3", + deployed=True, + note="Primary field unit", + project_id="PROJ-001", + location="Site A", + last_calibrated=date(2024, 1, 15), + next_calibration_due=date(2025, 1, 15), + deployed_with_modem_id="MDM001", + last_updated=datetime.utcnow(), + ) + + seismo2 = RosterUnit( + id="BE9450", + device_type="seismograph", + unit_type="series3", + deployed=False, + note="Benched for maintenance", + project_id="PROJ-001", + location="Warehouse", + last_calibrated=date(2023, 6, 20), + next_calibration_due=date(2024, 6, 20), # Past due + last_updated=datetime.utcnow(), + ) + + # Add some test modems + modem1 = RosterUnit( + id="MDM001", + device_type="modem", + unit_type="modem", + deployed=True, + note="Paired with BE9449", + project_id="PROJ-001", + location="Site A", + ip_address="192.168.1.100", + phone_number="+1-555-0123", + hardware_model="Raven XTV", + last_updated=datetime.utcnow(), + ) + + modem2 = RosterUnit( + id="MDM002", + device_type="modem", + unit_type="modem", + deployed=False, + note="Spare modem", + project_id="PROJ-001", + location="Warehouse", + ip_address="192.168.1.101", + phone_number="+1-555-0124", + hardware_model="Raven XT", + last_updated=datetime.utcnow(), + ) + + # Add test emitters (status reports) + emitter1 = Emitter( + id="BE9449", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(hours=2), + last_file="BE9449.2024.336.12.00.mseed", + status="OK", + notes="Running normally", + ) + + emitter2 = Emitter( + id="BE9450", + unit_type="series3", + last_seen=datetime.utcnow() - timedelta(days=30), + last_file="BE9450.2024.306.08.00.mseed", + status="Missing", + notes="No data received", + ) + + # Add all units + db.add_all([seismo1, seismo2, modem1, modem2, emitter1, emitter2]) + db.commit() + + print(f"✓ Test database created at {TEST_DB_PATH}") + print(f"✓ Added 2 seismographs (BE9449, BE9450)") + print(f"✓ Added 2 modems (MDM001, MDM002)") + print(f"✓ Added 2 emitter status reports") + print(f"\nDatabase is ready for testing!") + +except Exception as e: + print(f"Error creating test database: {e}") + db.rollback() + raise +finally: + db.close() diff --git a/templates/dashboard.html b/templates/dashboard.html index a52a101..4e3308a 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -98,6 +98,15 @@
+ +
+
+

Fleet Map

+ Deployed units +
+
+
+
@@ -132,7 +141,10 @@
-
+

Loading fleet data...

@@ -204,10 +216,116 @@ function updateDashboard(event) { alertsList.innerHTML = alertsHtml; } + // ===== Update Fleet Map ===== + updateFleetMap(data); + } catch (err) { console.error("Dashboard update error:", err); } } + +// Handle tab switching +document.addEventListener('DOMContentLoaded', function() { + const tabButtons = document.querySelectorAll('.tab-button'); + + tabButtons.forEach(button => { + button.addEventListener('click', function() { + // Remove active-tab class from all buttons + tabButtons.forEach(btn => btn.classList.remove('active-tab')); + // Add active-tab class to clicked button + this.classList.add('active-tab'); + }); + }); + + // Initialize fleet map + initFleetMap(); +}); + +let fleetMap = null; +let fleetMarkers = []; + +function initFleetMap() { + // Initialize the map centered on the US (can adjust based on your deployment area) + fleetMap = L.map('fleet-map').setView([39.8283, -98.5795], 4); + + // Add OpenStreetMap tiles + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18 + }).addTo(fleetMap); +} + +function updateFleetMap(data) { + if (!fleetMap) return; + + // Clear existing markers + fleetMarkers.forEach(marker => fleetMap.removeLayer(marker)); + fleetMarkers = []; + + // Get deployed units with location data + const deployedUnits = Object.entries(data.units).filter(([_, u]) => u.deployed && u.location); + + if (deployedUnits.length === 0) { + return; + } + + const bounds = []; + + deployedUnits.forEach(([id, unit]) => { + const coords = parseLocation(unit.location); + if (coords) { + const [lat, lon] = coords; + + // Create marker with custom color based on status + const markerColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'orange' : 'red'; + + const marker = L.circleMarker([lat, lon], { + radius: 8, + fillColor: markerColor, + color: '#fff', + weight: 2, + opacity: 1, + fillOpacity: 0.8 + }).addTo(fleetMap); + + // Add popup with unit info + marker.bindPopup(` +
+

${id}

+

Status: ${unit.status}

+

Type: ${unit.device_type}

+ ${unit.note ? `

${unit.note}

` : ''} + View Details → +
+ `); + + fleetMarkers.push(marker); + bounds.push([lat, lon]); + } + }); + + // Fit map to show all markers + if (bounds.length > 0) { + fleetMap.fitBounds(bounds, { padding: [50, 50] }); + } +} + +function parseLocation(location) { + if (!location) return null; + + // Try to parse as "lat,lon" format + const parts = location.split(',').map(s => s.trim()); + if (parts.length === 2) { + const lat = parseFloat(parts[0]); + const lon = parseFloat(parts[1]); + if (!isNaN(lat) && !isNaN(lon)) { + return [lat, lon]; + } + } + + // TODO: Add geocoding support for address strings + return null; +} {% endblock %} diff --git a/templates/partials/active_table.html b/templates/partials/active_table.html index a680d88..d9f4b72 100644 --- a/templates/partials/active_table.html +++ b/templates/partials/active_table.html @@ -1,25 +1,50 @@ - - - - - - - - - - - +{% if units %} +
+ {% for unit_id, unit in units.items() %} +
+
+ +
+ {% if unit.status == 'OK' %} + + {% elif unit.status == 'Pending' %} + + {% else %} + + {% endif %} +
-
- {% for uid, u in units.items() %} - - - - - - - - - {% endfor %} - -
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
+ +
+
+ + {{ unit_id }} + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} +
+ {% if unit.note %} +

{{ unit.note }}

+ {% endif %} +
+ + +
+ + {{ unit.age }} + +
+
+
+ {% endfor %} + +{% else %} +

No active units

+{% endif %} diff --git a/templates/partials/benched_table.html b/templates/partials/benched_table.html index a680d88..b109259 100644 --- a/templates/partials/benched_table.html +++ b/templates/partials/benched_table.html @@ -1,25 +1,50 @@ - - - - - - - - - - - +{% if units %} +
+ {% for unit_id, unit in units.items() %} +
+
+ +
+ +
-
- {% for uid, u in units.items() %} - - - - - - - - - {% endfor %} - -
IDStatusAgeLast SeenFileNote
{{ uid }}{{ u.status }}{{ u.age }}{{ u.last }}{{ u.fname }}{{ u.note }}
+ +
+
+ + {{ unit_id }} + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} +
+ {% if unit.note %} +

{{ unit.note }}

+ {% endif %} +
+ + +
+ {% if unit.age != 'N/A' %} + + Last seen: {{ unit.age }} ago + + {% else %} + + No data + + {% endif %} +
+ + + {% endfor %} + +{% else %} +

No benched units

+{% endif %} diff --git a/templates/partials/roster_table.html b/templates/partials/roster_table.html index 1b33fb3..f893aaf 100644 --- a/templates/partials/roster_table.html +++ b/templates/partials/roster_table.html @@ -8,6 +8,12 @@ Unit ID + + Type + + + Details + Last Seen @@ -43,7 +49,50 @@ -
{{ unit.id }}
+ + {{ unit.id }} + + + + {% if unit.device_type == 'modem' %} + + Modem + + {% else %} + + Seismograph + + {% endif %} + + +
+ {% if unit.device_type == 'modem' %} + {% if unit.ip_address %} +
{{ unit.ip_address }}
+ {% endif %} + {% if unit.phone_number %} +
{{ unit.phone_number }}
+ {% endif %} + {% if unit.hardware_model %} +
{{ unit.hardware_model }}
+ {% endif %} + {% else %} + {% if unit.next_calibration_due %} +
+ Cal Due: + {{ unit.next_calibration_due }} +
+ {% endif %} + {% if unit.deployed_with_modem_id %} + + {% endif %} + {% endif %} +
{{ unit.last_seen }}
@@ -63,12 +112,41 @@ - - View - - - - +
+ + {% if unit.deployed %} + + {% else %} + + {% endif %} + + +
{% endfor %} diff --git a/templates/roster.html b/templates/roster.html index 29bc0dd..1a22ff5 100644 --- a/templates/roster.html +++ b/templates/roster.html @@ -69,6 +69,14 @@ 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"> +
+ + +
+ + +
+

Seismograph Information

+
+ + +
+
+ + +

Typically 1 year after last calibration

+
+ +
+ + + +
@@ -111,6 +162,118 @@
+ + +