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:
Claude
2025-11-22 00:16:26 +00:00
parent e7e660a9c3
commit 247405c361
16 changed files with 1390 additions and 3 deletions

26
backend/database.py Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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

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