v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1
@@ -1,3 +1,4 @@
|
|||||||
|
import os
|
||||||
from fastapi import FastAPI, Request, Depends
|
from fastapi import FastAPI, Request, Depends
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -13,11 +14,15 @@ from backend.models import IgnoredUnit
|
|||||||
# Create database tables
|
# Create database tables
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
# Read environment (development or production)
|
||||||
|
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
|
||||||
|
|
||||||
# Initialize FastAPI app
|
# Initialize FastAPI app
|
||||||
|
VERSION = "0.2.2"
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
title="Seismo Fleet Manager",
|
title="Seismo Fleet Manager",
|
||||||
description="Backend API for managing seismograph fleet status",
|
description="Backend API for managing seismograph fleet status",
|
||||||
version="0.1.1"
|
version=VERSION
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure CORS
|
# Configure CORS
|
||||||
@@ -35,6 +40,24 @@ app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
|||||||
# Setup Jinja2 templates
|
# Setup Jinja2 templates
|
||||||
templates = Jinja2Templates(directory="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
|
# Include API routers
|
||||||
app.include_router(roster.router)
|
app.include_router(roster.router)
|
||||||
app.include_router(units.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)):
|
def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
||||||
"""
|
"""
|
||||||
Permanently delete a unit from the database.
|
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
|
deleted = False
|
||||||
|
|
||||||
@@ -218,7 +218,13 @@ def delete_roster_unit(unit_id: str, db: Session = Depends(get_db)):
|
|||||||
db.delete(emitter)
|
db.delete(emitter)
|
||||||
deleted = True
|
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:
|
if not deleted:
|
||||||
raise HTTPException(status_code=404, detail="Unit not found")
|
raise HTTPException(status_code=404, detail="Unit not found")
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,32 @@ from backend.models import Emitter
|
|||||||
router = APIRouter()
|
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
|
# Pydantic schemas for request/response validation
|
||||||
class EmitterReport(BaseModel):
|
class EmitterReport(BaseModel):
|
||||||
unit: str
|
unit: str
|
||||||
@@ -138,11 +164,16 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
emitter.last_seen = ts
|
emitter.last_seen = ts
|
||||||
emitter.last_file = last_file
|
emitter.last_file = last_file
|
||||||
emitter.status = status
|
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:
|
else:
|
||||||
# Insert new
|
# Insert new - auto-detect unit type from ID
|
||||||
|
detected_type = detect_unit_type(uid)
|
||||||
emitter = Emitter(
|
emitter = Emitter(
|
||||||
id=uid,
|
id=uid,
|
||||||
unit_type="series3",
|
unit_type=detected_type,
|
||||||
last_seen=ts,
|
last_seen=ts,
|
||||||
last_file=last_file,
|
last_file=last_file,
|
||||||
status=status
|
status=status
|
||||||
@@ -159,3 +190,97 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
|
|||||||
"units_processed": len(results),
|
"units_processed": len(results),
|
||||||
"results": 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
|
# Separate buckets for UI
|
||||||
active_units = {
|
active_units = {
|
||||||
uid: u for uid, u in units.items()
|
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 = {
|
benched_units = {
|
||||||
uid: u for uid, u in units.items()
|
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 = {
|
retired_units = {
|
||||||
@@ -140,7 +140,7 @@ def emit_status_snapshot():
|
|||||||
"retired": retired_units,
|
"retired": retired_units,
|
||||||
"unknown": unknown_units,
|
"unknown": unknown_units,
|
||||||
"summary": {
|
"summary": {
|
||||||
"total": len(units),
|
"total": len(active_units) + len(benched_units),
|
||||||
"active": len(active_units),
|
"active": len(active_units),
|
||||||
"benched": len(benched_units),
|
"benched": len(benched_units),
|
||||||
"retired": len(retired_units),
|
"retired": len(retired_units),
|
||||||
|
|||||||
@@ -1,16 +1,35 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
# --- PRODUCTION ---
|
||||||
seismo-backend:
|
seismo-backend:
|
||||||
build: .
|
build: .
|
||||||
container_name: seismo-fleet-manager
|
container_name: seismo-fleet-manager
|
||||||
ports:
|
ports:
|
||||||
- "8001:8001"
|
- "8001:8001"
|
||||||
volumes:
|
volumes:
|
||||||
# Persist SQLite database and photos
|
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
environment:
|
environment:
|
||||||
- PYTHONUNBUFFERED=1
|
- 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
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
|
||||||
@@ -21,3 +40,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<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 -->
|
<!-- Tailwind CSS -->
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
@@ -68,7 +68,12 @@
|
|||||||
Seismo<br>
|
Seismo<br>
|
||||||
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
|
||||||
</h1>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
|
|||||||
@@ -3,6 +3,13 @@
|
|||||||
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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">
|
<div class="mb-8">
|
||||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
<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>
|
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user