2 Commits

Author SHA1 Message Date
0e3f512203 Feat: expands project reservation system.
-Reservation list view
-expandable project cards
2026-03-15 05:25:23 +00:00
e4d1f0d684 feat: start build of listed reservation system 2026-03-13 21:37:06 +00:00
15 changed files with 987 additions and 747 deletions

View File

@@ -5,23 +5,6 @@ All notable changes to Terra-View will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.8.0] - 2026-03-18
### Added
- **Watcher Manager**: New admin page (`/admin/watchers`) for monitoring field watcher agents
- Live status cards per agent showing connectivity, version, IP, last-seen age, and log tail
- Trigger Update button to queue a self-update on the agent's next heartbeat
- Expand/collapse log tail with full-log expand mode
- Live surgical refresh every 30 seconds via `/api/admin/watchers` — no full page reload, open logs stay open
### Changed
- **Watcher status logic**: Agent status now reflects whether Terra-View is hearing from the watcher (ok if seen within 60 minutes, missing otherwise) — previously reflected the worst unit status from the last heartbeat payload, which caused false alarms when units went missing
### Fixed
- **Watcher Manager meta row**: Dark mode background was white due to invalid `dark:bg-slate-850` Tailwind class; corrected to `dark:bg-slate-800`
---
## [0.7.1] - 2026-03-12 ## [0.7.1] - 2026-03-12
### Added ### Added

View File

@@ -1,4 +1,4 @@
# Terra-View v0.8.0 # Terra-View v0.7.1
Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features
@@ -496,11 +496,6 @@ docker compose down -v
## Release Highlights ## Release Highlights
### v0.8.0 — 2026-03-18
- **Watcher Manager**: Admin page for monitoring field watcher agents with live status cards, log tails, and one-click update triggering
- **Watcher Status Fix**: Agent status now reflects heartbeat connectivity (missing if not heard from in >60 min) rather than unit-level data staleness
- **Live Refresh**: Watcher Manager surgically patches status, last-seen, and pending indicators every 30s without a full page reload
### v0.7.0 — 2026-03-07 ### v0.7.0 — 2026-03-07
- **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions - **Project Status Management**: On-hold and archived project states with automatic cancellation of pending actions
- **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata - **Manual SD Card Upload**: Upload offline NRL/SLM data directly from SD card (ZIP or multi-file); auto-creates monitoring sessions from `.rnh` metadata
@@ -599,13 +594,9 @@ MIT
## Version ## Version
**Current: 0.8.0**Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18) **Current: 0.7.0**Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12) Previous: 0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
0.7.0 — Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06) 0.6.0 — Calendar & reservation mode, device pairing interface, calibration UX overhaul, modem dashboard enhancements (2026-02-06)

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.8.0" VERSION = "0.7.1"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":
@@ -102,9 +102,6 @@ app.include_router(modem_dashboard.router)
from backend.routers import settings from backend.routers import settings
app.include_router(settings.router) app.include_router(settings.router)
from backend.routers import watcher_manager
app.include_router(watcher_manager.router)
# Projects system routers # Projects system routers
app.include_router(projects.router) app.include_router(projects.router)
app.include_router(project_locations.router) app.include_router(project_locations.router)

View File

@@ -66,26 +66,6 @@ class RosterUnit(Base):
slm_last_check = Column(DateTime, nullable=True) # Last communication check slm_last_check = Column(DateTime, nullable=True) # Last communication check
class WatcherAgent(Base):
"""
Watcher agents: tracks the watcher processes (series3-watcher, thor-watcher)
that run on field machines and report unit heartbeats.
Updated on every heartbeat received from each source_id.
"""
__tablename__ = "watcher_agents"
id = Column(String, primary_key=True, index=True) # source_id (hostname)
source_type = Column(String, nullable=False) # series3_watcher | series4_watcher
version = Column(String, nullable=True) # e.g. "1.4.0"
last_seen = Column(DateTime, default=datetime.utcnow)
status = Column(String, nullable=False, default="unknown") # ok | pending | missing | error | unknown
ip_address = Column(String, nullable=True)
log_tail = Column(Text, nullable=True) # last N log lines (JSON array of strings)
update_pending = Column(Boolean, default=False) # set True to trigger remote update
update_version = Column(String, nullable=True) # target version to update to
class IgnoredUnit(Base): class IgnoredUnit(Base):
""" """
Ignored units: units that report but should be filtered out from unknown emitters. Ignored units: units that report but should be filtered out from unknown emitters.
@@ -515,3 +495,6 @@ class JobReservationUnit(Base):
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap" assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
assigned_at = Column(DateTime, default=datetime.utcnow) assigned_at = Column(DateTime, default=datetime.utcnow)
notes = Column(Text, nullable=True) # "Replacing BE17353" etc. notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
# Power requirements for this deployment slot
power_type = Column(String, nullable=True) # "ac" | "solar" | None

View File

@@ -223,6 +223,10 @@ async def get_reservation(
unit_ids = [a.unit_id for a in assignments] unit_ids = [a.unit_id for a in assignments]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else [] units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
units_by_id = {u.id: u for u in units}
# Build power_type and notes lookup from assignments
power_type_map = {a.unit_id: a.power_type for a in assignments}
notes_map = {a.unit_id: a.notes for a in assignments}
return { return {
"id": reservation.id, "id": reservation.id,
@@ -239,11 +243,13 @@ async def get_reservation(
"color": reservation.color, "color": reservation.color,
"assigned_units": [ "assigned_units": [
{ {
"id": u.id, "id": uid,
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None, "last_calibrated": units_by_id[uid].last_calibrated.isoformat() if uid in units_by_id and units_by_id[uid].last_calibrated else None,
"deployed": u.deployed "deployed": units_by_id[uid].deployed if uid in units_by_id else False,
"power_type": power_type_map.get(uid),
"notes": notes_map.get(uid)
} }
for u in units for uid in unit_ids
] ]
} }
@@ -337,29 +343,27 @@ async def assign_units_to_reservation(
data = await request.json() data = await request.json()
unit_ids = data.get("unit_ids", []) unit_ids = data.get("unit_ids", [])
# Optional per-unit power types: {"BE17354": "ac", "BE9441": "solar"}
power_types = data.get("power_types", {})
location_notes = data.get("location_notes", {})
if not unit_ids: # Verify units exist (allow empty list to clear all assignments)
raise HTTPException(status_code=400, detail="No units specified") if unit_ids:
# Verify units exist
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
found_ids = {u.id for u in units} found_ids = {u.id for u in units}
missing = set(unit_ids) - found_ids missing = set(unit_ids) - found_ids
if missing: if missing:
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}") raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
# Check for conflicts (already assigned to overlapping reservations) # Full replace: delete all existing assignments for this reservation first
db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).delete()
db.flush()
# Check for conflicts with other reservations and insert new assignments
conflicts = [] conflicts = []
for unit_id in unit_ids: for unit_id in unit_ids:
# Check if unit is already assigned to this reservation
existing = db.query(JobReservationUnit).filter_by(
reservation_id=reservation_id,
unit_id=unit_id
).first()
if existing:
continue # Already assigned, skip
# Check overlapping reservations # Check overlapping reservations
if reservation.end_date:
overlapping = db.query(JobReservation).join( overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter( ).filter(
@@ -382,7 +386,9 @@ async def assign_units_to_reservation(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
reservation_id=reservation_id, reservation_id=reservation_id,
unit_id=unit_id, unit_id=unit_id,
assignment_source="filled" if reservation.assignment_type == "quantity" else "specific" assignment_source="filled" if reservation.assignment_type == "quantity" else "specific",
power_type=power_types.get(unit_id),
notes=location_notes.get(unit_id)
) )
db.add(assignment) db.add(assignment)
@@ -511,9 +517,8 @@ async def get_reservations_list(
else: else:
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1) end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Include TBD reservations that started before window end # Include TBD reservations that started before window end — show ALL device types
reservations = db.query(JobReservation).filter( reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date, JobReservation.start_date <= end_date,
or_( or_(
JobReservation.end_date >= start_date, JobReservation.end_date >= start_date,
@@ -524,9 +529,25 @@ async def get_reservations_list(
# Get assignment counts # Get assignment counts
reservation_data = [] reservation_data = []
for res in reservations: for res in reservations:
assigned_count = db.query(JobReservationUnit).filter_by( assignments = db.query(JobReservationUnit).filter_by(
reservation_id=res.id reservation_id=res.id
).count() ).all()
assigned_count = len(assignments)
# Enrich assignments with unit details
unit_ids = [a.unit_id for a in assignments]
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all() if unit_ids else []
units_by_id = {u.id: u for u in units}
assigned_units = [
{
"id": a.unit_id,
"power_type": a.power_type,
"notes": a.notes,
"deployed": units_by_id[a.unit_id].deployed if a.unit_id in units_by_id else False,
"last_calibrated": units_by_id[a.unit_id].last_calibrated if a.unit_id in units_by_id else None,
}
for a in assignments
]
# Check for calibration conflicts # Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id) conflicts = check_calibration_conflicts(db, res.id)
@@ -534,6 +555,7 @@ async def get_reservations_list(
reservation_data.append({ reservation_data.append({
"reservation": res, "reservation": res,
"assigned_count": assigned_count, "assigned_count": assigned_count,
"assigned_units": assigned_units,
"has_conflicts": len(conflicts) > 0, "has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts) "conflict_count": len(conflicts)
}) })
@@ -549,6 +571,56 @@ async def get_reservations_list(
) )
@router.get("/api/fleet-calendar/planner-availability", response_class=JSONResponse)
async def get_planner_availability(
device_type: str = "seismograph",
start_date: Optional[str] = None,
end_date: Optional[str] = None,
exclude_reservation_id: Optional[str] = None,
db: Session = Depends(get_db)
):
"""Get available units for the reservation planner split-panel UI.
Dates are optional — if omitted, returns all non-retired units regardless of reservations.
"""
if start_date and end_date:
try:
start = date.fromisoformat(start_date)
end = date.fromisoformat(end_date)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD")
units = get_available_units_for_period(db, start, end, device_type, exclude_reservation_id)
else:
# No dates: return all non-retired units of this type
from backend.models import RosterUnit as RU
from datetime import timedelta
all_units = db.query(RU).filter(
RU.device_type == device_type,
RU.retired == False
).all()
units = []
for u in all_units:
expiry = (u.last_calibrated + timedelta(days=365)) if u.last_calibrated else None
units.append({
"id": u.id,
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"expiry_date": expiry.isoformat() if expiry else None,
"calibration_status": "needs_calibration" if not u.last_calibrated else "valid",
"deployed": u.deployed,
"out_for_calibration": u.out_for_calibration or False,
"note": u.note or ""
})
# Sort: benched first (easier to assign), then deployed, then by ID
units.sort(key=lambda u: (1 if u["deployed"] else 0, u["id"]))
return {
"units": units,
"start_date": start_date,
"end_date": end_date,
"count": len(units)
}
@router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse) @router.get("/api/fleet-calendar/available-units", response_class=HTMLResponse)
async def get_available_units_partial( async def get_available_units_partial(
request: Request, request: Request,

View File

@@ -1,133 +0,0 @@
"""
Watcher Manager — admin API for series3-watcher and thor-watcher agents.
Endpoints:
GET /api/admin/watchers — list all watcher agents
GET /api/admin/watchers/{agent_id} — get single agent detail
POST /api/admin/watchers/{agent_id}/trigger-update — flag agent for update
POST /api/admin/watchers/{agent_id}/clear-update — clear update flag
GET /api/admin/watchers/{agent_id}/update-check — polled by watcher on heartbeat
Page:
GET /admin/watchers — HTML admin page
"""
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
from typing import Optional
from backend.database import get_db
from backend.models import WatcherAgent
from backend.templates_config import templates
router = APIRouter(tags=["admin"])
# ── helpers ──────────────────────────────────────────────────────────────────
def _agent_to_dict(agent: WatcherAgent) -> dict:
last_seen = agent.last_seen
if last_seen:
now_utc = datetime.utcnow()
age_minutes = int((now_utc - last_seen).total_seconds() // 60)
if age_minutes > 60:
status = "missing"
else:
status = "ok"
else:
age_minutes = None
status = "missing"
return {
"id": agent.id,
"source_type": agent.source_type,
"version": agent.version,
"last_seen": last_seen.isoformat() if last_seen else None,
"age_minutes": age_minutes,
"status": status,
"ip_address": agent.ip_address,
"log_tail": agent.log_tail,
"update_pending": bool(agent.update_pending),
"update_version": agent.update_version,
}
# ── API routes ────────────────────────────────────────────────────────────────
@router.get("/api/admin/watchers")
def list_watchers(db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
return [_agent_to_dict(a) for a in agents]
@router.get("/api/admin/watchers/{agent_id}")
def get_watcher(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
return _agent_to_dict(agent)
class TriggerUpdateRequest(BaseModel):
version: Optional[str] = None # target version label (informational)
@router.post("/api/admin/watchers/{agent_id}/trigger-update")
def trigger_update(agent_id: str, body: TriggerUpdateRequest, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = True
agent.update_version = body.version
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": True}
@router.post("/api/admin/watchers/{agent_id}/clear-update")
def clear_update(agent_id: str, db: Session = Depends(get_db)):
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
raise HTTPException(status_code=404, detail="Watcher agent not found")
agent.update_pending = False
agent.update_version = None
db.commit()
return {"ok": True, "agent_id": agent_id, "update_pending": False}
@router.get("/api/admin/watchers/{agent_id}/update-check")
def update_check(agent_id: str, db: Session = Depends(get_db)):
"""
Polled by watcher agents on each heartbeat cycle.
Returns update_available=True when an update has been triggered via the UI.
Automatically clears the flag after the watcher acknowledges it.
"""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == agent_id).first()
if not agent:
return {"update_available": False}
pending = bool(agent.update_pending)
if pending:
# Clear the flag — the watcher will now self-update
agent.update_pending = False
db.commit()
return {
"update_available": pending,
"version": agent.update_version,
}
# ── HTML page ─────────────────────────────────────────────────────────────────
@router.get("/admin/watchers", response_class=HTMLResponse)
def admin_watchers_page(request: Request, db: Session = Depends(get_db)):
agents = db.query(WatcherAgent).order_by(WatcherAgent.last_seen.desc()).all()
agents_data = [_agent_to_dict(a) for a in agents]
return templates.TemplateResponse("admin_watchers.html", {
"request": request,
"agents": agents_data,
})

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Optional, List from typing import Optional, List
from backend.database import get_db from backend.database import get_db
from backend.models import Emitter, WatcherAgent from backend.models import Emitter
router = APIRouter() router = APIRouter()
@@ -107,35 +107,6 @@ def get_fleet_status(db: Session = Depends(get_db)):
emitters = db.query(Emitter).all() emitters = db.query(Emitter).all()
return emitters return emitters
# ── Watcher agent upsert helper ───────────────────────────────────────────────
def _upsert_watcher_agent(db: Session, source_id: str, source_type: str,
version: str, ip_address: str, log_tail: str,
status: str) -> None:
"""Create or update the WatcherAgent row for a given source_id."""
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source_id).first()
if agent:
agent.source_type = source_type
agent.version = version
agent.last_seen = datetime.utcnow()
agent.status = status
if ip_address:
agent.ip_address = ip_address
if log_tail is not None:
agent.log_tail = log_tail
else:
agent = WatcherAgent(
id=source_id,
source_type=source_type,
version=version,
last_seen=datetime.utcnow(),
status=status,
ip_address=ip_address,
log_tail=log_tail,
)
db.add(agent)
# series3v1.1 Standardized Heartbeat Schema (multi-unit) # series3v1.1 Standardized Heartbeat Schema (multi-unit)
from fastapi import Request from fastapi import Request
@@ -149,11 +120,6 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source_id") source = payload.get("source_id")
units = payload.get("units", []) units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail") # list of strings or None
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 3 Heartbeat ===") print("\n=== Series 3 Heartbeat ===")
print("Source:", source) print("Source:", source)
@@ -216,27 +182,13 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status}) results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series3_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit() db.commit()
return { return {
"message": "Heartbeat processed", "message": "Heartbeat processed",
"source": source, "source": source,
"units_processed": len(results), "units_processed": len(results),
"results": results, "results": results
"update_available": update_available,
} }
@@ -269,11 +221,6 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source", "series4_emitter") source = payload.get("source", "series4_emitter")
units = payload.get("units", []) units = payload.get("units", [])
version = payload.get("version")
log_tail = payload.get("log_tail")
import json as _json
log_tail_str = _json.dumps(log_tail) if log_tail is not None else None
client_ip = request.client.host if request.client else None
print("\n=== Series 4 Heartbeat ===") print("\n=== Series 4 Heartbeat ===")
print("Source:", source) print("Source:", source)
@@ -329,25 +276,11 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
results.append({"unit": uid, "status": status}) results.append({"unit": uid, "status": status})
if source:
_upsert_watcher_agent(db, source, "series4_watcher", version,
client_ip, log_tail_str, "ok")
db.commit()
# Check if an update has been triggered for this agent
update_available = False
if source:
agent = db.query(WatcherAgent).filter(WatcherAgent.id == source).first()
if agent and agent.update_pending:
update_available = True
agent.update_pending = False
db.commit() db.commit()
return { return {
"message": "Heartbeat processed", "message": "Heartbeat processed",
"source": source, "source": source,
"units_processed": len(results), "units_processed": len(results),
"results": results, "results": results
"update_available": update_available,
} }

View File

@@ -646,22 +646,20 @@ def get_available_units_for_period(
if unit.id in reserved_unit_ids: if unit.id in reserved_unit_ids:
continue continue
# Check calibration through end of period if unit.last_calibrated:
if not unit.last_calibrated:
continue # Needs calibration
expiry_date = unit.last_calibrated + timedelta(days=365) expiry_date = unit.last_calibrated + timedelta(days=365)
if expiry_date <= end_date:
continue # Calibration expires during period
cal_status = get_calibration_status(unit, end_date, warning_days) cal_status = get_calibration_status(unit, end_date, warning_days)
else:
expiry_date = None
cal_status = "needs_calibration"
available_units.append({ available_units.append({
"id": unit.id, "id": unit.id,
"last_calibrated": unit.last_calibrated.isoformat(), "last_calibrated": unit.last_calibrated.isoformat() if unit.last_calibrated else None,
"expiry_date": expiry_date.isoformat(), "expiry_date": expiry_date.isoformat() if expiry_date else None,
"calibration_status": cal_status, "calibration_status": cal_status,
"deployed": unit.deployed, "deployed": unit.deployed,
"out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or "" "note": unit.note or ""
}) })

View File

@@ -60,19 +60,6 @@ def jinja_same_date(dt1, dt2) -> bool:
return False return False
def jinja_log_tail_display(s):
"""Jinja filter: decode a JSON-encoded log tail array into a plain-text string."""
if not s:
return ""
try:
lines = _json.loads(s)
if isinstance(lines, list):
return "\n".join(str(l) for l in lines)
return str(s)
except Exception:
return str(s)
# Register Jinja filters and globals # Register Jinja filters and globals
templates.env.filters["local_datetime"] = jinja_local_datetime templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time templates.env.filters["local_time"] = jinja_local_time
@@ -81,4 +68,3 @@ templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date templates.env.globals["same_date"] = jinja_same_date
templates.env.filters["log_tail_display"] = jinja_log_tail_display

View File

@@ -1,37 +0,0 @@
"""
Migration: add watcher_agents table.
Safe to run multiple times (idempotent).
"""
import sqlite3
import os
DB_PATH = os.path.join(os.path.dirname(__file__), "data", "seismo.db")
def migrate():
con = sqlite3.connect(DB_PATH)
cur = con.cursor()
cur.execute("""
CREATE TABLE IF NOT EXISTS watcher_agents (
id TEXT PRIMARY KEY,
source_type TEXT NOT NULL,
version TEXT,
last_seen DATETIME,
status TEXT NOT NULL DEFAULT 'unknown',
ip_address TEXT,
log_tail TEXT,
update_pending INTEGER NOT NULL DEFAULT 0,
update_version TEXT
)
""")
con.commit()
con.close()
print("Migration complete: watcher_agents table ready.")
if __name__ == "__main__":
migrate()

View File

@@ -1,273 +0,0 @@
{% extends "base.html" %}
{% block title %}Watcher Manager — Admin{% endblock %}
{% block content %}
<div class="mb-6">
<div class="flex items-center gap-3">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Watcher Manager</h1>
<span class="px-2 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300 rounded-full">Admin</span>
</div>
<p class="text-gray-500 dark:text-gray-400 mt-1 text-sm">
Monitor and manage field watcher agents. Data updates on each heartbeat received.
</p>
</div>
<!-- Agent cards -->
<div id="agent-list" class="space-y-4">
{% if not agents %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow p-8 text-center">
<svg class="w-12 h-12 mx-auto text-gray-300 dark:text-gray-600 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
</svg>
<p class="text-gray-500 dark:text-gray-400">No watcher agents have reported in yet.</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Once a watcher sends its first heartbeat it will appear here.</p>
</div>
{% endif %}
{% for agent in agents %}
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden" id="agent-{{ agent.id | replace(' ', '-') }}">
<!-- Card header -->
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-100 dark:border-slate-700">
<div class="flex items-center gap-3">
<!-- Status dot -->
{% if agent.status == 'ok' %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-green-500 flex-shrink-0"></span>
{% elif agent.status == 'pending' %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-yellow-400 flex-shrink-0"></span>
{% elif agent.status in ('missing', 'error') %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-red-500 flex-shrink-0"></span>
{% else %}
<span class="status-dot inline-block w-3 h-3 rounded-full bg-gray-400 flex-shrink-0"></span>
{% endif %}
<div>
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ agent.id }}</h2>
<div class="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mt-0.5">
<span>{{ agent.source_type }}</span>
{% if agent.version %}
<span class="bg-gray-100 dark:bg-slate-700 px-1.5 py-0.5 rounded font-mono">v{{ agent.version }}</span>
{% endif %}
{% if agent.ip_address %}
<span>{{ agent.ip_address }}</span>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Status badge -->
{% if agent.status == 'ok' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300">OK</span>
{% elif agent.status == 'pending' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300">Pending</span>
{% elif agent.status == 'missing' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Missing</span>
{% elif agent.status == 'error' %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300">Error</span>
{% else %}
<span class="status-badge px-2.5 py-1 text-xs font-semibold rounded-full bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400">Unknown</span>
{% endif %}
<!-- Trigger Update button -->
<button
onclick="triggerUpdate('{{ agent.id }}')"
class="px-3 py-1.5 text-xs font-medium bg-seismo-orange hover:bg-orange-600 text-white rounded-lg transition-colors"
id="btn-update-{{ agent.id | replace(' ', '-') }}"
>
Trigger Update
</button>
</div>
</div>
<!-- Meta row -->
<div class="px-6 py-3 bg-gray-50 dark:bg-slate-800 border-b border-gray-100 dark:border-slate-700 flex flex-wrap gap-6 text-sm">
<div>
<span class="text-gray-500 dark:text-gray-400">Last seen</span>
<span class="last-seen-value ml-2 font-medium text-gray-800 dark:text-gray-200">
{% if agent.last_seen %}
{{ agent.last_seen }}
{% if agent.age_minutes is not none %}
<span class="text-gray-400 dark:text-gray-500 font-normal">({{ agent.age_minutes }}m ago)</span>
{% endif %}
{% else %}
Never
{% endif %}
</span>
</div>
<div class="update-pending-indicator flex items-center gap-1.5 text-yellow-600 dark:text-yellow-400 {% if not agent.update_pending %}hidden{% endif %}">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
<span class="text-xs font-semibold">Update pending — will apply on next heartbeat</span>
</div>
</div>
<!-- Log tail -->
{% if agent.log_tail %}
<div class="px-6 py-4">
<div class="flex items-center justify-between mb-2">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide">Log Tail</span>
<div class="flex items-center gap-3">
<button onclick="expandLog('{{ agent.id | replace(' ', '-') }}')" id="expand-{{ agent.id | replace(' ', '-') }}" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
Expand
</button>
<button onclick="toggleLog('{{ agent.id | replace(' ', '-') }}')" class="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-200">
Toggle
</button>
</div>
</div>
<pre id="log-{{ agent.id | replace(' ', '-') }}" class="text-xs font-mono bg-gray-900 text-green-400 rounded-lg p-3 overflow-x-auto max-h-96 overflow-y-auto leading-relaxed hidden">{{ agent.log_tail | log_tail_display }}</pre>
</div>
{% else %}
<div class="px-6 py-4 text-xs text-gray-400 dark:text-gray-500 italic">No log data received yet.</div>
{% endif %}
</div>
{% endfor %}
</div>
<!-- Auto-refresh every 30s -->
<div class="mt-6 text-xs text-gray-400 dark:text-gray-600 text-center">
Auto-refreshes every 30 seconds &mdash; or <a href="/admin/watchers" class="underline hover:text-gray-600 dark:hover:text-gray-400">refresh now</a>
</div>
<script>
function triggerUpdate(agentId) {
if (!confirm('Trigger update for ' + agentId + '?\n\nThe watcher will self-update on its next heartbeat cycle.')) return;
const safeId = agentId.replace(/ /g, '-');
const btn = document.getElementById('btn-update-' + safeId);
btn.disabled = true;
btn.textContent = 'Sending...';
fetch('/api/admin/watchers/' + encodeURIComponent(agentId) + '/trigger-update', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({})
})
.then(r => r.json())
.then(data => {
if (data.ok) {
btn.textContent = 'Update Queued';
btn.classList.remove('bg-seismo-orange', 'hover:bg-orange-600');
btn.classList.add('bg-green-600');
// Show the pending indicator immediately without a reload
const card = document.getElementById('agent-' + safeId);
if (card) {
const indicator = card.querySelector('.update-pending-indicator');
if (indicator) indicator.classList.remove('hidden');
}
} else {
btn.textContent = 'Error';
btn.classList.add('bg-red-600');
btn.disabled = false;
}
})
.catch(() => {
btn.textContent = 'Failed';
btn.classList.add('bg-red-600');
btn.disabled = false;
});
}
function toggleLog(agentId) {
const el = document.getElementById('log-' + agentId);
if (el) el.classList.toggle('hidden');
}
function expandLog(agentId) {
const el = document.getElementById('log-' + agentId);
const btn = document.getElementById('expand-' + agentId);
if (!el) return;
el.classList.remove('hidden');
if (el.classList.contains('max-h-96')) {
el.classList.remove('max-h-96');
el.style.maxHeight = 'none';
if (btn) btn.textContent = 'Collapse';
} else {
el.classList.add('max-h-96');
el.style.maxHeight = '';
if (btn) btn.textContent = 'Expand';
}
}
// Status colors for dot and badge by status value
const STATUS_DOT = {
ok: 'bg-green-500',
pending: 'bg-yellow-400',
missing: 'bg-red-500',
error: 'bg-red-500',
};
const STATUS_BADGE_CLASSES = {
ok: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-300',
missing: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
error: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
};
const STATUS_BADGE_DEFAULT = 'bg-gray-100 text-gray-600 dark:bg-slate-700 dark:text-gray-400';
const DOT_COLORS = ['bg-green-500', 'bg-yellow-400', 'bg-red-500', 'bg-gray-400'];
const BADGE_COLORS = [
'bg-green-100', 'text-green-700', 'dark:bg-green-900', 'dark:text-green-300',
'bg-yellow-100', 'text-yellow-700', 'dark:bg-yellow-900', 'dark:text-yellow-300',
'bg-red-100', 'text-red-700', 'dark:bg-red-900', 'dark:text-red-300',
'bg-gray-100', 'text-gray-600', 'dark:bg-slate-700', 'dark:text-gray-400',
];
function patchAgent(card, agent) {
// Status dot
const dot = card.querySelector('.status-dot');
if (dot) {
dot.classList.remove(...DOT_COLORS);
dot.classList.add(STATUS_DOT[agent.status] || 'bg-gray-400');
}
// Status badge
const badge = card.querySelector('.status-badge');
if (badge) {
badge.classList.remove(...BADGE_COLORS);
const label = agent.status ? agent.status.charAt(0).toUpperCase() + agent.status.slice(1) : 'Unknown';
badge.textContent = label === 'Ok' ? 'OK' : label;
const cls = STATUS_BADGE_CLASSES[agent.status] || STATUS_BADGE_DEFAULT;
badge.classList.add(...cls.split(' '));
}
// Last seen / age
const lastSeen = card.querySelector('.last-seen-value');
if (lastSeen) {
if (agent.last_seen) {
const age = agent.age_minutes != null
? ` <span class="text-gray-400 dark:text-gray-500 font-normal">(${agent.age_minutes}m ago)</span>`
: '';
lastSeen.innerHTML = agent.last_seen + age;
} else {
lastSeen.textContent = 'Never';
}
}
// Update pending indicator
const indicator = card.querySelector('.update-pending-indicator');
if (indicator) {
indicator.classList.toggle('hidden', !agent.update_pending);
}
}
function liveRefresh() {
fetch('/api/admin/watchers')
.then(r => r.json())
.then(agents => {
agents.forEach(agent => {
const safeId = agent.id.replace(/ /g, '-');
const card = document.getElementById('agent-' + safeId);
if (card) patchAgent(card, agent);
});
})
.catch(() => {}); // silently ignore fetch errors
}
setInterval(liveRefresh, 30000);
</script>
{% endblock %}

View File

@@ -85,7 +85,7 @@
<div class="flex h-screen overflow-hidden"> <div class="flex h-screen overflow-hidden">
<!-- Sidebar (Responsive) --> <!-- Sidebar (Responsive) -->
<aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col"> <aside id="sidebar" class="sidebar w-64 bg-white dark:bg-slate-800 shadow-lg flex flex-col{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
<!-- Logo --> <!-- Logo -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700"> <div class="p-6 border-b border-gray-200 dark:border-gray-700">
<a href="/" class="block"> <a href="/" class="block">
@@ -155,7 +155,7 @@
<svg class="w-5 h-5 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg> </svg>
Fleet Calendar Reservation Planner
</a> </a>
<a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}"> <a href="/settings" class="flex items-center px-4 py-3 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 {% if request.url.path == '/settings' %}bg-gray-100 dark:bg-gray-700{% endif %}">
@@ -193,14 +193,14 @@
<!-- Main content --> <!-- Main content -->
<main class="main-content flex-1 overflow-y-auto"> <main class="main-content flex-1 overflow-y-auto">
<div class="p-8"> <div class="{% if request.query_params.get('embed') == '1' %}p-4{% else %}p-8{% endif %}">
{% block content %}{% endblock %} {% block content %}{% endblock %}
</div> </div>
</main> </main>
</div> </div>
<!-- Bottom Navigation (Mobile Only) --> <!-- Bottom Navigation (Mobile Only) -->
<nav class="bottom-nav"> <nav class="bottom-nav{% if request.query_params.get('embed') == '1' %} hidden{% endif %}">
<div class="grid grid-cols-4 h-16"> <div class="grid grid-cols-4 h-16">
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu"> <button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -223,9 +223,22 @@
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1> <h1 class="text-3xl font-bold text-gray-900 dark:text-white">Fleet Calendar</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p> <p class="text-gray-600 dark:text-gray-400 mt-1">Plan unit assignments and track calibrations</p>
</div> </div>
</div>
</div>
<!-- View Tabs -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 mb-6 w-fit">
<button id="tab-btn-planner" onclick="switchTab('planner')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservation Planner
</button>
<button id="tab-btn-calendar" onclick="switchTab('calendar')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Calendar
</button>
</div> </div>
</div>
<div id="view-calendar" class="hidden">
<!-- Summary Stats --> <!-- Summary Stats -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6"> <div class="grid grid-cols-2 md:grid-cols-5 gap-4 mb-6">
@@ -375,6 +388,47 @@
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8"> <div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 mb-8">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2> <h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">Active Reservations</h2>
<div id="reservations-list" <div id="reservations-list"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="calendar-reservations-refresh from:body"
hx-swap="innerHTML">
<p class="text-gray-500">Loading reservations...</p>
</div>
</div>
</div><!-- end #view-calendar -->
<!-- Reservation Planner View -->
<div id="view-planner">
<div class="flex flex-col lg:flex-row gap-6 min-h-[70vh]">
<!-- LEFT PANEL: sub-tabs switch content here only -->
<div class="lg:w-2/5 flex flex-col gap-4">
<!-- Sub-tab bar -->
<div class="flex rounded-lg bg-gray-100 dark:bg-gray-700 p-1 w-fit">
<button id="ptab-btn-list" onclick="switchPlannerTab('list')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors bg-white dark:bg-slate-600 text-gray-900 dark:text-white shadow">
Reservations
</button>
<button id="ptab-btn-assign" onclick="switchPlannerTab('assign')"
class="px-4 py-2 rounded-md text-sm font-medium transition-colors text-gray-600 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white">
Assign Units
</button>
</div>
<!-- Sub-tab: Reservations list -->
<div id="ptab-list" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Project Reservations</h2>
<button onclick="plannerReset(); switchPlannerTab('assign')"
class="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm flex items-center gap-1.5">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"/>
</svg>
New
</button>
</div>
<div id="planner-reservations-list" class="overflow-y-visible"
hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}" hx-get="/api/fleet-calendar/reservations-list?year={{ start_year }}&month={{ start_month }}&device_type={{ device_type }}"
hx-trigger="load" hx-trigger="load"
hx-swap="innerHTML"> hx-swap="innerHTML">
@@ -382,6 +436,187 @@
</div> </div>
</div> </div>
<!-- Sub-tab: Assign Units form -->
<div id="ptab-assign" class="hidden bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4 flex-1">
<div class="flex items-center gap-3">
<button onclick="switchPlannerTab('list')" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" title="Back to reservations">
<svg class="w-5 h-5" 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"/>
</svg>
</button>
<h2 class="text-xl font-semibold text-gray-900 dark:text-white" id="planner-form-title">New Reservation</h2>
</div>
<!-- Metadata fields: only shown when creating a new reservation -->
<div id="planner-meta-fields">
<!-- Name -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Job / Reservation Name *</label>
<input type="text" id="planner-name" placeholder="e.g., Pine Street May Deployment"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<!-- Device Type -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Device Type *</label>
<div class="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
<label class="flex-1 cursor-pointer">
<input type="radio" name="planner_device_type" value="seismograph" checked class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Seismograph
</span>
</label>
<label class="flex-1 cursor-pointer border-l border-gray-300 dark:border-gray-600">
<input type="radio" name="planner_device_type" value="slm" class="sr-only peer" onchange="plannerDatesChanged()">
<span class="block text-center px-4 py-2 text-sm font-medium bg-white dark:bg-slate-700 peer-checked:bg-blue-600 peer-checked:text-white text-gray-700 dark:text-gray-300 transition-colors">
Sound Level Meter
</span>
</label>
</div>
</div>
<!-- Project -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Link to Project (optional)</label>
<select id="planner-project"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<option value="">-- No project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
</div>
<!-- Dates -->
<div class="grid grid-cols-2 gap-3 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date *</label>
<input type="date" id="planner-start"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date *</label>
<input type="date" id="planner-end"
onchange="plannerDatesChanged()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div>
<!-- Color -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Color</label>
<div class="flex gap-2" id="planner-colors">
{% for color in ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899'] %}
<label class="cursor-pointer">
<input type="radio" name="planner_color" value="{{ color }}" {% if loop.first %}checked{% endif %} class="sr-only peer">
<span class="block w-7 h-7 rounded-full peer-checked:ring-2 peer-checked:ring-offset-2 peer-checked:ring-gray-900 dark:peer-checked:ring-white"
style="background-color: {{ color }}"></span>
</label>
{% endfor %}
</div>
</div>
<!-- Estimated Units Needed -->
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Estimated Units Needed</label>
<input type="number" id="planner-est-units" min="1" placeholder="e.g. 5"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
</div>
</div><!-- end #planner-meta-fields -->
<!-- Monitoring Locations -->
<div class="flex items-center justify-between mt-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Monitoring Locations</h3>
<button onclick="plannerAddSlot()"
class="text-xs px-2 py-1 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded text-gray-700 dark:text-gray-300">
+ Add Location
</button>
</div>
<div id="planner-slots" class="flex flex-col gap-2 overflow-y-auto max-h-72">
<!-- Locations rendered by JS -->
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-4" id="planner-slots-empty">
Set dates and click "+ Add Location" to start adding units
</p>
</div>
<!-- Notes -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Notes (optional)</label>
<textarea id="planner-notes" rows="2" placeholder="Optional notes"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<!-- Save -->
<div class="flex gap-3 pt-2 border-t border-gray-200 dark:border-gray-700 mt-auto">
<button onclick="plannerReset()"
class="px-4 py-2 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg text-sm">
Clear
</button>
<button onclick="plannerSave()"
class="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium" id="planner-save-btn">
Save Reservation
</button>
</div>
</div><!-- end ptab-assign -->
</div><!-- end left panel -->
<!-- RIGHT: Available Units (always visible) -->
<div class="lg:w-3/5 bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6 flex flex-col gap-4">
<div class="flex items-center justify-between flex-wrap gap-2">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Available Units
<span id="planner-avail-count" class="ml-2 text-sm font-normal text-gray-500 dark:text-gray-400"></span>
</h2>
<div class="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-deployed-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Deployed only
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" id="planner-benched-only" onchange="plannerFilterUnits()"
class="rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500">
Benched only
</label>
</div>
</div>
<input type="text" id="planner-search" placeholder="Search by unit ID..."
oninput="plannerFilterUnits()"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500">
<div id="planner-units-list" class="flex flex-col gap-1 overflow-y-auto flex-1" style="max-height: 55vh;">
<p class="text-sm text-gray-400 dark:text-gray-500 text-center py-8" id="planner-units-placeholder">
Set start and end dates to see available units
</p>
</div>
</div><!-- end right panel -->
</div><!-- end flex row -->
</div><!-- end view-planner -->
<!-- Unit Detail Modal (planner) -->
<div id="unit-detail-modal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="closeUnitDetailModal()"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col" onclick="event.stopPropagation()">
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="font-semibold text-gray-900 dark:text-white" id="unit-detail-modal-title">Unit Detail</h3>
<button onclick="closeUnitDetailModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<iframe id="unit-detail-iframe" src="" class="flex-1 rounded-b-xl" style="min-height: 70vh; border: none;"></iframe>
</div>
</div>
</div>
<!-- Day Detail Slide Panel --> <!-- Day Detail Slide Panel -->
<div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div> <div id="panel-backdrop" class="panel-backdrop" onclick="closeDayPanel()"></div>
<div id="day-panel" class="slide-panel"> <div id="day-panel" class="slide-panel">
@@ -616,6 +851,38 @@ function openReservationModal() {
updateCalendarAvailability(); updateCalendarAvailability();
} }
function toggleResCard(id) {
const detail = document.getElementById('res-detail-' + id);
const chevron = document.getElementById('chevron-' + id);
if (!detail) return;
const isHidden = detail.classList.contains('hidden');
if (isHidden) {
detail.classList.remove('hidden');
detail.style.display = 'block';
} else {
detail.classList.add('hidden');
detail.style.display = 'none';
}
if (chevron) chevron.style.transform = isHidden ? 'rotate(180deg)' : '';
}
async function deleteReservation(id, name) {
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) return;
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, { method: 'DELETE' });
if (response.ok) {
htmx.trigger('#planner-reservations-list', 'load');
} else {
const data = await response.json();
alert('Error: ' + (data.detail || 'Failed to delete'));
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting reservation');
}
}
async function editReservation(id) { async function editReservation(id) {
try { try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`); const response = await fetch(`/api/fleet-calendar/reservations/${id}`);
@@ -869,5 +1136,446 @@ document.addEventListener('keydown', function(e) {
closeReservationModal(); closeReservationModal();
} }
}); });
// ============================================================
// Tab + sub-tab switching
// ============================================================
function switchPlannerTab(tab) {
const isAssign = tab === 'assign';
document.getElementById('ptab-list').classList.toggle('hidden', isAssign);
document.getElementById('ptab-assign').classList.toggle('hidden', !isAssign);
['list', 'assign'].forEach(t => {
const btn = document.getElementById(`ptab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
function switchTab(tab) {
document.getElementById('view-calendar').classList.toggle('hidden', tab !== 'calendar');
document.getElementById('view-planner').classList.toggle('hidden', tab !== 'planner');
if (tab === 'calendar') htmx.trigger(document.body, 'calendar-reservations-refresh');
['calendar', 'planner'].forEach(t => {
const btn = document.getElementById(`tab-btn-${t}`);
if (t === tab) {
btn.classList.add('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.remove('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
} else {
btn.classList.remove('bg-white', 'dark:bg-slate-600', 'text-gray-900', 'dark:text-white', 'shadow');
btn.classList.add('text-gray-600', 'dark:text-gray-300', 'hover:text-gray-900', 'dark:hover:text-white');
}
});
}
// ============================================================
// Reservation Planner
// ============================================================
let plannerState = {
reservation_id: null, // null = creating new
slots: [], // array of {unit_id: string|null, power_type: string|null, notes: string|null}
allUnits: [] // full list from server
};
let dragSrcIdx = null;
function plannerDatesChanged() {
plannerLoadUnits();
}
async function plannerLoadUnits() {
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const excludeId = plannerState.reservation_id || '';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
let url = `/api/fleet-calendar/planner-availability?device_type=${plannerDeviceType}`;
if (start && end && end >= start) {
url += `&start_date=${start}&end_date=${end}`;
}
if (excludeId) url += `&exclude_reservation_id=${excludeId}`;
try {
const resp = await fetch(url);
const data = await resp.json();
plannerState.allUnits = data.units || [];
const hasDates = start && end;
document.getElementById('planner-avail-count').textContent =
hasDates ? `(${plannerState.allUnits.length} available for period)` : `(${plannerState.allUnits.length} total)`;
plannerRenderUnits();
} catch (e) {
console.error('Planner load error', e);
}
}
function plannerFilterUnits() {
// Mutually exclusive checkboxes
const deployedOnly = document.getElementById('planner-deployed-only');
const benchedOnly = document.getElementById('planner-benched-only');
if (event && event.target === deployedOnly && deployedOnly.checked) benchedOnly.checked = false;
if (event && event.target === benchedOnly && benchedOnly.checked) deployedOnly.checked = false;
plannerRenderUnits();
}
function plannerRenderUnits() {
const search = document.getElementById('planner-search').value.toLowerCase();
const deployedOnly = document.getElementById('planner-deployed-only').checked;
const benchedOnly = document.getElementById('planner-benched-only').checked;
const slottedIds = new Set(plannerState.slots.map(s => s.unit_id).filter(Boolean));
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
let units = plannerState.allUnits.filter(u => {
if (deployedOnly && !u.deployed) return false;
if (benchedOnly && u.deployed) return false;
if (search && !u.id.toLowerCase().includes(search)) return false;
return true;
});
const placeholder = document.getElementById('planner-units-placeholder');
const list = document.getElementById('planner-units-list');
if (plannerState.allUnits.length === 0) {
placeholder.classList.remove('hidden');
placeholder.textContent = 'Loading units...';
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
return;
}
placeholder.classList.add('hidden');
list.querySelectorAll('.planner-unit-row').forEach(el => el.remove());
if (units.length === 0) {
const empty = document.createElement('p');
empty.className = 'planner-unit-row text-sm text-gray-400 dark:text-gray-500 text-center py-8';
empty.textContent = 'No units match your filter';
list.appendChild(empty);
return;
}
for (const unit of units) {
const isSlotted = slottedIds.has(unit.id);
const row = document.createElement('div');
row.className = `planner-unit-row flex items-center justify-between px-3 py-2 rounded-lg border transition-colors ${
isSlotted
? 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 opacity-60 cursor-default'
: 'border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 cursor-pointer'
}`;
row.dataset.unitId = unit.id;
if (!isSlotted) row.onclick = () => plannerAssignUnit(unit.id);
const calDate = unit.last_calibrated
? new Date(unit.last_calibrated + 'T00:00:00').toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'})
: 'No cal date';
// Calibration expiry warning during deployment
let expiryWarning = '';
if (start && end && unit.expiry_date) {
const expiry = new Date(unit.expiry_date + 'T00:00:00');
const jobStart = new Date(start + 'T00:00:00');
const jobEnd = new Date(end + 'T00:00:00');
if (expiry >= jobStart && expiry <= jobEnd) {
const expiryStr = expiry.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
expiryWarning = `<span class="text-xs px-1.5 py-0.5 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400 rounded border border-amber-200 dark:border-amber-800" title="Will need swap during job">cal expires ${expiryStr}</span>`;
}
}
const deployedBadge = unit.deployed
? '<span class="text-xs px-1.5 py-0.5 bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400 rounded">Deployed</span>'
: '<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 rounded">Benched</span>';
row.innerHTML = `
<div class="flex flex-col gap-0.5 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<button onclick="event.stopPropagation(); openUnitDetailModal('${unit.id}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-sm">${unit.id}</button>
${deployedBadge}
${expiryWarning}
</div>
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: ${calDate}</span>
</div>
<div class="flex-shrink-0 ml-2">
${isSlotted
? '<span class="text-xs text-blue-600 dark:text-blue-400 font-medium">Assigned</span>'
: '<button class="text-xs px-2 py-1 bg-blue-600 text-white rounded hover:bg-blue-700 whitespace-nowrap">Assign →</button>'
}
</div>
`;
list.appendChild(row);
}
}
function openUnitDetailModal(unitId) {
document.getElementById('unit-detail-modal-title').textContent = unitId;
document.getElementById('unit-detail-iframe').src = `/unit/${unitId}?embed=1`;
document.getElementById('unit-detail-modal').classList.remove('hidden');
}
function closeUnitDetailModal() {
document.getElementById('unit-detail-modal').classList.add('hidden');
document.getElementById('unit-detail-iframe').src = '';
}
function plannerAddSlot() {
plannerState.slots.push({ unit_id: null, power_type: null, notes: null });
plannerRenderSlots();
}
function plannerAssignUnit(unitId) {
const emptyIdx = plannerState.slots.findIndex(s => !s.unit_id);
if (emptyIdx >= 0) {
plannerState.slots[emptyIdx].unit_id = unitId;
} else {
plannerState.slots.push({ unit_id: unitId, power_type: null, notes: null });
}
plannerRenderSlots();
plannerRenderUnits();
}
function plannerRemoveSlot(idx) {
plannerState.slots.splice(idx, 1);
plannerRenderSlots();
plannerRenderUnits();
}
function plannerSetPowerType(idx, value) {
plannerState.slots[idx].power_type = value || null;
}
function plannerSetSlotNotes(idx, value) {
plannerState.slots[idx].notes = value || null;
}
function plannerRenderSlots() {
const container = document.getElementById('planner-slots');
const emptyMsg = document.getElementById('planner-slots-empty');
container.querySelectorAll('.planner-slot-row').forEach(el => el.remove());
if (plannerState.slots.length === 0) {
emptyMsg.classList.remove('hidden');
return;
}
emptyMsg.classList.add('hidden');
plannerState.slots.forEach((slot, idx) => {
const row = document.createElement('div');
row.className = 'planner-slot-row flex flex-col gap-1.5 px-3 py-2 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-slate-700/50';
row.dataset.idx = idx;
row.draggable = !!slot.unit_id;
// Drag events
if (slot.unit_id) {
row.addEventListener('dragstart', e => {
dragSrcIdx = idx;
e.dataTransfer.effectAllowed = 'move';
row.classList.add('opacity-50');
});
row.addEventListener('dragend', () => row.classList.remove('opacity-50'));
}
row.addEventListener('dragover', e => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
container.querySelectorAll('.planner-slot-row').forEach(r => r.classList.remove('ring-2', 'ring-blue-400'));
row.classList.add('ring-2', 'ring-blue-400');
});
row.addEventListener('dragleave', () => row.classList.remove('ring-2', 'ring-blue-400'));
row.addEventListener('drop', e => {
e.preventDefault();
row.classList.remove('ring-2', 'ring-blue-400');
if (dragSrcIdx === null || dragSrcIdx === idx) return;
// Swap unit_id and power_type only (keep location notes in place)
const srcSlot = plannerState.slots[dragSrcIdx];
const dstSlot = plannerState.slots[idx];
[srcSlot.unit_id, dstSlot.unit_id] = [dstSlot.unit_id, srcSlot.unit_id];
[srcSlot.power_type, dstSlot.power_type] = [dstSlot.power_type, srcSlot.power_type];
dragSrcIdx = null;
plannerRenderSlots();
plannerRenderUnits();
});
const powerSelect = `
<select onchange="plannerSetPowerType(${idx}, this.value)"
class="text-xs px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-700 dark:text-gray-300 focus:ring-1 focus:ring-blue-500">
<option value="" ${!slot.power_type ? 'selected' : ''}>— power —</option>
<option value="ac" ${slot.power_type === 'ac' ? 'selected' : ''}>A/C Power</option>
<option value="solar" ${slot.power_type === 'solar' ? 'selected' : ''}>Solar</option>
</select>`;
const dragHandle = slot.unit_id
? `<span class="text-gray-300 dark:text-gray-600 cursor-grab active:cursor-grabbing select-none" title="Drag to reorder">⠿</span>`
: `<span class="w-4"></span>`;
row.innerHTML = `
<div class="flex items-center gap-2">
${dragHandle}
<span class="text-sm text-gray-500 dark:text-gray-400 w-16 flex-shrink-0">Loc. ${idx + 1}</span>
${slot.unit_id
? `<span class="flex-1 font-medium text-gray-900 dark:text-white">${slot.unit_id}</span>
${powerSelect}
<button onclick="plannerClearSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove unit">✕</button>`
: `<span class="flex-1 text-sm text-gray-400 dark:text-gray-500 italic">Empty — click a unit</span>
${powerSelect}
<button onclick="plannerRemoveSlot(${idx})" class="text-xs text-gray-400 hover:text-red-500 flex-shrink-0" title="Remove location">✕</button>`
}
</div>
<div class="pl-8">
<input type="text" value="${slot.notes ? slot.notes.replace(/"/g, '&quot;') : ''}"
oninput="plannerSetSlotNotes(${idx}, this.value)"
placeholder="Location notes (optional)"
class="w-full text-xs px-2 py-1 border border-gray-200 dark:border-gray-600 rounded bg-white dark:bg-slate-700 text-gray-600 dark:text-gray-400 placeholder-gray-300 dark:placeholder-gray-600 focus:ring-1 focus:ring-blue-500">
</div>
`;
container.appendChild(row);
});
}
function plannerClearSlot(idx) {
plannerState.slots[idx].unit_id = null;
plannerState.slots[idx].power_type = null;
plannerRenderSlots();
plannerRenderUnits();
}
function plannerReset() {
plannerState = { reservation_id: null, slots: [], allUnits: [] };
document.getElementById('planner-name').value = '';
document.getElementById('planner-project').value = '';
document.getElementById('planner-start').value = '';
document.getElementById('planner-end').value = '';
document.getElementById('planner-notes').value = '';
document.getElementById('planner-est-units').value = '';
document.getElementById('planner-search').value = '';
const defaultDt = document.querySelector('input[name="planner_device_type"][value="seismograph"]');
if (defaultDt) defaultDt.checked = true;
document.getElementById('planner-deployed-only').checked = false;
document.getElementById('planner-avail-count').textContent = '';
document.querySelector('input[name="planner_color"][value="#3B82F6"]').checked = true;
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = 'New Reservation';
document.getElementById('planner-save-btn').textContent = 'Save Reservation';
document.getElementById('planner-meta-fields').style.display = '';
plannerRenderSlots();
plannerRenderUnits();
}
async function plannerSave() {
const name = document.getElementById('planner-name').value.trim();
const start = document.getElementById('planner-start').value;
const end = document.getElementById('planner-end').value;
const projectId = document.getElementById('planner-project').value;
const notes = document.getElementById('planner-notes').value.trim();
const color = document.querySelector('input[name="planner_color"]:checked')?.value || '#3B82F6';
const estUnits = parseInt(document.getElementById('planner-est-units').value) || null;
const filledSlots = plannerState.slots.filter(s => s.unit_id);
if (!name) { alert('Please enter a reservation name.'); return; }
if (!start || !end) { alert('Please set start and end dates.'); return; }
if (end < start) { alert('End date must be after start date.'); return; }
const btn = document.getElementById('planner-save-btn');
btn.disabled = true;
btn.textContent = 'Saving...';
try {
const isEdit = !!plannerState.reservation_id;
const url = isEdit
? `/api/fleet-calendar/reservations/${plannerState.reservation_id}`
: '/api/fleet-calendar/reservations';
const method = isEdit ? 'PUT' : 'POST';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph';
const payload = {
name, start_date: start, end_date: end,
project_id: projectId || null,
assignment_type: 'specific',
device_type: plannerDeviceType,
color, notes: notes || null,
quantity_needed: estUnits
};
const resp = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await resp.json();
if (!result.success) throw new Error(result.detail || 'Save failed');
const reservationId = isEdit ? plannerState.reservation_id : result.reservation_id;
// Always call assign-units (even with empty list) — endpoint does a full replace
const unitIds = filledSlots.map(s => s.unit_id);
const powerTypes = {};
const locationNotes = {};
filledSlots.forEach(s => {
if (s.power_type) powerTypes[s.unit_id] = s.power_type;
if (s.notes) locationNotes[s.unit_id] = s.notes;
});
const assignResp = await fetch(
`/api/fleet-calendar/reservations/${reservationId}/assign-units`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ unit_ids: unitIds, power_types: powerTypes, location_notes: locationNotes })
}
);
const assignResult = await assignResp.json();
if (assignResult.conflicts && assignResult.conflicts.length > 0) {
const conflictIds = assignResult.conflicts.map(c => c.unit_id).join(', ');
alert(`Saved! Note: ${assignResult.conflicts.length} unit(s) had conflicts and were not assigned: ${conflictIds}`);
}
plannerReset();
switchPlannerTab('list');
// Reload the reservations list partial
htmx.trigger('#planner-reservations-list', 'load');
} catch (e) {
console.error('Planner save error', e);
alert('Error saving reservation: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = plannerState.reservation_id ? 'Save Changes' : 'Save Reservation';
}
}
async function openPlanner(reservationId) {
plannerReset();
if (reservationId) {
try {
const resp = await fetch(`/api/fleet-calendar/reservations/${reservationId}`);
const res = await resp.json();
plannerState.reservation_id = reservationId;
document.getElementById('planner-name').value = res.name;
document.getElementById('planner-project').value = res.project_id || '';
document.getElementById('planner-start').value = res.start_date;
document.getElementById('planner-end').value = res.end_date || '';
document.getElementById('planner-notes').value = res.notes || '';
document.getElementById('planner-est-units').value = res.quantity_needed || '';
const colorRadio = document.querySelector(`input[name="planner_color"][value="${res.color}"]`);
if (colorRadio) colorRadio.checked = true;
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
if (dtRadio) dtRadio.checked = true;
// Pre-fill slots from existing assigned units
for (const u of (res.assigned_units || [])) {
plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null });
}
const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = res.name;
document.getElementById('planner-save-btn').textContent = 'Save Changes';
document.getElementById('planner-meta-fields').style.display = 'none';
plannerRenderSlots();
if (res.start_date && res.end_date) plannerLoadUnits();
} catch (e) {
console.error('Error loading reservation for planner', e);
}
}
switchTab('planner');
switchPlannerTab('assign');
}
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -1,22 +1,36 @@
<!-- Reservations List --> <!-- Reservations List -->
{% if reservations %} {% if reservations %}
<div class="space-y-3"> <div class="space-y-2">
{% for item in reservations %} {% for item in reservations %}
{% set res = item.reservation %} {% set res = item.reservation %}
<div class="flex items-center justify-between p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors" {% set card_id = "res-card-" ~ res.id %}
{% set detail_id = "res-detail-" ~ res.id %}
<div class="rounded-lg border border-gray-200 dark:border-gray-700"
style="border-left: 4px solid {{ res.color }};"> style="border-left: 4px solid {{ res.color }};">
<div class="flex-1">
<div class="flex items-center gap-2"> <!-- Header row (always visible, clickable) -->
<div class="res-card-header flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors select-none"
data-res-id="{{ res.id }}"
onclick="toggleResCard('{{ res.id }}')">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3> <h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
{% if res.device_type == 'slm' %}
<span class="px-2 py-0.5 text-xs font-medium bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-400 rounded">SLM</span>
{% else %}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400 rounded">Seismograph</span>
{% endif %}
{% if item.has_conflicts %} {% if item.has_conflicts %}
<span class="px-2 py-0.5 text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400 rounded-full" <span class="px-2 py-0.5 text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 rounded"
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job"> title="{{ item.conflict_count }} unit(s) will need a calibration swap during this job">
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }} {{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
</span> </span>
{% endif %} {% endif %}
</div> </div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{{ res.start_date.strftime('%b %d, %Y') }} - {{ res.start_date.strftime('%b %d, %Y') }}
{% if res.end_date %} {% if res.end_date %}
{{ res.end_date.strftime('%b %d, %Y') }} {{ res.end_date.strftime('%b %d, %Y') }}
{% elif res.end_date_tbd %} {% elif res.end_date_tbd %}
@@ -28,73 +42,123 @@
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span> <span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
{% endif %} {% endif %}
</p> </p>
{% if res.notes %}
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
{% endif %}
</div> </div>
<div class="text-right ml-4">
<p class="text-lg font-bold text-gray-900 dark:text-white"> <!-- Unit count -->
{% if res.assignment_type == 'quantity' %} <div class="text-right mx-4 flex-shrink-0">
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }} <p class="text-base font-bold text-gray-900 dark:text-white">
{% if res.quantity_needed %}
{{ item.assigned_count }}/{{ res.quantity_needed }}
{% else %} {% else %}
{{ item.assigned_count }} {{ item.assigned_count }}
{% endif %} {% endif %}
</p> </p>
<p class="text-xs text-gray-500 dark:text-gray-400"> <p class="text-xs text-gray-500 dark:text-gray-400">
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }} {{ 'assigned' if item.assigned_count != 1 else 'assigned' }}
{% if res.quantity_needed %} needed{% endif %}
</p> </p>
</div> </div>
<div class="ml-4 flex items-center gap-2">
<button onclick="editReservation('{{ res.id }}')" <!-- Action buttons (stop propagation so clicks don't toggle card) -->
<div class="flex items-center gap-1 flex-shrink-0">
<button onclick="event.stopPropagation(); openPlanner('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-green-600 dark:hover:text-green-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Plan units">
<svg class="w-4 h-4" 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 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
</button>
<button onclick="event.stopPropagation(); editReservation('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" class="p-2 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Edit reservation"> title="Edit">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg> </svg>
</button> </button>
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}')" <button onclick="event.stopPropagation(); deleteReservation('{{ res.id }}', '{{ res.name }}')"
class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700" class="p-2 text-gray-400 hover:text-red-600 dark:hover:text-red-400 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="Delete reservation"> title="Delete">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg> </svg>
</button> </button>
<!-- Chevron (not in stopPropagation zone so clicking it still toggles the card) -->
<svg id="chevron-{{ res.id }}" class="w-4 h-4 text-gray-400 transition-transform duration-200 ml-1 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div> </div>
</div> </div>
<!-- Expandable detail panel -->
<div id="{{ detail_id }}" class="hidden border-t border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-slate-800/60 px-4 py-3">
{% if res.notes %}
<p class="text-sm text-gray-500 dark:text-gray-400 mb-3 italic">{{ res.notes }}</p>
{% endif %}
<div class="grid grid-cols-2 gap-x-6 gap-y-1 text-sm mb-3">
{% if res.quantity_needed %}
<div class="text-gray-500 dark:text-gray-400">Est. units needed</div>
<div class="font-medium text-gray-800 dark:text-gray-200">{{ res.quantity_needed }}</div>
{% endif %}
<div class="text-gray-500 dark:text-gray-400">Assigned</div>
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} unit{{ 's' if item.assigned_count != 1 else '' }}</div>
{% if res.quantity_needed and item.assigned_count < res.quantity_needed %}
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
<div class="font-medium text-amber-600 dark:text-amber-400">{{ res.quantity_needed - item.assigned_count }} more</div>
{% endif %}
{% if item.has_conflicts %}
<div class="text-gray-500 dark:text-gray-400">Cal swaps</div>
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.conflict_count }} unit{{ 's' if item.conflict_count != 1 else '' }} will need swapping during job</div>
{% endif %}
</div>
{% if item.assigned_units %}
<p class="text-xs font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500 mb-2">Monitoring Locations</p>
<div class="flex flex-col gap-1">
{% for u in item.assigned_units %}
<div class="rounded bg-white dark:bg-slate-700 border border-gray-100 dark:border-gray-600 text-sm">
<div class="flex items-center gap-3 px-3 py-1.5">
<span class="text-gray-400 dark:text-gray-500 text-xs w-12 flex-shrink-0">Loc. {{ loop.index }}</span>
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline">{{ u.id }}</button>
<span class="flex-1"></span>
{% if u.power_type == 'ac' %}
<span class="text-xs px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400 rounded">A/C</span>
{% elif u.power_type == 'solar' %}
<span class="text-xs px-1.5 py-0.5 bg-yellow-50 dark:bg-yellow-900/20 text-yellow-600 dark:text-yellow-400 rounded">Solar</span>
{% endif %}
{% if u.deployed %}
<span class="text-xs px-1.5 py-0.5 bg-green-50 dark:bg-green-900/20 text-green-600 dark:text-green-400 rounded">Deployed</span>
{% else %}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-600 text-gray-500 dark:text-gray-400 rounded">Benched</span>
{% endif %}
{% if u.last_calibrated %}
<span class="text-xs text-gray-400 dark:text-gray-500">Cal: {{ u.last_calibrated.strftime('%b %d, %Y') }}</span>
{% endif %}
</div>
{% if u.notes %}
<p class="px-3 pb-1.5 text-xs text-gray-400 dark:text-gray-500 italic">{{ u.notes }}</p>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-gray-400 dark:text-gray-500 italic">No units assigned yet. Click the clipboard icon to plan.</p>
{% endif %}
</div>
</div>
{% endfor %} {% endfor %}
</div> </div>
<script> <!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
async function deleteReservation(id, name) {
if (!confirm(`Delete reservation "${name}"?\n\nThis will remove all unit assignments.`)) {
return;
}
try {
const response = await fetch(`/api/fleet-calendar/reservations/${id}`, {
method: 'DELETE'
});
if (response.ok) {
window.location.reload();
} else {
const data = await response.json();
alert('Error: ' + (data.detail || 'Failed to delete'));
}
} catch (error) {
console.error('Error:', error);
alert('Error deleting reservation');
}
}
// editReservation is defined in fleet_calendar.html
</script>
{% else %} {% else %}
<div class="text-center py-8"> <div class="text-center py-8">
<svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-12 h-12 mx-auto text-gray-400 dark:text-gray-500 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg> </svg>
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</p> <p class="text-gray-500 dark:text-gray-400">No reservations found</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p> <p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Reservation" to plan unit assignments</p>
</div> </div>
{% endif %} {% endif %}

View File

@@ -41,12 +41,6 @@
</svg> </svg>
Danger Zone Danger Zone
</button> </button>
<button class="settings-tab text-gray-400 dark:text-gray-500" data-tab="developer" onclick="showTab('developer')">
<svg class="w-4 h-4 inline-block mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
Developer
</button>
</div> </div>
<!-- General Tab --> <!-- General Tab -->
@@ -520,32 +514,6 @@
</div> </div>
</div> </div>
<!-- Developer Tab -->
<div id="developer-tab" class="tab-content hidden">
<div class="space-y-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-1">Developer Tools</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-6">Admin-only tools for managing field watcher agents and diagnosing connectivity.</p>
<div class="space-y-4">
<!-- Watcher Manager -->
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-slate-700 rounded-lg">
<div>
<div class="font-medium text-gray-900 dark:text-white">Watcher Manager</div>
<div class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Monitor series3-watcher and thor-watcher agents. View status, log tails, and push remote updates.
</div>
</div>
<a href="/admin/watchers"
class="ml-6 px-4 py-2 bg-seismo-orange hover:bg-orange-600 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap">
Open
</a>
</div>
</div>
</div>
</div>
</div>
<style> <style>
.settings-tab { .settings-tab {
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;