diff --git a/FRONTEND_README.md b/FRONTEND_README.md
new file mode 100644
index 0000000..b9e2fdb
--- /dev/null
+++ b/FRONTEND_README.md
@@ -0,0 +1,303 @@
+# Seismo Fleet Manager - Frontend Documentation
+
+## Overview
+
+This is the MVP frontend scaffold for **Seismo Fleet Manager**, built with:
+- **FastAPI** (backend framework)
+- **HTMX** (dynamic updates without JavaScript frameworks)
+- **TailwindCSS** (utility-first styling)
+- **Jinja2** (server-side templating)
+- **Leaflet** (interactive maps)
+
+No React, Vue, or other frontend frameworks are used.
+
+## Project Structure
+
+```
+seismo-fleet-manager/
+├── backend/
+│ ├── main.py # FastAPI app entry point
+│ ├── routers/
+│ │ ├── roster.py # Fleet roster endpoints
+│ │ ├── units.py # Individual unit endpoints
+│ │ └── photos.py # Photo management endpoints
+│ ├── services/
+│ │ └── snapshot.py # Mock status snapshot (replace with real logic)
+│ ├── static/
+│ │ └── style.css # Custom CSS
+│ ├── database.py # SQLAlchemy database setup
+│ ├── models.py # Database models
+│ └── routes.py # Legacy API routes
+├── templates/
+│ ├── base.html # Base layout with sidebar & dark mode
+│ ├── dashboard.html # Main dashboard page
+│ ├── roster.html # Fleet roster page
+│ ├── unit_detail.html # Unit detail page
+│ └── partials/
+│ └── roster_table.html # HTMX partial for roster table
+├── data/
+│ └── photos/ # Photo storage (organized by unit_id)
+└── requirements.txt
+```
+
+## Running the Application
+
+### Install Dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+### Run the Server
+
+```bash
+uvicorn backend.main:app --host 0.0.0.0 --port 8001 --reload
+```
+
+The application will be available at:
+- **Web UI**: http://localhost:8001/
+- **API Docs**: http://localhost:8001/docs
+- **Health Check**: http://localhost:8001/health
+
+## Features
+
+### 1. Dashboard (`/`)
+
+The main dashboard provides an at-a-glance view of the fleet:
+
+- **Fleet Summary Card**: Total units, deployed units, status breakdown
+- **Recent Alerts Card**: Shows units with Missing or Pending status
+- **Recent Photos Card**: Placeholder for photo gallery
+- **Fleet Status Preview**: Quick view of first 5 units
+
+**Auto-refresh**: Dashboard updates every 10 seconds via HTMX
+
+### 2. Fleet Roster (`/roster`)
+
+A comprehensive table view of all seismograph units:
+
+**Columns**:
+- Status indicator (colored dot: green=OK, yellow=Pending, red=Missing)
+- Deployment indicator (blue dot if deployed)
+- Unit ID
+- Last seen timestamp
+- Age since last contact
+- Notes
+- Actions (View detail button)
+
+**Features**:
+- Auto-refresh every 10 seconds
+- Sorted by priority (Missing > Pending > OK)
+- Click any row to view unit details
+
+### 3. Unit Detail Page (`/unit/{unit_id}`)
+
+Split-screen layout with detailed information:
+
+**Left Column**:
+- Status card with real-time updates
+- Deployment status
+- Last contact time and file
+- Notes section
+- Editable metadata (mock form)
+
+**Right Column - Tabbed Interface**:
+- **Photos Tab**: Primary photo with thumbnail gallery
+- **Map Tab**: Interactive Leaflet map showing unit location
+- **History Tab**: Placeholder for event history
+
+**Auto-refresh**: Unit data updates every 10 seconds
+
+### 4. Dark/Light Mode
+
+Toggle button in sidebar switches between themes:
+- Uses Tailwind's `dark:` classes
+- Preference saved to localStorage
+- Smooth transitions on theme change
+
+## API Endpoints
+
+### Status & Fleet Data
+
+```http
+GET /api/status-snapshot
+```
+Returns complete fleet status snapshot with statistics.
+
+```http
+GET /api/roster
+```
+Returns sorted list of all units for roster table.
+
+```http
+GET /api/unit/{unit_id}
+```
+Returns detailed information for a single unit including coordinates.
+
+### Photo Management
+
+```http
+GET /api/unit/{unit_id}/photos
+```
+Returns list of photos for a unit, sorted by recency.
+
+```http
+GET /api/unit/{unit_id}/photo/{filename}
+```
+Serves a specific photo file.
+
+### Legacy Endpoints
+
+```http
+POST /emitters/report
+```
+Endpoint for emitters to report status (from original backend).
+
+```http
+GET /fleet/status
+```
+Returns database-backed fleet status (from original backend).
+
+## Mock Data
+
+### Location: `backend/services/snapshot.py`
+
+The `emit_status_snapshot()` function currently returns mock data with 8 units:
+
+- **BE1234**: OK, deployed (San Francisco)
+- **BE5678**: Pending, deployed (Los Angeles)
+- **BE9012**: Missing, deployed (New York)
+- **BE3456**: OK, benched (Chicago)
+- **BE7890**: OK, deployed (Houston)
+- **BE2468**: Pending, deployed
+- **BE1357**: OK, benched
+- **BE8642**: Missing, deployed
+
+**To replace with real data**: Update the `emit_status_snapshot()` function to call your Series3 emitter logic.
+
+## Styling
+
+### Color Palette
+
+The application uses your brand colors:
+
+```css
+orange: #f48b1c
+navy: #142a66
+burgundy: #7d234d
+```
+
+These are configured in the Tailwind config as `seismo-orange`, `seismo-navy`, `seismo-burgundy`.
+
+### Cards
+
+All cards use the consistent styling:
+```html
+
+```
+
+### Status Indicators
+
+- Green dot: OK status
+- Yellow dot: Pending status
+- Red dot: Missing status
+- Blue dot: Deployed
+- Gray dot: Benched
+
+## HTMX Usage
+
+HTMX enables dynamic updates without writing JavaScript:
+
+### Auto-refresh Example (Dashboard)
+
+```html
+
+```
+
+This fetches the snapshot on page load and every 10 seconds, then calls a JavaScript function to update the DOM.
+
+### Partial Template Loading (Roster)
+
+```html
+
+```
+
+This replaces the entire inner HTML with the server-rendered roster table every 10 seconds.
+
+## Adding Photos
+
+To add photos for a unit:
+
+1. Create a directory: `data/photos/{unit_id}/`
+2. Add image files (jpg, jpeg, png, gif, webp)
+3. Photos will automatically appear on the unit detail page
+4. Most recent file becomes the primary photo
+
+Example:
+```bash
+mkdir -p data/photos/BE1234
+cp my-photo.jpg data/photos/BE1234/deployment-site.jpg
+```
+
+## Customization
+
+### Adding New Pages
+
+1. Create a template in `templates/`
+2. Add a route in `backend/main.py`:
+
+```python
+@app.get("/my-page", response_class=HTMLResponse)
+async def my_page(request: Request):
+ return templates.TemplateResponse("my_page.html", {"request": request})
+```
+
+3. Add a navigation link in `templates/base.html`
+
+### Adding New API Endpoints
+
+1. Create a router file in `backend/routers/`
+2. Include the router in `backend/main.py`:
+
+```python
+from backend.routers import my_router
+app.include_router(my_router.router)
+```
+
+## Docker Deployment
+
+The project includes Docker configuration:
+
+```bash
+docker-compose up
+```
+
+This will start the application on port 8001 (configured to avoid conflicts with port 8000).
+
+## Next Steps
+
+1. **Replace Mock Data**: Update `backend/services/snapshot.py` with real Series3 emitter logic
+2. **Database Integration**: The existing SQLAlchemy models can store historical data
+3. **Photo Upload**: Add a form to upload photos from the UI
+4. **Projects Management**: Implement the "Projects" page
+5. **Settings**: Add user preferences and configuration
+6. **Event History**: Populate the History tab with real event data
+7. **Authentication**: Add user login/authentication if needed
+8. **Notifications**: Add real-time alerts for critical status changes
+
+## Development Tips
+
+- The `--reload` flag auto-reloads the server when code changes
+- Use browser DevTools to debug HTMX requests (look for `HX-Request` headers)
+- Check `/docs` for interactive API documentation (Swagger UI)
+- Dark mode state persists in browser localStorage
+- All timestamps are currently mock data - replace with real values
+
+## License
+
+See main README.md for license information.
diff --git a/database.py b/backend/database.py
similarity index 100%
rename from database.py
rename to backend/database.py
diff --git a/backend/main.py b/backend/main.py
new file mode 100644
index 0000000..9e75d26
--- /dev/null
+++ b/backend/main.py
@@ -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)
diff --git a/models.py b/backend/models.py
similarity index 94%
rename from models.py
rename to backend/models.py
index 9ae42c1..64490fc 100644
--- a/models.py
+++ b/backend/models.py
@@ -1,6 +1,6 @@
from sqlalchemy import Column, String, DateTime
from datetime import datetime
-from database import Base
+from backend.database import Base
class Emitter(Base):
diff --git a/backend/routers/photos.py b/backend/routers/photos.py
new file mode 100644
index 0000000..0d083bc
--- /dev/null
+++ b/backend/routers/photos.py
@@ -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// 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)
diff --git a/backend/routers/roster.py b/backend/routers/roster.py
new file mode 100644
index 0000000..d2792e1
--- /dev/null
+++ b/backend/routers/roster.py
@@ -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}
diff --git a/backend/routers/units.py b/backend/routers/units.py
new file mode 100644
index 0000000..654fa2c
--- /dev/null
+++ b/backend/routers/units.py
@@ -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
+ }
diff --git a/routes.py b/backend/routes.py
similarity index 96%
rename from routes.py
rename to backend/routes.py
index 4cdb4fa..e7e34da 100644
--- a/routes.py
+++ b/backend/routes.py
@@ -4,8 +4,8 @@ from pydantic import BaseModel
from datetime import datetime
from typing import Optional, List
-from database import get_db
-from models import Emitter
+from backend.database import get_db
+from backend.models import Emitter
router = APIRouter()
diff --git a/backend/services/snapshot.py b/backend/services/snapshot.py
new file mode 100644
index 0000000..8d7e38d
--- /dev/null
+++ b/backend/services/snapshot.py
@@ -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")
+ }
+ }
diff --git a/backend/static/style.css b/backend/static/style.css
new file mode 100644
index 0000000..e05260a
--- /dev/null
+++ b/backend/static/style.css
@@ -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);
+}
diff --git a/requirements.txt b/requirements.txt
index 4ab590c..f526daf 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,3 +3,5 @@ uvicorn[standard]==0.24.0
sqlalchemy==2.0.23
pydantic==2.5.0
python-multipart==0.0.6
+jinja2==3.1.2
+aiofiles==23.2.1
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000..155f6ac
--- /dev/null
+++ b/templates/base.html
@@ -0,0 +1,150 @@
+
+
+
+
+
+ {% block title %}Seismo Fleet Manager{% endblock %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% block extra_head %}{% endblock %}
+
+
+