diff --git a/CHANGELOG.md b/CHANGELOG.md index c7e0ffc..d559ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ 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 diff --git a/README.md b/README.md index f414509..64f10cb 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Terra-View v0.9.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 diff --git a/backend/main.py b/backend/main.py index c5217d7..5f5d7ea 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine) ENVIRONMENT = os.getenv("ENVIRONMENT", "production") # Initialize FastAPI app -VERSION = "0.9.0" +VERSION = "0.9.1" if ENVIRONMENT == "development": _build = os.getenv("BUILD_NUMBER", "0") if _build and _build != "0": diff --git a/backend/migrate_add_location_slots.py b/backend/migrate_add_location_slots.py new file mode 100644 index 0000000..54712ac --- /dev/null +++ b/backend/migrate_add_location_slots.py @@ -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() diff --git a/backend/models.py b/backend/models.py index 273c0bc..30bec47 100644 --- a/backend/models.py +++ b/backend/models.py @@ -482,6 +482,10 @@ class JobReservation(Base): 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) color = Column(String, default="#3B82F6") # For calendar display (blue default) diff --git a/backend/routers/fleet_calendar.py b/backend/routers/fleet_calendar.py index db4990e..eafcff9 100644 --- a/backend/routers/fleet_calendar.py +++ b/backend/routers/fleet_calendar.py @@ -212,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"], @@ -224,6 +225,7 @@ async def create_reservation( 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") ) @@ -275,6 +277,9 @@ async def get_reservation( # 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, "name": reservation.name, @@ -287,6 +292,7 @@ async def get_reservation( "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": [ @@ -336,6 +342,9 @@ async def update_reservation( 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: diff --git a/templates/dashboard.html b/templates/dashboard.html index e153d5b..3ffa65b 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -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 diff --git a/templates/fleet_calendar.html b/templates/fleet_calendar.html index c36c72b..112b5b7 100644 --- a/templates/fleet_calendar.html +++ b/templates/fleet_calendar.html @@ -2015,6 +2015,13 @@ async function plannerSave() { const method = isEdit ? 'PUT' : 'POST'; const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph'; + // Save full slot list including empties so they survive round-trips + const allSlots = plannerState.slots.map(s => ({ + unit_id: s.unit_id || null, + location_name: s.location_name || null, + power_type: s.power_type || null, + notes: s.notes || null + })); const payload = { name, start_date: start, end_date: end, project_id: projectId || null, @@ -2022,7 +2029,8 @@ async function plannerSave() { device_type: plannerDeviceType, color, notes: notes || null, estimated_units: estUnits, - quantity_needed: totalSlots + quantity_needed: totalSlots, + location_slots: allSlots }; const resp = await fetch(url, { @@ -2118,9 +2126,24 @@ async function openPlanner(reservationId) { plannerSetColor(res.color || '#3B82F6', true); const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`); if (dtRadio) dtRadio.checked = true; - // Pre-fill slots from existing assigned units - for (const u of (res.assigned_units || [])) { - plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null, location_name: u.location_name || null }); + // Restore full slot list — use location_slots if available, else fall back to assigned_units only + const assignedById = {}; + for (const u of (res.assigned_units || [])) assignedById[u.id] = u; + if (res.location_slots && res.location_slots.length > 0) { + for (const s of res.location_slots) { + const filled = s.unit_id ? (assignedById[s.unit_id] || {}) : {}; + plannerState.slots.push({ + unit_id: s.unit_id || null, + power_type: s.power_type || filled.power_type || null, + notes: s.notes || filled.notes || null, + location_name: s.location_name || filled.location_name || null + }); + } + } else { + // Legacy: no location_slots stored, just load filled ones + for (const u of (res.assigned_units || [])) { + plannerState.slots.push({ unit_id: u.id, power_type: u.power_type || null, notes: u.notes || null, location_name: u.location_name || null }); + } } const titleEl = document.getElementById('planner-form-title');