26 Commits

Author SHA1 Message Date
57a85f565b feat: add location_slots to job_reservations for full slot persistence and update version to 0.9.1
Fix: modems do not show as "missing" any more, cleans up the dashboard.
2026-03-24 01:13:29 +00:00
serversdwn
e6555ba924 feat: Update series4 heartbeat to accept new source_id field with fallback to legacy source 2026-03-20 17:13:21 -04:00
8694282dd0 Update version to 0.9.0 with Job Planner 2026-03-20 04:48:22 +00:00
bc02dc9564 feat: Enhance project and reservation management
- Updated reservation list to display estimated units and improved count display.
- Added "Upcoming" status to project dashboard and header with corresponding styles.
- Implemented a dropdown for quick status updates in project header.
- Modified project list compact view to reflect new status labels.
- Updated project overview to include a tab for upcoming projects.
- Added migration script to introduce estimated_units column in job_reservations table.
2026-03-19 22:52:35 +00:00
0d01715f81 'add reservation mechanic to dev' (#35) from reservation into dev
Reviewed-on: #35
2026-03-18 18:19:32 -04:00
b3ec249c5e feat: add location names to reservation slots and promote-to-project
- Each monitoring location slot can now have a named location (e.g. "North Gate")
- Location names and slot order are persisted and restored in the planner
- Location names display in the expanded reservation card view
- Added "Promote to Project" button that converts a reservation into a
  tracked project with monitoring locations and unit assignments pre-filled

Requires DB migration on prod:
  ALTER TABLE job_reservation_units ADD COLUMN location_name TEXT;
  ALTER TABLE job_reservation_units ADD COLUMN slot_index INTEGER;
2026-03-18 22:15:46 +00:00
b6e74258f1 Merge dev into reservation branch to pick up v0.8.0 watcher manager changes 2026-03-18 20:13:19 +00:00
1a87ff13c9 doc: update docs for 0.8.0 2026-03-18 19:59:34 +00:00
22c62c0729 fix: watcher manager now displays "heard from" status, instead of unit status. Refreshes ever 60 mins, if havent heard from in >60 mins display missing. 2026-03-18 19:45:39 +00:00
0f47b69c92 Merge pull request 'merge watcher into dev' (#33) from watcher into dev
Reviewed-on: #33
2026-03-18 14:54:33 -04:00
76667454b3 feat: enhance agent status display with new classes and live refresh functionality 2026-03-18 18:51:54 +00:00
0e3f512203 Feat: expands project reservation system.
-Reservation list view
-expandable project cards
2026-03-15 05:25:23 +00:00
serversdwn
15d962ba42 feat: watcher agent management system implemented. 2026-03-13 17:38:43 -04:00
e4d1f0d684 feat: start build of listed reservation system 2026-03-13 21:37:06 +00:00
serversdwn
b571dc29bc chore: make rebuild script executable 2026-03-13 17:34:32 +00:00
serversdwn
e2c841d5d7 doc: update readme v0.7.1 2026-03-12 22:41:47 +00:00
cc94493331 Merge pull request 'merge v0.7.1 dev into main.' (#31) from dev into main
Reviewed-on: #31
2026-03-12 18:40:15 -04:00
serversdwn
5a5426cceb v0.7.1 - Add out for call status, starting to work on reservation mode, fixed a big brain fart too. 2026-03-12 22:38:22 +00:00
serversdwn
66eddd6fe2 chore: db cleanups and migration script fixes. 2026-03-12 22:09:57 +00:00
serversdwn
c77794787c remove override 2026-03-12 21:41:30 +00:00
61c84bc71d fix: merge conflict fixed 2026-03-12 21:37:09 +00:00
fbf7f2a65d chore: clean up .gitignore 2026-03-12 21:22:51 +00:00
serversdwn
202fcaf91c Merge branch 'dev' of ssh://10.0.0.2:2222/serversdown/terra-view into dev 2026-03-12 20:25:02 +00:00
serversdwn
3a411d0a89 chore: docker and git stuff 2026-03-12 20:24:01 +00:00
0c2186f5d8 feat: reservation modal now usable. 2026-03-12 20:10:42 +00:00
c138e8c6a0 feat: add new "out for cal" status for units currently being calibrated.
-retire unit button changed to be more dramatic... lol
2026-03-12 17:59:42 +00:00
39 changed files with 2987 additions and 251 deletions

14
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# Terra-View Specifics
# Dev build counter (local only, never commit)
build_number.txt
docker-compose.override.yml
# SQLite database files
*.db
@@ -9,7 +10,7 @@ data/
data-dev/
.aider*
.aider*
docker-compose.override.yml
# Byte-compiled / optimized / DLL files
__pycache__/
@@ -219,3 +220,14 @@ marimo/_static/
marimo/_lsp/
__marimo__/
<<<<<<< HEAD
# Seismo Fleet Manager
# SQLite database files
*.db
*.db-journal
/data/
/data-dev/
.aider*
.aider*
=======
>>>>>>> 0c2186f5d89d948b0357d674c0773a67a67d8027

View File

@@ -5,6 +5,101 @@ 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/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.9.1] - 2026-03-23
### Fixed
- **Location slots not persisting**: Empty monitoring location slots (no unit assigned yet) were lost on save/reload. Added `location_slots` JSON column to `job_reservations` to store the full slot list including empty slots.
- **Modems in Recent Alerts**: Modems no longer appear in the dashboard Recent Alerts panel — alerts are for seismographs and SLMs only. Modem status is still tracked internally via paired device inheritance.
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 backend/migrate_add_location_slots.py
```
---
## [0.9.0] - 2026-03-19
### Added
- **Job Planner**: Full redesign of the Fleet Calendar into a two-tab Job Planner / Calendar interface
- **Planner tab**: Create and manage job reservations with name, device type, dates, color, estimated units, and monitoring locations
- **Calendar tab**: 12-month rolling heatmap with colored job bars per day; confirmed jobs solid, planned jobs dashed
- **Monitoring Locations**: Each job has named location slots (filled = unit assigned, empty = needs a unit); progress shown as `2/5` with colored squares that fill as units are assigned
- **Estimated Units**: Separate planning number independent of actual location count; shown prominently on job cards
- **Fleet Summary panel**: Unit counts as clickable filter buttons; unit list shows reservation badges with job name, dates, and color
- **Available Units panel**: Shows units available for the job's date range when assigning
- **Smart color picker**: 18-swatch palette + custom color wheel; new jobs auto-pick a color maximally distant in hue from existing jobs
- **Job card progress**: `est. N · X/Y (Z more)` with filled/empty squares; amber → green when fully assigned
- **Promote to Project**: Promote a planned job to a tracked project directly from the planner form
- **Collapsible job details**: Name, dates, device type, color, project link, and estimated units collapse into a summary header
- **Calendar bar tooltips**: Hover any job bar to see job name and date range
- **Hash-based tab persistence**: `#cal` in URL restores Calendar tab on refresh; device type toggle preserves active tab
- **Auto-scroll to today**: Switching to Calendar tab smooth-scrolls to the current month
- **Upcoming project status**: New `upcoming` status for projects promoted from reservations
- **Job device type**: Reservations carry a device type so they only appear on the correct calendar
- **Project filtering by device type**: Projects only appear on the calendar matching their type (vibration → seismograph, sound → SLM, combined → both)
- **Confirmed/Planned toggles**: Independent show/hide toggles for job bar layers on the calendar
- **Cal expire dots toggle**: Calibration expiry dots off by default, togglable
### Changed
- **Renamed**: "Fleet Calendar" / "Reservation Planner" → **"Job Planner"** throughout UI and sidebar
- **Project status dropdown**: Inline `<select>` in project header for quick status changes
- **"All Projects" tab**: Shows everything except deleted; default view excludes archived/completed
- **Toast notifications**: All `alert()` dialogs replaced with non-blocking toasts (green = success, red = error)
### Migration Notes
Run on each database before deploying:
```bash
docker compose exec terra-view python3 -c "
import sqlite3
conn = sqlite3.connect('/app/data/seismo_fleet.db')
conn.execute('ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER')
conn.commit()
conn.close()
"
```
---
## [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
### Added
- **"Out for Calibration" Unit Status**: New `out_for_cal` status for units currently away for calibration, with visual indicators in the roster, unit list, and seismograph stats panel
- **Reservation Modal**: Fleet calendar reservation modal is now fully functional for creating and managing device reservations
### Changed
- **Retire Unit Button**: Redesigned to be more visually prominent/destructive to reduce accidental clicks
### Fixed
- **Migration Scripts**: Fixed database path references in several migration scripts
- **Docker Compose**: Removed dev override file from the repository; dev environment config kept separate
### Migration Notes
Run the following migration script once per database before deploying:
```bash
python backend/migrate_add_out_for_calibration.py
```
---
## [0.7.0] - 2026-03-07
### Added

View File

@@ -1,4 +1,4 @@
# Terra-View v0.7.0
# Terra-View v0.9.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.
## Features
@@ -496,6 +496,11 @@ docker compose down -v
## 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
- **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
@@ -594,9 +599,13 @@ MIT
## Version
**Current: 0.7.0**Project status management, manual SD card upload, combined report wizard, NL32 support, MonitoringSession rename (2026-03-07)
**Current: 0.8.0**Watcher Manager admin page, live agent status refresh, watcher connectivity-based status (2026-03-18)
Previous: 0.6.1 — One-off recording schedules, bidirectional pairing sync, scheduler timezone fix (2026-02-16)
Previous: 0.7.1 — Out-for-calibration status, reservation modal, migration fixes (2026-03-12)
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)

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app
VERSION = "0.7.0"
VERSION = "0.9.1"
if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0":
@@ -102,6 +102,9 @@ app.include_router(modem_dashboard.router)
from backend.routers import settings
app.include_router(settings.router)
from backend.routers import watcher_manager
app.include_router(watcher_manager.router)
# Projects system routers
app.include_router(projects.router)
app.include_router(project_locations.router)
@@ -659,6 +662,7 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": True,
"retired": False,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -683,6 +687,32 @@ async def devices_all_partial(request: Request):
"last_seen": unit_data.get("last", "Never"),
"deployed": False,
"retired": False,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
"address": unit_data.get("address", ""),
"coordinates": unit_data.get("coordinates", ""),
"project_id": unit_data.get("project_id", ""),
"last_calibrated": unit_data.get("last_calibrated"),
"next_calibration_due": unit_data.get("next_calibration_due"),
"deployed_with_modem_id": unit_data.get("deployed_with_modem_id"),
"deployed_with_unit_id": unit_data.get("deployed_with_unit_id"),
"ip_address": unit_data.get("ip_address"),
"phone_number": unit_data.get("phone_number"),
"hardware_model": unit_data.get("hardware_model"),
})
# Add out-for-calibration units
for unit_id, unit_data in snapshot["out_for_calibration"].items():
units_list.append({
"id": unit_id,
"status": "Out for Calibration",
"age": "N/A",
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": True,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -707,6 +737,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": True,
"out_for_calibration": False,
"ignored": False,
"note": unit_data.get("note", ""),
"device_type": unit_data.get("device_type", "seismograph"),
@@ -731,6 +762,7 @@ async def devices_all_partial(request: Request):
"last_seen": "N/A",
"deployed": False,
"retired": False,
"out_for_calibration": False,
"ignored": True,
"note": unit_data.get("note", unit_data.get("reason", "")),
"device_type": unit_data.get("device_type", "unknown"),
@@ -748,15 +780,17 @@ async def devices_all_partial(request: Request):
# Sort by status category, then by ID
def sort_key(unit):
# Priority: deployed (active) -> benched -> retired -> ignored
# Priority: deployed (active) -> benched -> out_for_calibration -> retired -> ignored
if unit["deployed"]:
return (0, unit["id"])
elif not unit["retired"] and not unit["ignored"]:
elif not unit["retired"] and not unit["out_for_calibration"] and not unit["ignored"]:
return (1, unit["id"])
elif unit["retired"]:
elif unit["out_for_calibration"]:
return (2, unit["id"])
else:
elif unit["retired"]:
return (3, unit["id"])
else:
return (4, unit["id"])
units_list.sort(key=sort_key)

View File

@@ -0,0 +1,62 @@
"""
Migration: Add estimated_units to job_reservations
Adds column:
- job_reservations.estimated_units: Estimated number of units for the reservation (nullable integer)
"""
import sqlite3
import sys
from pathlib import Path
# Default database path (matches production pattern)
DB_PATH = "./data/seismo_fleet.db"
def migrate(db_path: str):
"""Run the migration."""
print(f"Migrating database: {db_path}")
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
try:
# Check if job_reservations table exists
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='job_reservations'")
if not cursor.fetchone():
print("job_reservations table does not exist. Skipping migration.")
return
# Get existing columns in job_reservations
cursor.execute("PRAGMA table_info(job_reservations)")
existing_cols = {row[1] for row in cursor.fetchall()}
# Add estimated_units column if it doesn't exist
if 'estimated_units' not in existing_cols:
print("Adding estimated_units column to job_reservations...")
cursor.execute("ALTER TABLE job_reservations ADD COLUMN estimated_units INTEGER")
else:
print("estimated_units column already exists. Skipping.")
conn.commit()
print("Migration completed successfully!")
except Exception as e:
print(f"Migration failed: {e}")
conn.rollback()
raise
finally:
conn.close()
if __name__ == "__main__":
db_path = DB_PATH
if len(sys.argv) > 1:
db_path = sys.argv[1]
if not Path(db_path).exists():
print(f"Database not found: {db_path}")
sys.exit(1)
migrate(db_path)

View File

@@ -0,0 +1,24 @@
"""
Migration: Add location_slots column to job_reservations table.
Stores the full ordered slot list (including empty/unassigned slots) as JSON.
Run once per database.
"""
import sqlite3
import os
DB_PATH = os.environ.get("DB_PATH", "/app/data/seismo_fleet.db")
def run():
conn = sqlite3.connect(DB_PATH)
cursor = conn.cursor()
existing = [r[1] for r in cursor.execute("PRAGMA table_info(job_reservations)").fetchall()]
if "location_slots" not in existing:
cursor.execute("ALTER TABLE job_reservations ADD COLUMN location_slots TEXT")
conn.commit()
print("Added location_slots column to job_reservations.")
else:
print("location_slots column already exists, skipping.")
conn.close()
if __name__ == "__main__":
run()

View File

@@ -0,0 +1,54 @@
"""
Database Migration: Add out_for_calibration field to roster table
Changes:
- Adds out_for_calibration BOOLEAN column (default FALSE) to roster table
- Safe to run multiple times (idempotent)
- No data loss
Usage:
python backend/migrate_add_out_for_calibration.py
"""
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
SQLALCHEMY_DATABASE_URL = "sqlite:///./data/seismo_fleet.db"
engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False})
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
def migrate():
db = SessionLocal()
try:
print("=" * 60)
print("Migration: Add out_for_calibration to roster")
print("=" * 60)
# Check if column already exists
result = db.execute(text("PRAGMA table_info(roster)")).fetchall()
columns = [row[1] for row in result]
if "out_for_calibration" in columns:
print("Column out_for_calibration already exists. Skipping.")
else:
db.execute(text("ALTER TABLE roster ADD COLUMN out_for_calibration BOOLEAN DEFAULT FALSE"))
db.commit()
print("Added out_for_calibration column to roster table.")
print("Migration complete.")
except Exception as e:
db.rollback()
print(f"Error: {e}")
raise
finally:
db.close()
if __name__ == "__main__":
migrate()

View File

@@ -44,7 +44,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -42,7 +42,7 @@ def migrate(db_path: str):
if __name__ == "__main__":
db_path = "./data/terra-view.db"
db_path = "./data/seismo_fleet.db"
if len(sys.argv) > 1:
db_path = sys.argv[1]

View File

@@ -32,6 +32,7 @@ class RosterUnit(Base):
device_type = Column(String, default="seismograph") # "seismograph" | "modem" | "slm"
deployed = Column(Boolean, default=True)
retired = Column(Boolean, default=False)
out_for_calibration = Column(Boolean, default=False)
note = Column(String, nullable=True)
project_id = Column(String, nullable=True)
location = Column(String, nullable=True) # Legacy field - use address/coordinates instead
@@ -65,6 +66,26 @@ class RosterUnit(Base):
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):
"""
Ignored units: units that report but should be filtered out from unknown emitters.
@@ -459,6 +480,11 @@ class JobReservation(Base):
# For quantity reservations
device_type = Column(String, default="seismograph") # seismograph | slm
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
estimated_units = Column(Integer, nullable=True)
# Full slot list as JSON: [{"location_name": "North Gate", "unit_id": null}, ...]
# Includes empty slots (no unit assigned yet). Filled slots are authoritative in JobReservationUnit.
location_slots = Column(Text, nullable=True)
# Metadata
notes = Column(Text, nullable=True)
@@ -494,3 +520,10 @@ class JobReservationUnit(Base):
assignment_source = Column(String, default="specific") # "specific" | "filled" | "swap"
assigned_at = Column(DateTime, default=datetime.utcnow)
notes = Column(Text, nullable=True) # "Replacing BE17353" etc.
# Power requirements for this deployment slot
power_type = Column(String, nullable=True) # "ac" | "solar" | None
# Location identity
location_name = Column(String, nullable=True) # e.g. "North Gate", "Main Entrance"
slot_index = Column(Integer, nullable=True) # Order within reservation (0-based)

View File

@@ -19,7 +19,7 @@ import logging
from backend.database import get_db
from backend.models import (
RosterUnit, JobReservation, JobReservationUnit,
UserPreferences, Project
UserPreferences, Project, MonitoringLocation, UnitAssignment
)
from backend.templates_config import templates
from backend.services.fleet_calendar_service import (
@@ -61,9 +61,53 @@ async def fleet_calendar_page(
# Get projects for the reservation form dropdown
projects = db.query(Project).filter(
Project.status == "active"
Project.status.in_(["active", "upcoming", "on_hold"])
).order_by(Project.name).all()
# Build a serializable list of items with dates for calendar bars
# Includes both tracked Projects (with dates) and Job Reservations (matching device_type)
project_colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6', '#EC4899', '#06B6D4', '#F97316']
# Map calendar device_type to project_type_ids
device_type_to_project_types = {
"seismograph": ["vibration_monitoring", "combined"],
"slm": ["sound_monitoring", "combined"],
}
relevant_project_types = device_type_to_project_types.get(device_type, [])
calendar_projects = []
for i, p in enumerate(projects):
if p.start_date and p.project_type_id in relevant_project_types:
calendar_projects.append({
"id": p.id,
"name": p.name,
"start_date": p.start_date.isoformat(),
"end_date": p.end_date.isoformat() if p.end_date else None,
"color": project_colors[i % len(project_colors)],
"confirmed": True,
})
# Add job reservations for this device_type as bars
from sqlalchemy import or_ as _or
cal_window_end = date(year + ((month + 10) // 12), ((month + 10) % 12) + 1, 1)
reservations_for_cal = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= cal_window_end,
_or(
JobReservation.end_date >= date(year, month, 1),
JobReservation.end_date == None,
)
).all()
for res in reservations_for_cal:
end = res.end_date or res.estimated_end_date
calendar_projects.append({
"id": res.id,
"name": res.name,
"start_date": res.start_date.isoformat(),
"end_date": end.isoformat() if end else None,
"color": res.color,
"confirmed": bool(res.project_id),
})
# Calculate prev/next month navigation
prev_year, prev_month = (year - 1, 12) if month == 1 else (year, month - 1)
next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
@@ -81,6 +125,7 @@ async def fleet_calendar_page(
"device_type": device_type,
"calendar_data": calendar_data,
"projects": projects,
"calendar_projects": calendar_projects,
"today": today.isoformat()
}
)
@@ -167,6 +212,7 @@ async def create_reservation(
if estimated_end_date and estimated_end_date < start_date:
raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
import json as _json
reservation = JobReservation(
id=str(uuid.uuid4()),
name=data["name"],
@@ -178,6 +224,8 @@ async def create_reservation(
assignment_type=data["assignment_type"],
device_type=data.get("device_type", "seismograph"),
quantity_needed=data.get("quantity_needed"),
estimated_units=data.get("estimated_units"),
location_slots=_json.dumps(data["location_slots"]) if data.get("location_slots") is not None else None,
notes=data.get("notes"),
color=data.get("color", "#3B82F6")
)
@@ -221,8 +269,16 @@ async def get_reservation(
reservation_id=reservation_id
).all()
unit_ids = [a.unit_id for a in assignments]
# Sort assignments by slot_index so order is preserved
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
unit_ids = [a.unit_id for a in assignments_sorted]
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 per-unit lookups from assignments
assignment_map = {a.unit_id: a for a in assignments_sorted}
import json as _json
stored_slots = _json.loads(reservation.location_slots) if reservation.location_slots else None
return {
"id": reservation.id,
@@ -235,15 +291,21 @@ async def get_reservation(
"assignment_type": reservation.assignment_type,
"device_type": reservation.device_type,
"quantity_needed": reservation.quantity_needed,
"estimated_units": reservation.estimated_units,
"location_slots": stored_slots,
"notes": reservation.notes,
"color": reservation.color,
"assigned_units": [
{
"id": u.id,
"last_calibrated": u.last_calibrated.isoformat() if u.last_calibrated else None,
"deployed": u.deployed
"id": uid,
"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": units_by_id[uid].deployed if uid in units_by_id else False,
"power_type": assignment_map[uid].power_type,
"notes": assignment_map[uid].notes,
"location_name": assignment_map[uid].location_name,
"slot_index": assignment_map[uid].slot_index,
}
for u in units
for uid in unit_ids
]
}
@@ -278,6 +340,11 @@ async def update_reservation(
reservation.assignment_type = data["assignment_type"]
if "quantity_needed" in data:
reservation.quantity_needed = data["quantity_needed"]
if "estimated_units" in data:
reservation.estimated_units = data["estimated_units"]
if "location_slots" in data:
import json as _json
reservation.location_slots = _json.dumps(data["location_slots"]) if data["location_slots"] is not None else None
if "notes" in data:
reservation.notes = data["notes"]
if "color" in data:
@@ -337,52 +404,57 @@ async def assign_units_to_reservation(
data = await request.json()
unit_ids = data.get("unit_ids", [])
# Optional per-unit dicts keyed by unit_id
power_types = data.get("power_types", {})
location_notes = data.get("location_notes", {})
location_names = data.get("location_names", {})
# slot_indices: {"BE17354": 0, "BE9441": 1, ...}
slot_indices = data.get("slot_indices", {})
if not unit_ids:
raise HTTPException(status_code=400, detail="No units specified")
# Verify units exist (allow empty list to clear all assignments)
if unit_ids:
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
found_ids = {u.id for u in units}
missing = set(unit_ids) - found_ids
if missing:
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
# Verify units exist
units = db.query(RosterUnit).filter(RosterUnit.id.in_(unit_ids)).all()
found_ids = {u.id for u in units}
missing = set(unit_ids) - found_ids
if missing:
raise HTTPException(status_code=404, detail=f"Units not found: {', '.join(missing)}")
# 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 (already assigned to overlapping reservations)
# Check for conflicts with other reservations and insert new assignments
conflicts = []
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
overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter(
JobReservationUnit.unit_id == unit_id,
JobReservation.id != reservation_id,
JobReservation.start_date <= reservation.end_date,
JobReservation.end_date >= reservation.start_date
).first()
if reservation.end_date:
overlapping = db.query(JobReservation).join(
JobReservationUnit, JobReservation.id == JobReservationUnit.reservation_id
).filter(
JobReservationUnit.unit_id == unit_id,
JobReservation.id != reservation_id,
JobReservation.start_date <= reservation.end_date,
JobReservation.end_date >= reservation.start_date
).first()
if overlapping:
conflicts.append({
"unit_id": unit_id,
"conflict_reservation": overlapping.name,
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
})
continue
if overlapping:
conflicts.append({
"unit_id": unit_id,
"conflict_reservation": overlapping.name,
"conflict_dates": f"{overlapping.start_date} - {overlapping.end_date}"
})
continue
# Add assignment
assignment = JobReservationUnit(
id=str(uuid.uuid4()),
reservation_id=reservation_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),
location_name=location_names.get(unit_id),
slot_index=slot_indices.get(unit_id),
)
db.add(assignment)
@@ -511,7 +583,7 @@ async def get_reservations_list(
else:
end_date = date(end_year, end_month + 1, 1) - timedelta(days=1)
# Include TBD reservations that started before window end
# Filter by device_type and date window
reservations = db.query(JobReservation).filter(
JobReservation.device_type == device_type,
JobReservation.start_date <= end_date,
@@ -524,16 +596,38 @@ async def get_reservations_list(
# Get assignment counts
reservation_data = []
for res in reservations:
assigned_count = db.query(JobReservationUnit).filter_by(
assignments = db.query(JobReservationUnit).filter_by(
reservation_id=res.id
).count()
).all()
assigned_count = len(assignments)
# Enrich assignments with unit details, sorted by slot_index
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
unit_ids = [a.unit_id for a in assignments_sorted]
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,
"location_name": a.location_name,
"slot_index": a.slot_index,
"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_sorted
]
# Check for calibration conflicts
conflicts = check_calibration_conflicts(db, res.id)
location_count = res.quantity_needed or assigned_count
reservation_data.append({
"reservation": res,
"assigned_count": assigned_count,
"location_count": location_count,
"assigned_units": assigned_units,
"has_conflicts": len(conflicts) > 0,
"conflict_count": len(conflicts)
})
@@ -549,6 +643,79 @@ 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, with current reservation info
from backend.models import RosterUnit as RU
from datetime import timedelta
today = date.today()
all_units = db.query(RU).filter(
RU.device_type == device_type,
RU.retired == False
).all()
# Build a map: unit_id -> list of active/upcoming reservations
active_assignments = db.query(JobReservationUnit).join(
JobReservation, JobReservationUnit.reservation_id == JobReservation.id
).filter(
JobReservation.device_type == device_type,
JobReservation.end_date >= today
).all()
unit_reservations = {}
for assignment in active_assignments:
res = db.query(JobReservation).filter(JobReservation.id == assignment.reservation_id).first()
if not res:
continue
unit_reservations.setdefault(assignment.unit_id, []).append({
"reservation_id": res.id,
"reservation_name": res.name,
"start_date": res.start_date.isoformat() if res.start_date else None,
"end_date": res.end_date.isoformat() if res.end_date else None,
"color": res.color or "#3B82F6"
})
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 "",
"reservations": unit_reservations.get(u.id, [])
})
# 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)
async def get_available_units_partial(
request: Request,
@@ -608,3 +775,102 @@ async def get_month_partial(
"today": date.today().isoformat()
}
)
# ============================================================================
# Promote Reservation to Project
# ============================================================================
@router.post("/api/fleet-calendar/reservations/{reservation_id}/promote-to-project", response_class=JSONResponse)
async def promote_reservation_to_project(
reservation_id: str,
request: Request,
db: Session = Depends(get_db)
):
"""
Promote a job reservation to a full project in the projects DB.
Creates: Project + MonitoringLocations + UnitAssignments.
"""
reservation = db.query(JobReservation).filter_by(id=reservation_id).first()
if not reservation:
raise HTTPException(status_code=404, detail="Reservation not found")
data = await request.json()
project_number = data.get("project_number") or None
client_name = data.get("client_name") or None
# Map device_type to project_type_id
if reservation.device_type == "slm":
project_type_id = "sound_monitoring"
location_type = "sound"
else:
project_type_id = "vibration_monitoring"
location_type = "vibration"
# Check for duplicate project name
existing = db.query(Project).filter_by(name=reservation.name).first()
if existing:
raise HTTPException(status_code=409, detail=f"A project named '{reservation.name}' already exists.")
# Create the project
project_id = str(uuid.uuid4())
project = Project(
id=project_id,
name=reservation.name,
project_number=project_number,
client_name=client_name,
project_type_id=project_type_id,
status="upcoming",
start_date=reservation.start_date,
end_date=reservation.end_date,
description=reservation.notes,
)
db.add(project)
db.flush()
# Load assignments sorted by slot_index
assignments = db.query(JobReservationUnit).filter_by(reservation_id=reservation_id).all()
assignments_sorted = sorted(assignments, key=lambda a: (a.slot_index if a.slot_index is not None else 999))
locations_created = 0
units_assigned = 0
for i, assignment in enumerate(assignments_sorted):
loc_num = str(i + 1).zfill(3)
loc_name = assignment.location_name or f"Location {i + 1}"
location = MonitoringLocation(
id=str(uuid.uuid4()),
project_id=project_id,
location_type=location_type,
name=loc_name,
description=assignment.notes,
)
db.add(location)
db.flush()
locations_created += 1
if assignment.unit_id:
unit_assignment = UnitAssignment(
id=str(uuid.uuid4()),
unit_id=assignment.unit_id,
location_id=location.id,
project_id=project_id,
device_type=reservation.device_type or "seismograph",
status="active",
notes=f"Power: {assignment.power_type}" if assignment.power_type else None,
)
db.add(unit_assignment)
units_assigned += 1
db.commit()
logger.info(f"Promoted reservation '{reservation.name}' to project {project_id}")
return {
"success": True,
"project_id": project_id,
"project_name": reservation.name,
"locations_created": locations_created,
"units_assigned": units_assigned,
}

View File

@@ -373,11 +373,13 @@ async def get_projects_list(
"""
query = db.query(Project)
# Filter by status if provided; otherwise exclude soft-deleted projects
if status:
# Filter by status if provided; otherwise exclude archived/deleted from default view
if status == "all":
query = query.filter(Project.status != "deleted")
elif status:
query = query.filter(Project.status == status)
else:
query = query.filter(Project.status != "deleted")
query = query.filter(Project.status.notin_(["deleted", "archived", "completed"]))
# Filter by project type if provided
if project_type_id:
@@ -438,6 +440,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
"""
# Count projects by status (exclude deleted)
total_projects = db.query(func.count(Project.id)).filter(Project.status != "deleted").scalar()
upcoming_projects = db.query(func.count(Project.id)).filter_by(status="upcoming").scalar()
active_projects = db.query(func.count(Project.id)).filter_by(status="active").scalar()
on_hold_projects = db.query(func.count(Project.id)).filter_by(status="on_hold").scalar()
completed_projects = db.query(func.count(Project.id)).filter_by(status="completed").scalar()
@@ -459,6 +462,7 @@ async def get_projects_stats(request: Request, db: Session = Depends(get_db)):
"request": request,
"total_projects": total_projects,
"active_projects": active_projects,
"upcoming_projects": upcoming_projects,
"on_hold_projects": on_hold_projects,
"completed_projects": completed_projects,
"total_locations": total_locations,

View File

@@ -146,6 +146,7 @@ async def add_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -177,6 +178,7 @@ async def add_roster_unit(
# Convert boolean strings to actual booleans
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -211,6 +213,7 @@ async def add_roster_unit(
unit_type=unit_type,
deployed=deployed_bool,
retired=retired_bool,
out_for_calibration=out_for_calibration_bool,
note=note,
project_id=project_id,
location=location,
@@ -463,6 +466,7 @@ def get_roster_unit(unit_id: str, db: Session = Depends(get_db)):
"unit_type": unit.unit_type,
"deployed": unit.deployed,
"retired": unit.retired,
"out_for_calibration": unit.out_for_calibration or False,
"note": unit.note or "",
"project_id": unit.project_id or "",
"location": unit.location or "",
@@ -494,6 +498,7 @@ async def edit_roster_unit(
unit_type: str = Form("series3"),
deployed: str = Form(None),
retired: str = Form(None),
out_for_calibration: str = Form(None),
note: str = Form(""),
project_id: str = Form(None),
location: str = Form(None),
@@ -535,6 +540,7 @@ async def edit_roster_unit(
# Convert boolean strings to actual booleans
deployed_bool = deployed in ['true', 'True', '1', 'yes'] if deployed else False
retired_bool = retired in ['true', 'True', '1', 'yes'] if retired else False
out_for_calibration_bool = out_for_calibration in ['true', 'True', '1', 'yes'] if out_for_calibration else False
# Convert port strings to integers
slm_tcp_port_int = int(slm_tcp_port) if slm_tcp_port and slm_tcp_port.strip() else None
@@ -564,12 +570,14 @@ async def edit_roster_unit(
old_note = unit.note
old_deployed = unit.deployed
old_retired = unit.retired
old_out_for_calibration = unit.out_for_calibration
# Update all fields
unit.device_type = device_type
unit.unit_type = unit_type
unit.deployed = deployed_bool
unit.retired = retired_bool
unit.out_for_calibration = out_for_calibration_bool
unit.note = note
unit.project_id = project_id
unit.location = location
@@ -677,6 +685,11 @@ async def edit_roster_unit(
old_status_text = "retired" if old_retired else "active"
record_history(db, unit_id, "retired_change", "retired", old_status_text, status_text, "manual")
if old_out_for_calibration != out_for_calibration_bool:
status_text = "out_for_calibration" if out_for_calibration_bool else "available"
old_status_text = "out_for_calibration" if old_out_for_calibration else "available"
record_history(db, unit_id, "calibration_status_change", "out_for_calibration", old_status_text, status_text, "manual")
# Handle cascade to paired device
cascaded_unit_id = None
if cascade_to_unit_id and cascade_to_unit_id.strip():

View File

@@ -28,7 +28,8 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
total = len(seismos)
deployed = sum(1 for s in seismos if s.deployed)
benched = sum(1 for s in seismos if not s.deployed)
benched = sum(1 for s in seismos if not s.deployed and not s.out_for_calibration)
out_for_calibration = sum(1 for s in seismos if s.out_for_calibration)
# Count modems assigned to deployed seismographs
with_modem = sum(1 for s in seismos if s.deployed and s.deployed_with_modem_id)
@@ -41,6 +42,7 @@ async def get_seismo_stats(request: Request, db: Session = Depends(get_db)):
"total": total,
"deployed": deployed,
"benched": benched,
"out_for_calibration": out_for_calibration,
"with_modem": with_modem,
"without_modem": without_modem
}
@@ -77,7 +79,9 @@ async def get_seismo_units(
if status == "deployed":
query = query.filter(RosterUnit.deployed == True)
elif status == "benched":
query = query.filter(RosterUnit.deployed == False)
query = query.filter(RosterUnit.deployed == False, RosterUnit.out_for_calibration == False)
elif status == "out_for_calibration":
query = query.filter(RosterUnit.out_for_calibration == True)
# Apply modem filter
if modem == "with":

View File

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

View File

@@ -5,7 +5,7 @@ from datetime import datetime
from typing import Optional, List
from backend.database import get_db
from backend.models import Emitter
from backend.models import Emitter, WatcherAgent
router = APIRouter()
@@ -107,6 +107,35 @@ def get_fleet_status(db: Session = Depends(get_db)):
emitters = db.query(Emitter).all()
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)
from fastapi import Request
@@ -120,6 +149,11 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
source = payload.get("source_id")
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("Source:", source)
@@ -182,13 +216,27 @@ async def series3_heartbeat(request: Request, db: Session = Depends(get_db)):
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()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
"results": results,
"update_available": update_available,
}
@@ -219,8 +267,14 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
"""
payload = await request.json()
source = payload.get("source", "series4_emitter")
# Accept source_id (new standard field) with fallback to legacy "source" key
source = payload.get("source_id") or payload.get("source", "series4_emitter")
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("Source:", source)
@@ -276,11 +330,25 @@ async def series4_heartbeat(request: Request, db: Session = Depends(get_db)):
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()
return {
"message": "Heartbeat processed",
"source": source,
"units_processed": len(results),
"results": results
"results": results,
"update_available": update_available,
}

View File

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

View File

@@ -80,6 +80,12 @@ def emit_status_snapshot():
age = "N/A"
last_seen = None
fname = ""
elif r.out_for_calibration:
# Out for calibration units get separated later
status = "Out for Calibration"
age = "N/A"
last_seen = None
fname = ""
else:
if e:
last_seen = ensure_utc(e.last_seen)
@@ -103,6 +109,7 @@ def emit_status_snapshot():
"deployed": r.deployed,
"note": r.note or "",
"retired": r.retired,
"out_for_calibration": r.out_for_calibration or False,
# Device type and type-specific fields
"device_type": r.device_type or "seismograph",
"last_calibrated": r.last_calibrated.isoformat() if r.last_calibrated else None,
@@ -133,6 +140,7 @@ def emit_status_snapshot():
"deployed": False, # default
"note": "",
"retired": False,
"out_for_calibration": False,
# Device type and type-specific fields (defaults for unknown units)
"device_type": "seismograph", # default
"last_calibrated": None,
@@ -179,12 +187,12 @@ def emit_status_snapshot():
# Separate buckets for UI
active_units = {
uid: u for uid, u in units.items()
if not u["retired"] and u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and u["deployed"] and uid not in ignored
}
benched_units = {
uid: u for uid, u in units.items()
if not u["retired"] and not u["deployed"] and uid not in ignored
if not u["retired"] and not u["out_for_calibration"] and not u["deployed"] and uid not in ignored
}
retired_units = {
@@ -192,6 +200,11 @@ def emit_status_snapshot():
if u["retired"]
}
out_for_calibration_units = {
uid: u for uid, u in units.items()
if u["out_for_calibration"]
}
# Unknown units - emitters that aren't in the roster and aren't ignored
unknown_units = {
uid: u for uid, u in units.items()
@@ -204,12 +217,14 @@ def emit_status_snapshot():
"active": active_units,
"benched": benched_units,
"retired": retired_units,
"out_for_calibration": out_for_calibration_units,
"unknown": unknown_units,
"summary": {
"total": len(active_units) + len(benched_units),
"active": len(active_units),
"benched": len(benched_units),
"retired": len(retired_units),
"out_for_calibration": len(out_for_calibration_units),
"unknown": len(unknown_units),
# Status counts only for deployed units (active_units)
"ok": sum(1 for u in active_units.values() if u["status"] == "OK"),

View File

@@ -60,6 +60,19 @@ def jinja_same_date(dt1, dt2) -> bool:
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
templates.env.filters["local_datetime"] = jinja_local_datetime
templates.env.filters["local_time"] = jinja_local_time
@@ -68,3 +81,4 @@ templates.env.filters["fromjson"] = jinja_fromjson
templates.env.globals["timezone_abbr"] = jinja_timezone_abbr
templates.env.globals["get_user_timezone"] = get_user_timezone
templates.env.globals["same_date"] = jinja_same_date
templates.env.filters["log_tail_display"] = jinja_log_tail_display

View File

@@ -1,9 +1,7 @@
services:
# --- TERRA-VIEW PRODUCTION ---
terra-view:
build: .
container_name: terra-view
ports:
- "8001:8001"
volumes:
@@ -29,7 +27,6 @@ services:
build:
context: ../slmm
dockerfile: Dockerfile
container_name: slmm
network_mode: host
volumes:
- ../slmm/data:/app/data

37
migrate_watcher_agents.py Normal file
View File

@@ -0,0 +1,37 @@
"""
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()

0
rebuild-prod.sh Normal file → Executable file
View File

View File

@@ -8,7 +8,7 @@ import sys
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
DATABASE_URL = "sqlite:///data/sfm.db"
DATABASE_URL = "sqlite:///data/seismo_fleet.db"
def rename_unit(old_id: str, new_id: str):
"""

View File

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

View File

@@ -85,7 +85,7 @@
<div class="flex h-screen overflow-hidden">
<!-- 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 -->
<div class="p-6 border-b border-gray-200 dark:border-gray-700">
<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">
<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>
Fleet Calendar
Job Planner
</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 %}">
@@ -193,14 +193,14 @@
<!-- Main content -->
<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 %}
</div>
</main>
</div>
<!-- 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">
<button id="hamburgerBtn" class="bottom-nav-btn" onclick="toggleMenu()" aria-label="Menu">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">

View File

@@ -509,7 +509,7 @@ function renderFilteredDashboard(data) {
// Update the Recent Alerts section with filtering
function updateAlertsFiltered(filteredActive) {
const alertsList = document.getElementById('alerts-list');
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing');
const missingUnits = Object.entries(filteredActive).filter(([_, u]) => u.status === 'Missing' && u.device_type !== 'modem');
if (!missingUnits.length) {
// Check if this is because of filters or genuinely no alerts

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@
{% for unit in units %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-id="{{ unit.id }}"
data-type="{{ unit.device_type }}"
@@ -60,7 +60,9 @@
data-note="{{ unit.note if unit.note else '' }}">
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center space-x-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-3 h-3 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif not unit.deployed %}
<span class="w-3 h-3 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-3 h-3 rounded-full bg-green-500" title="OK"></span>
@@ -72,6 +74,8 @@
{% if unit.deployed %}
<span class="w-2 h-2 rounded-full bg-blue-500" title="Deployed"></span>
{% elif unit.out_for_calibration %}
<span class="w-2 h-2 rounded-full bg-purple-400" title="Out for Calibration"></span>
{% else %}
<span class="w-2 h-2 rounded-full bg-gray-300 dark:bg-gray-600" title="Benched"></span>
{% endif %}
@@ -203,14 +207,16 @@
<div class="unit-card device-card"
onclick="openUnitModal('{{ unit.id }}', '{{ unit.status }}', '{{ unit.age }}')"
data-device-type="{{ unit.device_type }}"
data-status="{% if unit.deployed %}deployed{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-status="{% if unit.deployed %}deployed{% elif unit.out_for_calibration %}out_for_calibration{% elif unit.retired %}retired{% elif unit.ignored %}ignored{% else %}benched{% endif %}"
data-health="{{ unit.status }}"
data-unit-id="{{ unit.id }}"
data-age="{{ unit.age }}">
<!-- Header: Status Dot + Unit ID + Status Badge -->
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-2">
{% if not unit.deployed %}
{% if unit.out_for_calibration %}
<span class="w-4 h-4 rounded-full bg-purple-500" title="Out for Calibration"></span>
{% elif not unit.deployed %}
<span class="w-4 h-4 rounded-full bg-gray-400 dark:bg-gray-500" title="Benched"></span>
{% elif unit.status == 'OK' %}
<span class="w-4 h-4 rounded-full bg-green-500" title="OK"></span>
@@ -224,12 +230,13 @@
<span class="font-bold text-lg text-seismo-orange dark:text-seismo-orange">{{ unit.id }}</span>
</div>
<span class="px-3 py-1 rounded-full text-xs font-medium
{% if unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% if unit.out_for_calibration %}bg-purple-100 dark:bg-purple-900/30 text-purple-800 dark:text-purple-300
{% elif unit.status == 'OK' %}bg-green-100 dark:bg-green-900/30 text-green-800 dark:text-green-300
{% elif unit.status == 'Pending' %}bg-yellow-100 dark:bg-yellow-900/30 text-yellow-800 dark:text-yellow-300
{% elif unit.status == 'Missing' %}bg-red-100 dark:bg-red-900/30 text-red-800 dark:text-red-300
{% else %}bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400
{% endif %}">
{% if unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
{% if unit.out_for_calibration %}Out for Cal{% elif unit.status in ['N/A', 'Unknown'] %}Benched{% else %}{{ unit.status }}{% endif %}
</span>
</div>
@@ -279,6 +286,10 @@
<span class="text-xs text-blue-600 dark:text-blue-400">
⚡ Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="text-xs text-purple-600 dark:text-purple-400">
🔧 Out for Calibration
</span>
{% else %}
<span class="text-xs text-gray-500 dark:text-gray-500">
📦 Benched

View File

@@ -1,103 +1,203 @@
<!-- Reservations List -->
{% if reservations %}
<div class="space-y-3">
<div class="space-y-2">
{% for item in reservations %}
{% 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 }};">
<div class="flex-1">
<div class="flex items-center gap-2">
<h3 class="font-semibold text-gray-900 dark:text-white">{{ res.name }}</h3>
{% 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"
title="{{ item.conflict_count }} unit(s) have calibration expiring during this job">
{{ item.conflict_count }} conflict{{ 's' if item.conflict_count != 1 else '' }}
</span>
<!-- 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>
{% 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 %}
<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) will need a calibration swap during this job">
{{ item.conflict_count }} cal swap{{ 's' if item.conflict_count != 1 else '' }}
</span>
{% endif %}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
{{ res.start_date.strftime('%b %d, %Y') }}
{% if res.end_date %}
{{ res.end_date.strftime('%b %d, %Y') }}
{% elif res.end_date_tbd %}
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
{% if res.estimated_end_date %}
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
{% endif %}
{% else %}
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
{% endif %}
</p>
</div>
<!-- Counts -->
<div class="flex flex-col items-end gap-1 mx-4 flex-shrink-0">
{% set full = item.assigned_count == item.location_count and item.location_count > 0 %}
{% set remaining = item.location_count - item.assigned_count %}
<!-- Number row -->
<div class="flex items-baseline gap-2">
<span class="text-xs text-gray-400 dark:text-gray-500">est. {% if res.estimated_units %}{{ res.estimated_units }}{% else %}—{% endif %}</span>
<span class="text-gray-300 dark:text-gray-600">·</span>
<span class="text-base font-bold {% if full %}text-green-600 dark:text-green-400{% elif item.assigned_count == 0 %}text-gray-400 dark:text-gray-500{% else %}text-amber-500 dark:text-amber-400{% endif %}">
{{ item.assigned_count }}/{{ item.location_count }}
</span>
{% if remaining > 0 %}
<span class="text-xs text-amber-500 dark:text-amber-400 whitespace-nowrap">({{ remaining }} more)</span>
{% endif %}
</div>
<!-- Progress squares -->
{% if item.location_count > 0 %}
<div class="flex gap-0.5">
{% for i in range(item.location_count) %}
<span class="w-3 h-3 rounded-sm {% if i < item.assigned_count %}{% if full %}bg-green-500{% else %}bg-amber-500{% endif %}{% else %}bg-gray-300 dark:bg-gray-600{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
{{ res.start_date.strftime('%b %d, %Y') }} -
{% if res.end_date %}
{{ res.end_date.strftime('%b %d, %Y') }}
{% elif res.end_date_tbd %}
<span class="text-yellow-600 dark:text-yellow-400 font-medium">TBD</span>
{% if res.estimated_end_date %}
<span class="text-gray-400">(est. {{ res.estimated_end_date.strftime('%b %d, %Y') }})</span>
{% endif %}
{% else %}
<span class="text-yellow-600 dark:text-yellow-400">Ongoing</span>
{% endif %}
</p>
<!-- Action buttons -->
<div class="flex items-center gap-1 flex-shrink-0">
<!-- Assign units (always visible) -->
<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="Assign 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>
<!-- "..." overflow menu -->
<div class="relative" onclick="event.stopPropagation()">
<button onclick="toggleResMenu('{{ res.id }}')"
class="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
title="More options">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<circle cx="5" cy="12" r="1.5"/><circle cx="12" cy="12" r="1.5"/><circle cx="19" cy="12" r="1.5"/>
</svg>
</button>
<div id="res-menu-{{ res.id }}"
class="hidden absolute right-0 top-8 z-20 w-44 bg-white dark:bg-slate-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1">
<button onclick="openPromoteModal('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
<svg class="w-4 h-4 text-emerald-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 10l7-7m0 0l7 7m-7-7v18"/>
</svg>
Promote to Project
</button>
<button onclick="editReservation('{{ res.id }}'); toggleResMenu('{{ res.id }}')"
class="w-full text-left px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-slate-700 flex items-center gap-2">
<svg class="w-4 h-4 text-blue-500" 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"/>
</svg>
Edit
</button>
<div class="border-t border-gray-100 dark:border-gray-700 my-1"></div>
<button onclick="deleteReservation('{{ res.id }}', '{{ res.name }}'); toggleResMenu('{{ res.id }}')"
class="w-full text-left px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20 flex items-center gap-2">
<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"/>
</svg>
Delete
</button>
</div>
</div>
<!-- Chevron -->
<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>
<!-- 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-400 dark:text-gray-500 mt-1">{{ res.notes }}</p>
<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">
<div class="text-gray-500 dark:text-gray-400">Estimated</div>
<div class="font-medium {% if res.estimated_units %}text-gray-800 dark:text-gray-200{% else %}text-gray-400 dark:text-gray-500 italic{% endif %}">
{% if res.estimated_units %}{{ res.estimated_units }} unit{{ 's' if res.estimated_units != 1 else '' }}{% else %}not specified{% endif %}
</div>
<div class="text-gray-500 dark:text-gray-400">Locations</div>
<div class="font-medium text-gray-800 dark:text-gray-200">{{ item.assigned_count }} of {{ item.location_count }} filled</div>
{% if item.assigned_count < item.location_count %}
<div class="text-gray-500 dark:text-gray-400">Still needed</div>
<div class="font-medium text-amber-600 dark:text-amber-400">{{ item.location_count - item.assigned_count }} location{{ 's' if (item.location_count - item.assigned_count) != 1 else '' }} remaining</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>
<div class="flex flex-col min-w-0">
{% if u.location_name %}
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300 truncate">{{ u.location_name }}</span>
{% endif %}
<button onclick="openUnitDetailModal('{{ u.id }}')"
class="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left text-sm">{{ u.id }}</button>
</div>
<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 class="text-right ml-4">
<p class="text-lg font-bold text-gray-900 dark:text-white">
{% if res.assignment_type == 'quantity' %}
{{ item.assigned_count }}/{{ res.quantity_needed or '?' }}
{% else %}
{{ item.assigned_count }}
{% endif %}
</p>
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ 'units needed' if res.assignment_type == 'quantity' else 'units assigned' }}
</p>
</div>
<div class="ml-4 flex items-center gap-2">
<button onclick="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"
title="Edit reservation">
<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="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>
</button>
<button onclick="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"
title="Delete reservation">
<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="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>
</button>
</div>
</div>
{% endfor %}
</div>
<script>
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');
}
}
function editReservation(id) {
// For now, just show alert - can implement edit modal later
alert('Edit functionality coming soon. For now, delete and recreate the reservation.');
}
</script>
<!-- toggleResCard, deleteReservation, editReservation, openUnitDetailModal defined in fleet_calendar.html -->
{% else %}
<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">
<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>
<p class="text-gray-500 dark:text-gray-400">No reservations for {{ year }}</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-gray-500 dark:text-gray-400">No jobs yet</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-1">Click "New Job" to start planning a deployment</p>
</div>
{% endif %}

View File

@@ -11,7 +11,9 @@
{% endif %}
</p>
</div>
{% if project.status == 'active' %}
{% if project.status == 'upcoming' %}
<span class="px-3 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
{% elif project.status == 'active' %}
<span class="px-3 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
{% elif project.status == 'on_hold' %}
<span class="px-3 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>

View File

@@ -3,12 +3,26 @@
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white mb-2">{{ project.name }}</h1>
<div class="flex items-center gap-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{% if project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
{{ project.status|title }}
</span>
<div class="relative inline-block">
<select onchange="quickUpdateStatus(this.value)"
class="appearance-none cursor-pointer inline-flex items-center pl-3 pr-7 py-1 rounded-full text-sm font-medium border-0 focus:ring-2 focus:ring-offset-1 focus:ring-blue-500
{% if project.status == 'upcoming' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
{% elif project.status == 'active' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
{% elif project.status == 'on_hold' %}bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200
{% elif project.status == 'completed' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200{% endif %}">
<option value="upcoming" {% if project.status == 'upcoming' %}selected{% endif %}>Upcoming</option>
<option value="active" {% if project.status == 'active' %}selected{% endif %}>Active</option>
<option value="on_hold" {% if project.status == 'on_hold' %}selected{% endif %}>On Hold</option>
<option value="completed" {% if project.status == 'completed' %}selected{% endif %}>Completed</option>
<option value="archived" {% if project.status == 'archived' %}selected{% endif %}>Archived</option>
</select>
<span class="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-current opacity-60">
<svg class="w-3 h-3" 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>
</span>
</div>
{% if project_type %}
<span class="text-gray-500 dark:text-gray-400">{{ project_type.name }}</span>
{% endif %}

View File

@@ -14,7 +14,9 @@
{% endif %}
</div>
{% if item.project.status == 'active' %}
{% if item.project.status == 'upcoming' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-300 rounded-full">Upcoming</span>
{% elif item.project.status == 'active' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Active</span>
{% elif item.project.status == 'on_hold' %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400 rounded-full">On Hold</span>

View File

@@ -31,6 +31,9 @@
<div>
<p class="text-gray-600 dark:text-gray-400 text-sm">Benched</p>
<p class="text-3xl font-bold text-gray-600 dark:text-gray-400 mt-2">{{ benched }}</p>
{% if out_for_calibration > 0 %}
<p class="text-xs text-purple-600 dark:text-purple-400 mt-1">{{ out_for_calibration }} out for calibration</p>
{% endif %}
</div>
<svg class="w-12 h-12 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>

View File

@@ -106,6 +106,13 @@
</svg>
Deployed
</span>
{% elif unit.out_for_calibration %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clip-rule="evenodd"></path>
</svg>
Out for Cal
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
<svg class="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">

View File

@@ -328,6 +328,7 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Status</label>
<select name="status" id="settings-status"
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
<option value="upcoming">Upcoming</option>
<option value="active">Active</option>
<option value="on_hold">On Hold</option>
<option value="completed">Completed</option>
@@ -758,6 +759,24 @@ const projectId = "{{ project_id }}";
let editingLocationId = null;
let projectTypeId = null;
async function quickUpdateStatus(newStatus) {
try {
const response = await fetch(`/api/projects/${projectId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ status: newStatus })
});
if (response.ok) {
// Reload the page to reflect new badge color and any side effects
window.location.reload();
} else {
alert('Failed to update status');
}
} catch (e) {
alert('Error updating status');
}
}
// Tab switching
function switchTab(tabName) {
// Hide all tab panels

View File

@@ -36,12 +36,17 @@
<nav class="flex space-x-8 px-6" aria-label="Tabs">
<button onclick="switchTab('all')"
id="tab-all"
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
All Projects
</button>
<button onclick="switchTab('upcoming')"
id="tab-upcoming"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
Upcoming
</button>
<button onclick="switchTab('active')"
id="tab-active"
class="tab-button border-b-2 border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 px-1 py-4 text-sm font-medium">
class="tab-button border-b-2 border-seismo-orange text-seismo-orange px-1 py-4 text-sm font-medium">
Active
</button>
<button onclick="switchTab('on_hold')"
@@ -66,7 +71,7 @@
<!-- Projects List -->
<div id="projects-list"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
hx-get="/api/projects/list"
hx-get="/api/projects/list?status=active"
hx-trigger="load"
hx-swap="innerHTML">
<!-- Loading skeletons -->

View File

@@ -56,6 +56,7 @@
<option value="">All Status</option>
<option value="deployed">Deployed</option>
<option value="benched">Benched</option>
<option value="out_for_calibration">Out for Calibration</option>
</select>
<!-- Modem Filter -->

View File

@@ -41,6 +41,12 @@
</svg>
Danger Zone
</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>
<!-- General Tab -->
@@ -514,6 +520,32 @@
</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>
.settings-tab {
padding: 0.75rem 1.5rem;

View File

@@ -92,7 +92,7 @@
<p id="deployedStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
<div>
<span class="text-sm text-gray-500 dark:text-gray-400">Retired</span>
<span class="text-sm text-gray-500 dark:text-gray-400">Unit Status</span>
<p id="retiredStatus" class="font-medium text-gray-900 dark:text-white">--</p>
</div>
</div>
@@ -497,18 +497,28 @@
</div>
</div>
<!-- Checkboxes -->
<div class="flex items-center gap-6 border-t border-gray-200 dark:border-gray-700 pt-4">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="deployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="retired" id="retired" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Retired</span>
</label>
<!-- Status Checkboxes -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-4 space-y-3">
<div class="flex items-center gap-6">
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="deployed" id="deployed" value="true"
class="w-4 h-4 text-seismo-orange focus:ring-seismo-orange rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Deployed</span>
</label>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="out_for_calibration" id="outForCalibration" value="true"
class="w-4 h-4 text-purple-600 focus:ring-purple-500 rounded">
<span class="text-sm text-gray-700 dark:text-gray-300">Out for Calibration</span>
</label>
</div>
<!-- Hidden field for retired — controlled by the Retire button below -->
<input type="hidden" name="retired" id="retired" value="">
<div id="retireButtonSection">
<button type="button" id="retireBtn"
class="px-4 py-2 text-sm font-medium rounded-lg border transition-colors"
onclick="toggleRetired()">
</button>
</div>
</div>
<!-- Notes -->
@@ -817,7 +827,16 @@ function populateViewMode() {
}
document.getElementById('deployedStatus').textContent = currentUnit.deployed ? 'Yes' : 'No';
document.getElementById('retiredStatus').textContent = currentUnit.retired ? 'Yes' : 'No';
if (currentUnit.retired) {
document.getElementById('retiredStatus').textContent = 'Retired';
document.getElementById('retiredStatus').className = 'font-medium text-red-600 dark:text-red-400';
} else if (currentUnit.out_for_calibration) {
document.getElementById('retiredStatus').textContent = 'Out for Calibration';
document.getElementById('retiredStatus').className = 'font-medium text-purple-600 dark:text-purple-400';
} else {
document.getElementById('retiredStatus').textContent = 'Active';
document.getElementById('retiredStatus').className = 'font-medium text-gray-900 dark:text-white';
}
// Basic info
document.getElementById('viewDeviceType').textContent = currentUnit.device_type || '--';
@@ -1009,7 +1028,9 @@ function populateEditForm() {
document.getElementById('address').value = currentUnit.address || '';
document.getElementById('coordinates').value = currentUnit.coordinates || '';
document.getElementById('deployed').checked = currentUnit.deployed;
document.getElementById('retired').checked = currentUnit.retired;
document.getElementById('outForCalibration').checked = currentUnit.out_for_calibration || false;
document.getElementById('retired').value = currentUnit.retired ? 'true' : '';
updateRetireButton(currentUnit.retired);
document.getElementById('note').value = currentUnit.note || '';
// Seismograph fields
@@ -1153,6 +1174,25 @@ function cancelEdit() {
populateEditForm();
}
function updateRetireButton(isRetired) {
const btn = document.getElementById('retireBtn');
if (!btn) return;
if (isRetired) {
btn.textContent = 'Un-Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600 hover:bg-gray-200 dark:hover:bg-gray-600';
} else {
btn.textContent = 'Retire Unit';
btn.className = 'px-4 py-2 text-sm font-medium rounded-lg border transition-colors bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400 border-red-300 dark:border-red-700 hover:bg-red-100 dark:hover:bg-red-900/40';
}
}
function toggleRetired() {
const hiddenInput = document.getElementById('retired');
const isCurrentlyRetired = hiddenInput.value === 'true';
hiddenInput.value = isCurrentlyRetired ? '' : 'true';
updateRetireButton(!isCurrentlyRetired);
}
// Handle form submission
document.getElementById('editForm').addEventListener('submit', async function(e) {
e.preventDefault();