Add MVP frontend scaffold with FastAPI + HTMX + TailwindCSS
- Created complete frontend structure with Jinja2 templates - Implemented three main pages: Dashboard, Fleet Roster, and Unit Detail - Added HTMX auto-refresh for real-time updates (10s interval) - Integrated dark/light mode toggle with localStorage persistence - Built responsive card-based UI with sidebar navigation - Created API endpoints for status snapshot, roster, unit details, and photos - Added mock data service for development (emit_status_snapshot) - Implemented tabbed interface on unit detail page (Photos, Map, History) - Integrated Leaflet maps for unit location visualization - Configured static file serving and photo management - Updated requirements.txt with Jinja2 and aiofiles - Reorganized backend structure into routers and services - Added comprehensive FRONTEND_README.md documentation Frontend features: - Auto-refreshing dashboard with fleet summary and alerts - Sortable fleet roster table (prioritizes Missing > Pending > OK) - Unit detail view with status, deployment info, and notes - Photo gallery with thumbnail navigation - Interactive maps showing unit coordinates - Consistent styling with brand colors (orange, navy, burgundy) Ready for integration with real Series3 emitter data.
This commit is contained in:
26
backend/database.py
Normal file
26
backend/database.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
import os
|
||||
|
||||
# Ensure data directory exists
|
||||
os.makedirs("data", exist_ok=True)
|
||||
|
||||
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
|
||||
|
||||
engine = create_engine(
|
||||
SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Dependency for database sessions"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
108
backend/main.py
Normal file
108
backend/main.py
Normal file
@@ -0,0 +1,108 @@
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
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.services.snapshot import emit_status_snapshot
|
||||
|
||||
# Create database tables
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Seismo Fleet Manager",
|
||||
description="Backend API for managing seismograph fleet status",
|
||||
version="0.1.0"
|
||||
)
|
||||
|
||||
# Configure CORS
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Mount static files
|
||||
app.mount("/static", StaticFiles(directory="backend/static"), name="static")
|
||||
|
||||
# Setup Jinja2 templates
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
|
||||
# Include API routers
|
||||
app.include_router(roster.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(photos.router)
|
||||
|
||||
|
||||
# Legacy routes from the original backend
|
||||
from backend import routes as legacy_routes
|
||||
app.include_router(legacy_routes.router)
|
||||
|
||||
|
||||
# HTML page routes
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def dashboard(request: Request):
|
||||
"""Dashboard home page"""
|
||||
return templates.TemplateResponse("dashboard.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/roster", response_class=HTMLResponse)
|
||||
async def roster_page(request: Request):
|
||||
"""Fleet roster page"""
|
||||
return templates.TemplateResponse("roster.html", {"request": request})
|
||||
|
||||
|
||||
@app.get("/unit/{unit_id}", response_class=HTMLResponse)
|
||||
async def unit_detail_page(request: Request, unit_id: str):
|
||||
"""Unit detail page"""
|
||||
return templates.TemplateResponse("unit_detail.html", {
|
||||
"request": request,
|
||||
"unit_id": unit_id
|
||||
})
|
||||
|
||||
|
||||
@app.get("/partials/roster-table", response_class=HTMLResponse)
|
||||
async def roster_table_partial(request: Request):
|
||||
"""Partial template for roster table (HTMX)"""
|
||||
from datetime import datetime
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
units_list = []
|
||||
for unit_id, unit_data in snapshot["units"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
})
|
||||
|
||||
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||
|
||||
return templates.TemplateResponse("partials/roster_table.html", {
|
||||
"request": request,
|
||||
"units": units_list,
|
||||
"timestamp": datetime.now().strftime("%H:%M:%S")
|
||||
})
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"message": "Seismo Fleet Manager v0.1",
|
||||
"status": "running"
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8001)
|
||||
18
backend/models.py
Normal file
18
backend/models.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from sqlalchemy import Column, String, DateTime
|
||||
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
|
||||
notes = Column(String, nullable=True)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Emitter(id={self.id}, type={self.unit_type}, status={self.status})>"
|
||||
64
backend/routers/photos.py
Normal file
64
backend/routers/photos.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
import os
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["photos"])
|
||||
|
||||
PHOTOS_BASE_DIR = Path("data/photos")
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/photos")
|
||||
def get_unit_photos(unit_id: str):
|
||||
"""
|
||||
Reads /data/photos/<unit_id>/ and returns list of image filenames.
|
||||
Primary photo = most recent file.
|
||||
"""
|
||||
unit_photo_dir = PHOTOS_BASE_DIR / unit_id
|
||||
|
||||
if not unit_photo_dir.exists():
|
||||
# Return empty list if no photos directory exists
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"photos": [],
|
||||
"primary_photo": None
|
||||
}
|
||||
|
||||
# Get all image files
|
||||
image_extensions = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
|
||||
photos = []
|
||||
|
||||
for file_path in unit_photo_dir.iterdir():
|
||||
if file_path.is_file() and file_path.suffix.lower() in image_extensions:
|
||||
photos.append({
|
||||
"filename": file_path.name,
|
||||
"path": f"/api/unit/{unit_id}/photo/{file_path.name}",
|
||||
"modified": file_path.stat().st_mtime
|
||||
})
|
||||
|
||||
# Sort by modification time (most recent first)
|
||||
photos.sort(key=lambda x: x["modified"], reverse=True)
|
||||
|
||||
# Primary photo is the most recent
|
||||
primary_photo = photos[0]["filename"] if photos else None
|
||||
|
||||
return {
|
||||
"unit_id": unit_id,
|
||||
"photos": [p["filename"] for p in photos],
|
||||
"primary_photo": primary_photo,
|
||||
"photo_urls": [p["path"] for p in photos]
|
||||
}
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}/photo/{filename}")
|
||||
def get_photo(unit_id: str, filename: str):
|
||||
"""
|
||||
Serves a specific photo file.
|
||||
"""
|
||||
file_path = PHOTOS_BASE_DIR / unit_id / filename
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail="Photo not found")
|
||||
|
||||
return FileResponse(file_path)
|
||||
46
backend/routers/roster.py
Normal file
46
backend/routers/roster.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, Any
|
||||
import random
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["roster"])
|
||||
|
||||
|
||||
@router.get("/status-snapshot")
|
||||
def get_status_snapshot(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Calls emit_status_snapshot() to get current fleet status.
|
||||
This will be replaced with real Series3 emitter logic later.
|
||||
"""
|
||||
return emit_status_snapshot()
|
||||
|
||||
|
||||
@router.get("/roster")
|
||||
def get_roster(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns list of units with their metadata and status.
|
||||
Uses mock data for now.
|
||||
"""
|
||||
snapshot = emit_status_snapshot()
|
||||
units_list = []
|
||||
|
||||
for unit_id, unit_data in snapshot["units"].items():
|
||||
units_list.append({
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
"last_file": unit_data.get("fname", "")
|
||||
})
|
||||
|
||||
# Sort by status priority (Missing > Pending > OK) then by ID
|
||||
status_priority = {"Missing": 0, "Pending": 1, "OK": 2}
|
||||
units_list.sort(key=lambda x: (status_priority.get(x["status"], 3), x["id"]))
|
||||
|
||||
return {"units": units_list}
|
||||
44
backend/routers/units.py
Normal file
44
backend/routers/units.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from datetime import datetime
|
||||
from typing import Dict, Any
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.services.snapshot import emit_status_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["units"])
|
||||
|
||||
|
||||
@router.get("/unit/{unit_id}")
|
||||
def get_unit_detail(unit_id: str, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns detailed data for a single unit.
|
||||
"""
|
||||
snapshot = emit_status_snapshot()
|
||||
|
||||
if unit_id not in snapshot["units"]:
|
||||
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
|
||||
|
||||
unit_data = snapshot["units"][unit_id]
|
||||
|
||||
# Mock coordinates for now (will be replaced with real data)
|
||||
mock_coords = {
|
||||
"BE1234": {"lat": 37.7749, "lon": -122.4194, "location": "San Francisco, CA"},
|
||||
"BE5678": {"lat": 34.0522, "lon": -118.2437, "location": "Los Angeles, CA"},
|
||||
"BE9012": {"lat": 40.7128, "lon": -74.0060, "location": "New York, NY"},
|
||||
"BE3456": {"lat": 41.8781, "lon": -87.6298, "location": "Chicago, IL"},
|
||||
"BE7890": {"lat": 29.7604, "lon": -95.3698, "location": "Houston, TX"},
|
||||
}
|
||||
|
||||
coords = mock_coords.get(unit_id, {"lat": 39.8283, "lon": -98.5795, "location": "Unknown"})
|
||||
|
||||
return {
|
||||
"id": unit_id,
|
||||
"status": unit_data["status"],
|
||||
"age": unit_data["age"],
|
||||
"last_seen": unit_data["last"],
|
||||
"last_file": unit_data.get("fname", ""),
|
||||
"deployed": unit_data["deployed"],
|
||||
"note": unit_data.get("note", ""),
|
||||
"coordinates": coords
|
||||
}
|
||||
82
backend/routes.py
Normal file
82
backend/routes.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
from pydantic import BaseModel
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from backend.database import get_db
|
||||
from backend.models import Emitter
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# Pydantic schemas for request/response validation
|
||||
class EmitterReport(BaseModel):
|
||||
unit: str
|
||||
unit_type: str
|
||||
timestamp: str
|
||||
file: str
|
||||
status: str
|
||||
|
||||
|
||||
class EmitterResponse(BaseModel):
|
||||
id: str
|
||||
unit_type: str
|
||||
last_seen: datetime
|
||||
last_file: str
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
@router.post("/emitters/report", status_code=200)
|
||||
def report_emitter(report: EmitterReport, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Endpoint for emitters to report their status.
|
||||
Creates a new emitter if it doesn't exist, or updates an existing one.
|
||||
"""
|
||||
try:
|
||||
# Parse the timestamp
|
||||
timestamp = datetime.fromisoformat(report.timestamp.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="Invalid timestamp format")
|
||||
|
||||
# Check if emitter already exists
|
||||
emitter = db.query(Emitter).filter(Emitter.id == report.unit).first()
|
||||
|
||||
if emitter:
|
||||
# Update existing emitter
|
||||
emitter.unit_type = report.unit_type
|
||||
emitter.last_seen = timestamp
|
||||
emitter.last_file = report.file
|
||||
emitter.status = report.status
|
||||
else:
|
||||
# Create new emitter
|
||||
emitter = Emitter(
|
||||
id=report.unit,
|
||||
unit_type=report.unit_type,
|
||||
last_seen=timestamp,
|
||||
last_file=report.file,
|
||||
status=report.status
|
||||
)
|
||||
db.add(emitter)
|
||||
|
||||
db.commit()
|
||||
db.refresh(emitter)
|
||||
|
||||
return {
|
||||
"message": "Emitter report received",
|
||||
"unit": emitter.id,
|
||||
"status": emitter.status
|
||||
}
|
||||
|
||||
|
||||
@router.get("/fleet/status", response_model=List[EmitterResponse])
|
||||
def get_fleet_status(db: Session = Depends(get_db)):
|
||||
"""
|
||||
Returns a list of all emitters and their current status.
|
||||
"""
|
||||
emitters = db.query(Emitter).all()
|
||||
return emitters
|
||||
96
backend/services/snapshot.py
Normal file
96
backend/services/snapshot.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Mock implementation of emit_status_snapshot().
|
||||
This will be replaced with real Series3 emitter logic by the user.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import random
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
# 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"
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
12
backend/static/style.css
Normal file
12
backend/static/style.css
Normal file
@@ -0,0 +1,12 @@
|
||||
/* Custom styles for Seismo Fleet Manager */
|
||||
|
||||
/* Additional custom styles can go here */
|
||||
|
||||
.card-hover {
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.card-hover:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
Reference in New Issue
Block a user