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

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
}