merge 0.9.1 #39

Merged
serversdown merged 2 commits from dev into main 2026-03-23 21:17:16 -04:00
8 changed files with 81 additions and 7 deletions
Showing only changes of commit 57a85f565b - Show all commits

View File

@@ -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/), The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [0.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 ## [0.9.0] - 2026-03-19
### Added ### Added

View File

@@ -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. Backend API and HTMX-powered web interface for managing a mixed fleet of seismographs and field modems. Track deployments, monitor health in real time, merge roster intent with incoming telemetry, and control your fleet through a unified database and dashboard.
## Features ## Features

View File

@@ -30,7 +30,7 @@ Base.metadata.create_all(bind=engine)
ENVIRONMENT = os.getenv("ENVIRONMENT", "production") ENVIRONMENT = os.getenv("ENVIRONMENT", "production")
# Initialize FastAPI app # Initialize FastAPI app
VERSION = "0.9.0" VERSION = "0.9.1"
if ENVIRONMENT == "development": if ENVIRONMENT == "development":
_build = os.getenv("BUILD_NUMBER", "0") _build = os.getenv("BUILD_NUMBER", "0")
if _build and _build != "0": if _build and _build != "0":

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

@@ -482,6 +482,10 @@ class JobReservation(Base):
quantity_needed = Column(Integer, nullable=True) # e.g., 8 units quantity_needed = Column(Integer, nullable=True) # e.g., 8 units
estimated_units = Column(Integer, nullable=True) 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 # Metadata
notes = Column(Text, nullable=True) notes = Column(Text, nullable=True)
color = Column(String, default="#3B82F6") # For calendar display (blue default) color = Column(String, default="#3B82F6") # For calendar display (blue default)

View File

@@ -212,6 +212,7 @@ async def create_reservation(
if estimated_end_date and estimated_end_date < start_date: if estimated_end_date and estimated_end_date < start_date:
raise HTTPException(status_code=400, detail="Estimated end date must be after start date") raise HTTPException(status_code=400, detail="Estimated end date must be after start date")
import json as _json
reservation = JobReservation( reservation = JobReservation(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
name=data["name"], name=data["name"],
@@ -224,6 +225,7 @@ async def create_reservation(
device_type=data.get("device_type", "seismograph"), device_type=data.get("device_type", "seismograph"),
quantity_needed=data.get("quantity_needed"), quantity_needed=data.get("quantity_needed"),
estimated_units=data.get("estimated_units"), 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"), notes=data.get("notes"),
color=data.get("color", "#3B82F6") color=data.get("color", "#3B82F6")
) )
@@ -275,6 +277,9 @@ async def get_reservation(
# Build per-unit lookups from assignments # Build per-unit lookups from assignments
assignment_map = {a.unit_id: a for a in assignments_sorted} 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 { return {
"id": reservation.id, "id": reservation.id,
"name": reservation.name, "name": reservation.name,
@@ -287,6 +292,7 @@ async def get_reservation(
"device_type": reservation.device_type, "device_type": reservation.device_type,
"quantity_needed": reservation.quantity_needed, "quantity_needed": reservation.quantity_needed,
"estimated_units": reservation.estimated_units, "estimated_units": reservation.estimated_units,
"location_slots": stored_slots,
"notes": reservation.notes, "notes": reservation.notes,
"color": reservation.color, "color": reservation.color,
"assigned_units": [ "assigned_units": [
@@ -336,6 +342,9 @@ async def update_reservation(
reservation.quantity_needed = data["quantity_needed"] reservation.quantity_needed = data["quantity_needed"]
if "estimated_units" in data: if "estimated_units" in data:
reservation.estimated_units = data["estimated_units"] 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: if "notes" in data:
reservation.notes = data["notes"] reservation.notes = data["notes"]
if "color" in data: if "color" in data:

View File

@@ -509,7 +509,7 @@ function renderFilteredDashboard(data) {
// Update the Recent Alerts section with filtering // Update the Recent Alerts section with filtering
function updateAlertsFiltered(filteredActive) { function updateAlertsFiltered(filteredActive) {
const alertsList = document.getElementById('alerts-list'); 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) { if (!missingUnits.length) {
// Check if this is because of filters or genuinely no alerts // Check if this is because of filters or genuinely no alerts

View File

@@ -2015,6 +2015,13 @@ async function plannerSave() {
const method = isEdit ? 'PUT' : 'POST'; const method = isEdit ? 'PUT' : 'POST';
const plannerDeviceType = document.querySelector('input[name="planner_device_type"]:checked')?.value || 'seismograph'; 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 = { const payload = {
name, start_date: start, end_date: end, name, start_date: start, end_date: end,
project_id: projectId || null, project_id: projectId || null,
@@ -2022,7 +2029,8 @@ async function plannerSave() {
device_type: plannerDeviceType, device_type: plannerDeviceType,
color, notes: notes || null, color, notes: notes || null,
estimated_units: estUnits, estimated_units: estUnits,
quantity_needed: totalSlots quantity_needed: totalSlots,
location_slots: allSlots
}; };
const resp = await fetch(url, { const resp = await fetch(url, {
@@ -2118,10 +2126,25 @@ async function openPlanner(reservationId) {
plannerSetColor(res.color || '#3B82F6', true); plannerSetColor(res.color || '#3B82F6', true);
const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`); const dtRadio = document.querySelector(`input[name="planner_device_type"][value="${res.device_type || 'seismograph'}"]`);
if (dtRadio) dtRadio.checked = true; if (dtRadio) dtRadio.checked = true;
// Pre-fill slots from existing assigned units // 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 || [])) { 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 }); 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'); const titleEl = document.getElementById('planner-form-title');
if (titleEl) titleEl.textContent = res.name; if (titleEl) titleEl.textContent = res.name;