diff --git a/backend/main.py b/backend/main.py index e9264e8..4b5a155 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,3 +1,4 @@ +import os from fastapi import FastAPI, Request, Depends from fastapi.middleware.cors import CORSMiddleware from fastapi.staticfiles import StaticFiles @@ -13,11 +14,15 @@ from backend.models import IgnoredUnit # Create database tables Base.metadata.create_all(bind=engine) +# Read environment (development or production) +ENVIRONMENT = os.getenv("ENVIRONMENT", "production") + # Initialize FastAPI app +VERSION = "0.2.2" app = FastAPI( title="Seismo Fleet Manager", description="Backend API for managing seismograph fleet status", - version="0.1.1" + version=VERSION ) # Configure CORS @@ -35,6 +40,24 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static") # Setup Jinja2 templates templates = Jinja2Templates(directory="templates") +# Add custom context processor to inject environment variable into all templates +@app.middleware("http") +async def add_environment_to_context(request: Request, call_next): + """Middleware to add environment variable to request state""" + request.state.environment = ENVIRONMENT + response = await call_next(request) + return response + +# Override TemplateResponse to include environment and version in context +original_template_response = templates.TemplateResponse +def custom_template_response(name, context=None, *args, **kwargs): + if context is None: + context = {} + context["environment"] = ENVIRONMENT + context["version"] = VERSION + return original_template_response(name, context, *args, **kwargs) +templates.TemplateResponse = custom_template_response + # Include API routers app.include_router(roster.router) app.include_router(units.router) diff --git a/backend/routers/roster_edit.py b/backend/routers/roster_edit.py index 82f2061..e1ab7ca 100644 --- a/backend/routers/roster_edit.py +++ b/backend/routers/roster_edit.py @@ -202,7 +202,7 @@ def set_retired(unit_id: str, retired: bool = Form(...), db: Session = Depends(g def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): """ Permanently delete a unit from the database. - Checks both roster and emitters tables and deletes from any table where the unit exists. + Checks roster, emitters, and ignored_units tables and deletes from any table where the unit exists. """ deleted = False @@ -218,7 +218,13 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)): db.delete(emitter) deleted = True - # If not found in either table, return error + # Try to delete from ignored_units table + ignored_unit = db.query(IgnoredUnit).filter(IgnoredUnit.id == unit_id).first() + if ignored_unit: + db.delete(ignored_unit) + deleted = True + + # If not found in any table, return error if not deleted: raise HTTPException(status_code=404, detail="Unit not found") diff --git a/backend/routes.py b/backend/routes.py index fd39489..2c6cd8f 100644 --- a/backend/routes.py +++ b/backend/routes.py @@ -10,6 +10,32 @@ from backend.models import Emitter router = APIRouter() +# Helper function to detect unit type from unit ID +def detect_unit_type(unit_id: str) -> str: + """ + Automatically detect if a unit is Series 3 or Series 4 based on ID pattern. + + Series 4 (Micromate) units have IDs starting with "UM" followed by digits (e.g., UM11719) + Series 3 units typically have other patterns + + Returns: + "series4" if the unit ID matches Micromate pattern (UM#####) + "series3" otherwise + """ + if not unit_id: + return "unknown" + + # Series 4 (Micromate) pattern: UM followed by digits + if unit_id.upper().startswith("UM") and len(unit_id) > 2: + # Check if remaining characters after "UM" are digits + rest = unit_id[2:] + if rest.isdigit(): + return "series4" + + # Default to series3 for other patterns + return "series3" + + # Pydantic schemas for request/response validation class EmitterReport(BaseModel): unit: str @@ -138,11 +164,16 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)): emitter.last_seen = ts emitter.last_file = last_file emitter.status = status + # Update unit_type if it was incorrectly classified + detected_type = detect_unit_type(uid) + if emitter.unit_type != detected_type: + emitter.unit_type = detected_type else: - # Insert new + # Insert new - auto-detect unit type from ID + detected_type = detect_unit_type(uid) emitter = Emitter( id=uid, - unit_type="series3", + unit_type=detected_type, last_seen=ts, last_file=last_file, status=status @@ -159,3 +190,97 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)): "units_processed": len(results), "results": results } + + +# series4 (Micromate) Standardized Heartbeat Schema +@router.post("/api/series4/heartbeat", status_code=200) +async def series4_heartbeat(request: Request, db: Session = Depends(get_db)): + """ + Accepts a full telemetry payload from the Series4 (Micromate) emitter. + Updates or inserts each unit into the database. + + Expected payload: + { + "source": "series4_emitter", + "generated_at": "2025-12-04T20:01:00", + "units": [ + { + "unit_id": "UM11719", + "type": "micromate", + "project_hint": "Clearwater - ECMS 57940", + "last_call": "2025-12-04T19:30:42", + "status": "OK", + "age_days": 0.04, + "age_hours": 0.9, + "mlg_path": "C:\\THORDATA\\..." + } + ] + } + """ + payload = await request.json() + + source = payload.get("source", "series4_emitter") + units = payload.get("units", []) + + print("\n=== Series 4 Heartbeat ===") + print("Source:", source) + print("Units received:", len(units)) + print("==========================\n") + + results = [] + + for u in units: + uid = u.get("unit_id") + last_call_str = u.get("last_call") + status = u.get("status", "Unknown") + mlg_path = u.get("mlg_path") + project_hint = u.get("project_hint") + + # Parse last_call timestamp + try: + if last_call_str: + ts = datetime.fromisoformat(last_call_str.replace("Z", "+00:00")) + else: + ts = None + except: + ts = None + + # Pull from DB + emitter = db.query(Emitter).filter(Emitter.id == uid).first() + + if emitter: + # Update existing + emitter.last_seen = ts + emitter.last_file = mlg_path + emitter.status = status + # Update unit_type if it was incorrectly classified + detected_type = detect_unit_type(uid) + if emitter.unit_type != detected_type: + emitter.unit_type = detected_type + # Optionally update notes with project hint if it exists + if project_hint and not emitter.notes: + emitter.notes = f"Project: {project_hint}" + else: + # Insert new - auto-detect unit type from ID + detected_type = detect_unit_type(uid) + notes = f"Project: {project_hint}" if project_hint else None + emitter = Emitter( + id=uid, + unit_type=detected_type, + last_seen=ts, + last_file=mlg_path, + status=status, + notes=notes + ) + 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 478d987..b8805f0 100644 --- a/backend/services/snapshot.py +++ b/backend/services/snapshot.py @@ -113,12 +113,12 @@ def emit_status_snapshot(): # Separate buckets for UI active_units = { uid: u for uid, u in units.items() - if not u["retired"] and u["deployed"] + if not u["retired"] and u["deployed"] and uid not in ignored } benched_units = { uid: u for uid, u in units.items() - if not u["retired"] and not u["deployed"] + if not u["retired"] and not u["deployed"] and uid not in ignored } retired_units = { @@ -140,7 +140,7 @@ def emit_status_snapshot(): "retired": retired_units, "unknown": unknown_units, "summary": { - "total": len(units), + "total": len(active_units) + len(benched_units), "active": len(active_units), "benched": len(benched_units), "retired": len(retired_units), diff --git a/docker-compose.yml b/docker-compose.yml index 410d866..b0c0ccf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,35 @@ -version: '3.8' - services: + + # --- PRODUCTION --- seismo-backend: build: . container_name: seismo-fleet-manager ports: - "8001:8001" volumes: - # Persist SQLite database and photos - ./data:/app/data environment: - PYTHONUNBUFFERED=1 + - ENVIRONMENT=production + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8001/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # --- DEVELOPMENT --- + sfm-dev: + build: . + container_name: sfm-dev + ports: + - "1001:8001" + volumes: + - ./data-dev:/app/data + environment: + - PYTHONUNBUFFERED=1 + - ENVIRONMENT=development restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8001/health"] @@ -21,3 +40,4 @@ services: volumes: data: + data-dev: diff --git a/sfm.code-workspace b/sfm.code-workspace new file mode 100644 index 0000000..2a0ed79 --- /dev/null +++ b/sfm.code-workspace @@ -0,0 +1,7 @@ +{ + "folders": [ + { + "path": ".." + } + ] +} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html index e2d496c..8814760 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,7 +3,7 @@
-v 0.2.1
+v {{ version }}
+ {% if environment == 'development' %} + DEV + {% endif %} +Development Environment
+You are currently viewing the development version of Seismo Fleet Manager.
+Fleet overview and recent activity