v0.1.1 update

This commit is contained in:
serversdwn
2025-12-02 06:36:13 +00:00
parent 938e950dd6
commit 90ecada35f
17 changed files with 867 additions and 221 deletions

View File

@@ -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()

View File

@@ -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"
}

View File

@@ -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)

View 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"]}
)

View 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}

View 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
}

View File

@@ -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
}

View File

@@ -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()