v0.1.1 update
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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"<Emitter(id={self.id}, type={self.unit_type}, status={self.status})>"
|
||||
|
||||
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)
|
||||
25
backend/routers/dashboard.py
Normal file
25
backend/routers/dashboard.py
Normal file
@@ -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"]}
|
||||
)
|
||||
34
backend/routers/dashboard_tabs.py
Normal file
34
backend/routers/dashboard_tabs.py
Normal file
@@ -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}
|
||||
178
backend/routers/roster_edit.py
Normal file
178
backend/routers/roster_edit.py
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user