v0.4.0 - merge from claude/dev-015sto5mf2MpPCE57TbNKtaF #1

Merged
serversdown merged 32 commits from claude/dev-015sto5mf2MpPCE57TbNKtaF into main 2026-01-02 16:10:54 -05:00
19 changed files with 1396 additions and 50 deletions
Showing only changes of commit a6ad9fdecf - Show all commits

View File

@@ -13,7 +13,7 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . .
# Expose port
EXPOSE 8000
EXPOSE 8001
# Run the application
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# Run the application using the new backend structure
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8001"]

303
FRONTEND_README.md Normal file
View File

@@ -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
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
```
### 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
<div hx-get="/api/status-snapshot"
hx-trigger="load, every 10s"
hx-swap="none"
hx-on::after-request="updateDashboard(event)">
```
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
<div hx-get="/partials/roster-table"
hx-trigger="load, every 10s"
hx-swap="innerHTML">
```
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.

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)

View File

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

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
}

View File

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

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

View File

@@ -5,15 +5,15 @@ services:
build: .
container_name: seismo-fleet-manager
ports:
- "8001:8000"
- "8001:8001"
volumes:
# Persist SQLite database
# Persist SQLite database and photos
- ./data:/app/data
environment:
- PYTHONUNBUFFERED=1
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8000/"]
test: ["CMD", "curl", "-f", "http://localhost:8001/health"]
interval: 30s
timeout: 10s
retries: 3

41
main.py
View File

@@ -1,41 +0,0 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from database import engine, Base
from routes import router
# 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 (adjust origins as needed for your deployment)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # In production, specify exact origins
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API routes
app.include_router(router)
@app.get("/")
def root():
"""Root endpoint - health check"""
return {
"message": "Seismo Fleet Manager API v0.1",
"status": "running"
}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)

View File

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

150
templates/base.html Normal file
View File

@@ -0,0 +1,150 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Seismo Fleet Manager{% endblock %}</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"></script>
<!-- Leaflet for maps -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<!-- Custom Tailwind Config -->
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
colors: {
seismo: {
orange: '#f48b1c',
navy: '#142a66',
burgundy: '#7d234d',
}
}
}
}
}
</script>
<style>
/* Custom scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Smooth transitions */
* {
transition: background-color 0.2s ease, color 0.2s ease;
}
</style>
{% block extra_head %}{% endblock %}
</head>
<body class="bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col">
<!-- Logo -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<h1 class="text-2xl font-bold text-seismo-navy dark:text-seismo-orange">
Seismo<br>
<span class="text-seismo-orange dark:text-seismo-burgundy">Fleet Manager</span>
</h1>
</div>
<!-- Navigation -->
<nav class="flex-1 p-4 space-y-2">
<a href="/" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6"></path>
</svg>
Dashboard
</a>
<a href="/roster" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/roster' %}bg-gray-100 dark:bg-gray-700{% endif %}">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
Fleet Roster
</a>
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
Projects
</a>
<a href="#" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 opacity-50 cursor-not-allowed">
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
Settings
</a>
</nav>
<!-- Dark mode toggle -->
<div class="p-4 border-t border-gray-200 dark:border-gray-700">
<button onclick="toggleDarkMode()" class="w-full flex items-center justify-center px-4 py-3 rounded-lg bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600">
<svg id="theme-toggle-dark-icon" class="w-5 h-5 hidden dark:block" fill="currentColor" viewBox="0 0 20 20">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<svg id="theme-toggle-light-icon" class="w-5 h-5 block dark:hidden" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
<span class="ml-3">Toggle theme</span>
</button>
</div>
</aside>
<!-- Main content -->
<main class="flex-1 overflow-y-auto">
<div class="p-8">
{% block content %}{% endblock %}
</div>
</main>
</div>
<script>
// Dark mode toggle
function toggleDarkMode() {
const html = document.documentElement;
if (html.classList.contains('dark')) {
html.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
html.classList.add('dark');
localStorage.setItem('theme', 'dark');
}
}
// Load saved theme preference
if (localStorage.getItem('theme') === 'light') {
document.documentElement.classList.remove('dark');
} else if (localStorage.getItem('theme') === 'dark') {
document.documentElement.classList.add('dark');
}
</script>
{% block extra_scripts %}{% endblock %}
</body>
</html>

178
templates/dashboard.html Normal file
View File

@@ -0,0 +1,178 @@
{% extends "base.html" %}
{% block title %}Dashboard - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Fleet overview and recent activity</p>
</div>
<!-- Dashboard cards with auto-refresh -->
<div hx-get="/api/status-snapshot" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateDashboard(event)">
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Fleet Summary Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Fleet Summary</h2>
<svg class="w-6 h-6 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"></path>
</svg>
</div>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Total Units</span>
<span id="total-units" class="text-2xl font-bold text-gray-900 dark:text-white">--</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-units" class="text-2xl font-bold text-blue-600 dark:text-blue-400">--</span>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-3 mt-3">
<div class="flex justify-between items-center mb-2">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-green-500 mr-2"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">OK</span>
</div>
<span id="status-ok" class="font-semibold text-green-600 dark:text-green-400">--</span>
</div>
<div class="flex justify-between items-center mb-2">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-yellow-500 mr-2"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">Pending</span>
</div>
<span id="status-pending" class="font-semibold text-yellow-600 dark:text-yellow-400">--</span>
</div>
<div class="flex justify-between items-center">
<div class="flex items-center">
<span class="w-3 h-3 rounded-full bg-red-500 mr-2"></span>
<span class="text-sm text-gray-600 dark:text-gray-400">Missing</span>
</div>
<span id="status-missing" class="font-semibold text-red-600 dark:text-red-400">--</span>
</div>
</div>
</div>
</div>
<!-- Recent Alerts Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Alerts</h2>
<svg class="w-6 h-6 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
</div>
<div id="alerts-list" class="space-y-3">
<p class="text-sm text-gray-500 dark:text-gray-400">Loading alerts...</p>
</div>
</div>
<!-- Recent Photos Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Recent Photos</h2>
<svg class="w-6 h-6 text-seismo-burgundy" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div class="text-center text-gray-500 dark:text-gray-400">
<svg class="w-16 h-16 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="text-sm">No recent photos</p>
</div>
</div>
</div>
<!-- Quick Access to Fleet Roster -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Fleet Status</h2>
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy font-medium flex items-center">
View All
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</div>
<div id="fleet-preview" class="space-y-2">
<p class="text-gray-500 dark:text-gray-400">Loading fleet data...</p>
</div>
</div>
</div>
<script>
function updateDashboard(event) {
try {
const data = JSON.parse(event.detail.xhr.response);
// Update fleet summary
document.getElementById('total-units').textContent = data.total_units || 0;
document.getElementById('deployed-units').textContent = data.deployed_units || 0;
document.getElementById('status-ok').textContent = data.status_summary.OK || 0;
document.getElementById('status-pending').textContent = data.status_summary.Pending || 0;
document.getElementById('status-missing').textContent = data.status_summary.Missing || 0;
// Update alerts list
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Missing');
const pendingUnits = Object.entries(data.units).filter(([id, unit]) => unit.status === 'Pending');
if (missingUnits.length === 0 && pendingUnits.length === 0) {
alertsList.innerHTML = '<p class="text-sm text-green-600 dark:text-green-400">✓ All units reporting normally</p>';
} else {
let alertsHtml = '';
missingUnits.slice(0, 3).forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-red-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-red-600 dark:text-red-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Missing for ${unit.age}</p>
</div>
</div>
`;
});
pendingUnits.slice(0, 2).forEach(([id, unit]) => {
alertsHtml += `
<div class="flex items-start space-x-2 text-sm">
<span class="w-2 h-2 rounded-full bg-yellow-500 mt-1.5"></span>
<div>
<a href="/unit/${id}" class="font-medium text-yellow-600 dark:text-yellow-400 hover:underline">${id}</a>
<p class="text-gray-600 dark:text-gray-400">Pending for ${unit.age}</p>
</div>
</div>
`;
});
alertsList.innerHTML = alertsHtml;
}
// Update fleet preview
const fleetPreview = document.getElementById('fleet-preview');
const unitsList = Object.entries(data.units).slice(0, 5);
let previewHtml = '';
unitsList.forEach(([id, unit]) => {
const statusColor = unit.status === 'OK' ? 'green' : unit.status === 'Pending' ? 'yellow' : 'red';
const deployedDot = unit.deployed ? '<span class="w-2 h-2 rounded-full bg-blue-500"></span>' : '';
previewHtml += `
<a href="/unit/${id}" class="flex items-center justify-between p-3 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700">
<div class="flex items-center space-x-3">
<span class="w-3 h-3 rounded-full bg-${statusColor}-500"></span>
${deployedDot}
<span class="font-medium">${id}</span>
</div>
<span class="text-sm text-gray-500 dark:text-gray-400">${unit.age}</span>
</a>
`;
});
fleetPreview.innerHTML = previewHtml;
} catch (error) {
console.error('Error updating dashboard:', error);
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,87 @@
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Status
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Unit ID
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Last Seen
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Age
</th>
<th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Note
</th>
<th scope="col" class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-slate-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
{% elif unit.status == 'Pending' %}
<span class="w-3 h-3 rounded-full bg-yellow-500" title="Pending"></span>
{% else %}
<span class="w-3 h-3 rounded-full bg-red-500" title="Missing"></span>
{% endif %}
{% if unit.deployed %}
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ unit.id }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm text-gray-500 dark:text-gray-400">{{ unit.last_seen }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm
{% if unit.status == 'Missing' %}text-red-600 dark:text-red-400 font-semibold
{% elif unit.status == 'Pending' %}text-yellow-600 dark:text-yellow-400
{% else %}text-gray-500 dark:text-gray-400
{% endif %}">
{{ unit.age }}
</div>
</td>
<td class="px-6 py-4">
<div class="text-sm text-gray-500 dark:text-gray-400 truncate max-w-xs" title="{{ unit.note }}">
{{ unit.note if unit.note else '-' }}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<a href="/unit/{{ unit.id }}" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center">
View
<svg class="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<!-- Last updated indicator -->
<div class="px-6 py-3 bg-gray-50 dark:bg-gray-700 text-xs text-gray-500 dark:text-gray-400 text-right">
Last updated: <span id="last-updated">{{ timestamp }}</span>
</div>
</div>
<script>
// Update timestamp
document.getElementById('last-updated').textContent = new Date().toLocaleTimeString();
</script>

29
templates/roster.html Normal file
View File

@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Fleet Roster - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-8">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Roster</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Real-time status of all seismograph units</p>
</div>
<!-- Auto-refresh roster every 10 seconds -->
<div hx-get="/partials/roster-table" hx-trigger="load, every 10s" hx-swap="innerHTML">
<!-- Initial loading state -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<div class="flex items-center justify-center py-12">
<div class="animate-pulse flex space-x-4">
<div class="flex-1 space-y-4 py-1">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-3/4"></div>
<div class="space-y-2">
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded"></div>
<div class="h-4 bg-gray-300 dark:bg-gray-600 rounded w-5/6"></div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

268
templates/unit_detail.html Normal file
View File

@@ -0,0 +1,268 @@
{% extends "base.html" %}
{% block title %}Unit {{ unit_id }} - Seismo Fleet Manager{% endblock %}
{% block content %}
<div class="mb-6">
<a href="/roster" class="text-seismo-orange hover:text-seismo-burgundy inline-flex items-center mb-4">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Back to Fleet Roster
</a>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Unit {{ unit_id }}</h1>
</div>
<!-- Auto-refresh unit data -->
<div hx-get="/api/unit/{{ unit_id }}" hx-trigger="load, every 10s" hx-swap="none" hx-on::after-request="updateUnitData(event)">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Left Column: Unit Info -->
<div class="space-y-6">
<!-- Status Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Unit Status</h2>
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Status</span>
<div class="flex items-center space-x-2">
<span id="status-indicator" class="w-3 h-3 rounded-full bg-gray-400"></span>
<span id="status-text" class="font-semibold">Loading...</span>
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Deployed</span>
<span id="deployed-status" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Age</span>
<span id="age-value" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Last Seen</span>
<span id="last-seen-value" class="font-semibold">--</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600 dark:text-gray-400">Last File</span>
<span id="last-file-value" class="font-mono text-sm">--</span>
</div>
</div>
</div>
<!-- Notes Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Notes</h2>
<div id="notes-content" class="text-gray-600 dark:text-gray-400">
Loading...
</div>
</div>
<!-- Metadata Card -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Edit Metadata</h2>
<form class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Unit Note
</label>
<textarea
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-seismo-orange focus:border-transparent"
rows="3"
placeholder="Enter notes about this unit...">
</textarea>
</div>
<button
type="button"
class="w-full px-4 py-2 bg-seismo-orange hover:bg-seismo-burgundy text-white rounded-lg font-medium transition-colors"
onclick="alert('Mock: Save functionality not implemented')">
Save Changes
</button>
</form>
</div>
</div>
<!-- Right Column: Tabbed Interface -->
<div class="space-y-6">
<!-- Tabs -->
<div class="rounded-xl shadow-lg bg-white dark:bg-slate-800 overflow-hidden">
<div class="border-b border-gray-200 dark:border-gray-700">
<nav class="flex -mb-px">
<button
onclick="switchTab('photos')"
id="tab-photos"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Photos
</button>
<button
onclick="switchTab('map')"
id="tab-map"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
Map
</button>
<button
onclick="switchTab('history')"
id="tab-history"
class="tab-button flex-1 py-4 px-6 text-center border-b-2 font-medium text-sm transition-colors border-transparent text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
History
</button>
</nav>
</div>
<!-- Tab Content -->
<div class="p-6">
<!-- Photos Tab -->
<div id="content-photos" class="tab-content">
<div hx-get="/api/unit/{{ unit_id }}/photos" hx-trigger="load" hx-swap="none" hx-on::after-request="updatePhotos(event)">
<div id="photos-container" class="text-center">
<div class="animate-pulse">
<div class="h-64 bg-gray-200 dark:bg-gray-700 rounded-lg mb-4"></div>
<div class="grid grid-cols-4 gap-2">
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
<div class="h-20 bg-gray-200 dark:bg-gray-700 rounded"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Map Tab -->
<div id="content-map" class="tab-content hidden">
<div id="map" style="height: 500px; width: 100%;" class="rounded-lg"></div>
<div id="location-info" class="mt-4 text-sm text-gray-600 dark:text-gray-400">
<p><strong>Location:</strong> <span id="location-name">Loading...</span></p>
<p><strong>Coordinates:</strong> <span id="coordinates">--</span></p>
</div>
</div>
<!-- History Tab -->
<div id="content-history" class="tab-content hidden">
<div class="text-center text-gray-500 dark:text-gray-400 py-12">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<p class="text-lg font-medium">Event History</p>
<p class="text-sm mt-2">Event history will be displayed here</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
let unitData = null;
let map = null;
let marker = null;
function switchTab(tabName) {
// Update tab buttons
document.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('border-seismo-orange', 'text-seismo-orange');
btn.classList.add('border-transparent', 'text-gray-500', 'dark:text-gray-400');
});
document.getElementById(`tab-${tabName}`).classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
document.getElementById(`tab-${tabName}`).classList.add('border-seismo-orange', 'text-seismo-orange');
// Update tab content
document.querySelectorAll('.tab-content').forEach(content => {
content.classList.add('hidden');
});
document.getElementById(`content-${tabName}`).classList.remove('hidden');
// Initialize map if switching to map tab
if (tabName === 'map' && !map && unitData) {
setTimeout(() => initMap(), 100);
}
}
function updateUnitData(event) {
try {
unitData = JSON.parse(event.detail.xhr.response);
// Update status
const statusIndicator = document.getElementById('status-indicator');
const statusText = document.getElementById('status-text');
const statusColors = {
'OK': 'bg-green-500',
'Pending': 'bg-yellow-500',
'Missing': 'bg-red-500'
};
statusIndicator.className = `w-3 h-3 rounded-full ${statusColors[unitData.status] || 'bg-gray-400'}`;
statusText.textContent = unitData.status;
statusText.className = `font-semibold ${unitData.status === 'OK' ? 'text-green-600 dark:text-green-400' : unitData.status === 'Pending' ? 'text-yellow-600 dark:text-yellow-400' : 'text-red-600 dark:text-red-400'}`;
// Update other fields
document.getElementById('deployed-status').textContent = unitData.deployed ? '✓ Deployed' : '✗ Benched';
document.getElementById('age-value').textContent = unitData.age;
document.getElementById('last-seen-value').textContent = unitData.last_seen;
document.getElementById('last-file-value').textContent = unitData.last_file;
document.getElementById('notes-content').textContent = unitData.note || 'No notes available';
// Update location info
if (unitData.coordinates) {
document.getElementById('location-name').textContent = unitData.coordinates.location;
document.getElementById('coordinates').textContent = `${unitData.coordinates.lat.toFixed(4)}, ${unitData.coordinates.lon.toFixed(4)}`;
}
} catch (error) {
console.error('Error updating unit data:', error);
}
}
function updatePhotos(event) {
try {
const data = JSON.parse(event.detail.xhr.response);
const container = document.getElementById('photos-container');
if (data.photos.length === 0) {
container.innerHTML = `
<div class="text-center text-gray-500 dark:text-gray-400 py-12">
<svg class="w-16 h-16 mx-auto mb-4 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
<p class="text-lg font-medium">No Photos Available</p>
<p class="text-sm mt-2">Photos will appear here when uploaded</p>
</div>
`;
} else {
let html = `
<div class="mb-4">
<img src="${data.photo_urls[0]}" alt="Primary photo" class="w-full h-auto rounded-lg shadow-lg" id="primary-image">
</div>
<div class="grid grid-cols-4 gap-2">
`;
data.photo_urls.forEach((url, index) => {
html += `
<img src="${url}" alt="Photo ${index + 1}"
class="w-full h-20 object-cover rounded cursor-pointer hover:opacity-75 transition-opacity"
onclick="document.getElementById('primary-image').src = this.src">
`;
});
html += '</div>';
container.innerHTML = html;
}
} catch (error) {
console.error('Error updating photos:', error);
}
}
function initMap() {
if (!unitData || !unitData.coordinates) return;
const coords = unitData.coordinates;
map = L.map('map').setView([coords.lat, coords.lon], 13);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
marker = L.marker([coords.lat, coords.lon]).addTo(map)
.bindPopup(`<b>${unitData.id}</b><br>${coords.location}`)
.openPopup();
}
</script>
{% endblock %}