merge 0.9.1 #39
14
CHANGELOG.md
14
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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":
|
||||
|
||||
24
backend/migrate_add_location_slots.py
Normal file
24
backend/migrate_add_location_slots.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,10 +2126,25 @@ 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
|
||||
// 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');
|
||||
if (titleEl) titleEl.textContent = res.name;
|
||||
|
||||
Reference in New Issue
Block a user