Merge pull request #12 from serversdwn/claude/dev-015sto5mf2MpPCE57TbNKtaF
v0.2.2-series4 endpoint added, dev branch set up at :1001
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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:
|
||||
|
||||
7
sfm.code-workspace
Normal file
7
sfm.code-workspace
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": ".."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
||||
<title>{% if environment == 'development' %}[DEV] {% endif %}{% block title %}Seismo Fleet Manager{% endblock %}</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
@@ -68,7 +68,12 @@
|
||||
Seismo<br>
|
||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||
</h1>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">v 0.2.1</p>
|
||||
<div class="flex items-center justify-between mt-2">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">v {{ version }}</p>
|
||||
{% if environment == 'development' %}
|
||||
<span class="px-2 py-1 text-xs font-bold text-white bg-yellow-500 rounded">DEV</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
|
||||
@@ -3,6 +3,13 @@
|
||||
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if environment == 'development' %}
|
||||
<div class="mb-4 p-4 bg-yellow-100 dark:bg-yellow-900 border-l-4 border-yellow-500 text-yellow-700 dark:text-yellow-200 rounded">
|
||||
<p class="font-bold">Development Environment</p>
|
||||
<p class="text-sm">You are currently viewing the development version of Seismo Fleet Manager.</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-8">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||
|
||||
Reference in New Issue
Block a user