Compare commits
2 Commits
v0.8.0
...
0e3f512203
| Author | SHA1 | Date | |
|---|---|---|---|
| 0e3f512203 | |||
| e4d1f0d684 |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
@@ -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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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()
|
|
||||||
@@ -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 — 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 %}
|
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -223,10 +223,23 @@
|
|||||||
<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>
|
||||||
</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 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">
|
||||||
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
|
<div class="bg-white dark:bg-slate-800 rounded-lg p-4 shadow">
|
||||||
@@ -375,11 +388,233 @@
|
|||||||
<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">
|
||||||
<p class="text-gray-500">Loading reservations...</p>
|
<p class="text-gray-500">Loading reservations...</p>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Day Detail Slide Panel -->
|
<!-- Day Detail 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, '"') : ''}"
|
||||||
|
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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user