v0.11.0 #50

Merged
serversdown merged 13 commits from release/0.11.0 into main 2026-05-15 19:16:43 -04:00
9 changed files with 531 additions and 38 deletions
Showing only changes of commit d5a0163852 - Show all commits
+63
View File
@@ -0,0 +1,63 @@
"""
Migration: add `removed_at` + `removal_reason` columns to `monitoring_locations`.
Lets operators mark a location as no longer actively monitored without
deleting it (so historical events stay attributed correctly). Mirrors
the timestamp-based "closed state" pattern already used by
`unit_assignments.assigned_until`.
Behavior:
- `removed_at IS NULL` → location is active (default for all existing
rows after this migration)
- `removed_at` set → location is removed; historical events still
attribute to it but it's hidden from active
surfaces (assign dropdowns, calendar, etc.)
- `removal_reason` → optional operator note (e.g. "client dropped
from scope")
Idempotent — safe to re-run. Non-destructive — adds only.
Run with:
docker exec terra-view-terra-view-1 python3 /app/backend/migrate_add_location_removed.py
"""
import os
import sqlite3
DB_PATH = "./data/seismo_fleet.db"
def _has_column(cur: sqlite3.Cursor, table: str, column: str) -> bool:
cur.execute(f"PRAGMA table_info({table})")
return any(row[1] == column for row in cur.fetchall())
def migrate_database() -> None:
if not os.path.exists(DB_PATH):
print(f"Database not found at {DB_PATH}")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
added = []
if not _has_column(cur, "monitoring_locations", "removed_at"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removed_at DATETIME")
added.append("removed_at")
if not _has_column(cur, "monitoring_locations", "removal_reason"):
cur.execute("ALTER TABLE monitoring_locations ADD COLUMN removal_reason TEXT")
added.append("removal_reason")
conn.commit()
conn.close()
if added:
print(f" Added columns to monitoring_locations: {', '.join(added)}")
else:
print(" monitoring_locations already has removed_at + removal_reason — nothing to do.")
if __name__ == "__main__":
print("Running migration: add removed_at + removal_reason to monitoring_locations")
migrate_database()
print("Done.")
+8
View File
@@ -235,6 +235,14 @@ class MonitoringLocation(Base):
# For vibration: {"ground_type": "bedrock", "depth": "10m"}
location_metadata = Column(Text, nullable=True)
# Soft-removal: NULL means active. When set, the location is hidden from
# active surfaces (assign dropdowns, calendar, scheduler, dashboard
# vibration summary) but historical events generated before this time
# still attribute to it. Mirrors the closed-state pattern used by
# UnitAssignment.assigned_until.
removed_at = Column(DateTime, nullable=True)
removal_reason = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+3
View File
@@ -361,6 +361,9 @@ def locations_search(
db.query(MonitoringLocation)
.filter(MonitoringLocation.project_id == project_id)
.filter(MonitoringLocation.location_type == "vibration")
# Don't propose creating assignments at removed locations — they
# were intentionally decommissioned and shouldn't be backfill targets.
.filter(MonitoringLocation.removed_at == None) # noqa: E711
.all()
)
+197 -17
View File
@@ -31,6 +31,7 @@ from backend.models import (
MonitoringSession,
DataFile,
UnitHistory,
ScheduledAction,
)
from backend.templates_config import templates
from backend.utils.timezone import local_to_utc
@@ -138,7 +139,7 @@ async def get_project_locations(
):
"""
Get all monitoring locations for a project.
Returns HTML partial with location list.
Returns HTML partial with location list, split into active + removed.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
@@ -152,10 +153,14 @@ async def get_project_locations(
locations = query.order_by(MonitoringLocation.name).all()
# Enrich with assignment info
locations_data = []
# Enrich with assignment info, splitting active vs removed.
active_data: list = []
removed_data: list = []
for location in locations:
# Get active assignment (active = assigned_until IS NULL)
# Get active assignment (active = assigned_until IS NULL). For
# removed locations this will normally be None because the
# /remove cascade closes them, but check anyway for resilience
# against legacy data.
assignment = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location.id,
@@ -172,17 +177,23 @@ async def get_project_locations(
location_id=location.id
).count()
locations_data.append({
"location": location,
"assignment": assignment,
item = {
"location": location,
"assignment": assignment,
"assigned_unit": assigned_unit,
"session_count": session_count,
})
}
if location.removed_at is None:
active_data.append(item)
else:
removed_data.append(item)
return templates.TemplateResponse("partials/projects/location_list.html", {
"request": request,
"project": project,
"locations": locations_data,
"request": request,
"project": project,
"locations": active_data, # back-compat alias
"active_locations": active_data,
"removed_locations": removed_data,
})
@@ -191,10 +202,15 @@ async def get_project_locations_json(
project_id: str,
db: Session = Depends(get_db),
location_type: Optional[str] = Query(None),
include_removed: bool = Query(False),
):
"""
Get all monitoring locations for a project as JSON.
Used by the schedule modal to populate location dropdown.
Removed locations are filtered out by default (you can't schedule
a new action at a removed location). Pass `include_removed=true`
to get them too — useful for historical / reporting views.
"""
project = db.query(Project).filter_by(id=project_id).first()
if not project:
@@ -205,16 +221,21 @@ async def get_project_locations_json(
if location_type:
query = query.filter_by(location_type=location_type)
if not include_removed:
query = query.filter(MonitoringLocation.removed_at == None) # noqa: E711
locations = query.order_by(MonitoringLocation.name).all()
return [
{
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
"id": loc.id,
"name": loc.name,
"location_type": loc.location_type,
"description": loc.description,
"address": loc.address,
"coordinates": loc.coordinates,
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
"removal_reason": loc.removal_reason,
}
for loc in locations
]
@@ -335,6 +356,165 @@ async def delete_location(
return {"success": True, "message": "Location deleted successfully"}
@router.post("/locations/{location_id}/remove")
async def remove_location(
project_id: str,
location_id: str,
request: Request,
db: Session = Depends(get_db),
):
"""
Soft-remove a monitoring location — mark it as no longer actively
monitored without destroying it.
Use case: a client drops a location from scope mid-project, but the
historical events recorded there should remain attributed. Deleting
would orphan those events; this preserves them.
Cascading side-effects:
1. All active UnitAssignment rows at this location are closed
(assigned_until = effective_date, status = "completed").
Units become available for other deployments.
2. All pending ScheduledAction rows at this location are cancelled
(execution_status = "cancelled").
3. Historical events stay attributed (attribution is window-based;
events with timestamp < effective_date still match the
now-closed assignment windows).
Accepts JSON body:
- effective_date: ISO datetime (optional, defaults to now)
- reason: operator note (optional)
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is not None:
raise HTTPException(
status_code=400,
detail=f"Location is already removed (as of {location.removed_at.isoformat()}).",
)
# Body is optional — POST with no body is fine and means "remove now,
# no reason given."
try:
payload = await request.json()
except Exception:
payload = {}
# Effective date: accept "YYYY-MM-DDTHH:MM" from datetime-local inputs or
# full ISO. Defaults to now if absent/empty.
raw_eff = payload.get("effective_date")
if raw_eff:
try:
effective_date = datetime.fromisoformat(raw_eff)
except (TypeError, ValueError):
raise HTTPException(
status_code=400,
detail=f"Invalid effective_date: {raw_eff!r}",
)
else:
effective_date = datetime.utcnow()
reason = (payload.get("reason") or "").strip() or None
# 1. Close active assignments at this location.
active_assignments = db.query(UnitAssignment).filter(
and_(
UnitAssignment.location_id == location_id,
UnitAssignment.assigned_until == None, # noqa: E711 — SQL NULL
)
).all()
for a in active_assignments:
a.status = "completed"
a.assigned_until = effective_date
_record_assignment_history(
db,
unit_id=a.unit_id,
change_type="assignment_ended",
old_value=location.name,
new_value="location removed",
notes=f"Location '{location.name}' marked as removed"
+ (f"{reason}" if reason else ""),
)
# 2. Cancel pending scheduled actions at this location.
pending_actions = db.query(ScheduledAction).filter(
and_(
ScheduledAction.location_id == location_id,
ScheduledAction.execution_status == "pending",
ScheduledAction.scheduled_time >= effective_date,
)
).all()
for sa in pending_actions:
sa.execution_status = "cancelled"
sa.error_message = (
f"Cancelled: location '{location.name}' marked as removed"
+ (f"{reason}" if reason else "")
)
# 3. Mark the location itself as removed.
location.removed_at = effective_date
location.removal_reason = reason
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' marked as removed",
"effective_date": effective_date.isoformat(),
"assignments_closed": len(active_assignments),
"actions_cancelled": len(pending_actions),
}
@router.post("/locations/{location_id}/restore")
async def restore_location(
project_id: str,
location_id: str,
db: Session = Depends(get_db),
):
"""
Restore a previously-removed monitoring location to active.
Clears `removed_at` and `removal_reason`. Does NOT automatically
re-open the assignments or scheduled actions that were closed when
the location was removed — those stay closed and the operator can
create new ones if they want to resume monitoring.
"""
location = db.query(MonitoringLocation).filter_by(
id=location_id,
project_id=project_id,
).first()
if not location:
raise HTTPException(status_code=404, detail="Location not found")
if location.removed_at is None:
raise HTTPException(
status_code=400,
detail="Location is already active.",
)
location.removed_at = None
location.removal_reason = None
location.updated_at = datetime.utcnow()
db.commit()
return {
"success": True,
"message": f"Location '{location.name}' restored to active",
}
# ============================================================================
# Unit Assignments
# ============================================================================
+16 -7
View File
@@ -318,13 +318,18 @@ async def events_for_unit(
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
return {
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
# Soft-removal indicator so the UI can render a "(removed)"
# badge next to historical attributions whose location is no
# longer actively monitored.
"location_removed_at": (loc.removed_at.isoformat()
if loc and loc.removed_at else None),
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
}
# 2. Fetch all events for this serial in one shot.
@@ -515,6 +520,10 @@ async def vibration_summary_for_project(
"event_count": ec,
"peak_pvs": ev_peak,
"last_event": ev_last,
# Soft-removal state — UI can show a "(removed)" badge in the
# per-location list so operators see at a glance that a row's
# numbers are historical-only.
"removed_at": loc.removed_at.isoformat() if loc.removed_at else None,
})
per_location.sort(key=lambda r: r["event_count"], reverse=True)
+91 -13
View File
@@ -1,7 +1,21 @@
<!-- Project Locations List -->
{% if locations %}
<!-- Project Locations List — split into Active + Removed sections.
Active locations get the full card with Assign/Edit/Delete/Remove
actions. Removed locations get a greyed-out card with a
Removed-on date, optional reason, and a Restore button. -->
{% if not active_locations and not removed_locations %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" 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>
</svg>
<p>No locations added yet</p>
</div>
{% else %}
{# ─── Active locations ─── #}
{% if active_locations %}
<div class="space-y-3">
{% for item in locations %}
{% for item in active_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 hover:border-seismo-orange transition-colors">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
@@ -24,11 +38,13 @@
<div class="flex items-center gap-2">
{% if item.assignment %}
<button onclick="unassignUnit('{{ item.assignment.id }}')" class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
<button onclick="unassignUnit('{{ item.assignment.id }}')"
class="text-xs px-3 py-1 rounded-full bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
Unassign
</button>
{% else %}
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')" class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
<button onclick="openAssignModal('{{ item.location.id }}', '{{ item.location.location_type or 'sound' }}')"
class="text-xs px-3 py-1 rounded-full bg-seismo-orange text-white hover:bg-seismo-navy">
Assign
</button>
{% endif %}
@@ -37,7 +53,14 @@
class="text-xs px-3 py-1 rounded-full bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300">
Edit
</button>
<button onclick="deleteLocation('{{ item.location.id }}')" class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300">
<button onclick="openRemoveLocationModal('{{ item.location.id }}', {{ item.location.name | tojson }})"
class="text-xs px-3 py-1 rounded-full bg-amber-50 text-amber-700 dark:bg-amber-900/20 dark:text-amber-300 hover:bg-amber-100"
title="Mark as no longer actively monitored — preserves historical events">
Remove
</button>
<button onclick="deleteLocation('{{ item.location.id }}')"
class="text-xs px-3 py-1 rounded-full bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300"
title="Permanently delete — only available if there's no history">
Delete
</button>
</div>
@@ -54,11 +77,66 @@
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-gray-500 dark:text-gray-400">
<svg class="w-12 h-12 mx-auto mb-3 opacity-50" 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>
</svg>
<p>No locations added yet</p>
</div>
{% endif %}
{# ─── Removed locations (collapsed by default) ─── #}
{% if removed_locations %}
<details class="mt-6 group" {% if not active_locations %}open{% endif %}>
<summary class="cursor-pointer text-sm font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 select-none list-none">
<span class="inline-flex items-center gap-2">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
Removed locations
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400">{{ removed_locations | length }}</span>
</span>
<p class="ml-6 mt-1 text-xs text-gray-400 dark:text-gray-500">Historical only — events stay attributed, but no new assignments or schedules can be created here.</p>
</summary>
<div class="space-y-3 mt-3">
{% for item in removed_locations %}
<div class="border border-gray-200 dark:border-gray-700 rounded-lg p-4 bg-gray-50 dark:bg-slate-900/30 opacity-75">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 flex-wrap">
<a href="/projects/{{ project.id }}/nrl/{{ item.location.id }}"
class="font-semibold text-gray-700 dark:text-gray-300 hover:text-seismo-orange truncate">
{{ item.location.name }}
</a>
<span class="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold">
Removed
</span>
<span class="text-xs text-gray-500 dark:text-gray-400">
{{ item.location.removed_at.strftime('%Y-%m-%d') if item.location.removed_at else '—' }}
</span>
</div>
{% if item.location.removal_reason %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 italic">"{{ item.location.removal_reason }}"</p>
{% endif %}
{% if item.location.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.description }}</p>
{% endif %}
{% if item.location.address %}
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ item.location.address }}</p>
{% endif %}
</div>
<div class="flex items-center gap-2">
<button onclick="restoreLocation('{{ item.location.id }}', {{ item.location.name | tojson }})"
class="text-xs px-3 py-1 rounded-full bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300 hover:bg-green-200"
title="Restore to active monitoring">
Restore
</button>
</div>
</div>
<div class="mt-3 text-xs text-gray-500 dark:text-gray-400 flex flex-wrap gap-4">
<span>Historical sessions: {{ item.session_count }}</span>
</div>
</div>
{% endfor %}
</div>
</details>
{% endif %}
{% endif %}
@@ -60,6 +60,10 @@
class="flex items-center justify-between py-1.5 px-3 rounded hover:bg-gray-50 dark:hover:bg-slate-700/50 transition-colors">
<span class="text-sm font-medium text-gray-900 dark:text-white truncate">
📍 {{ loc.location_name }}
{% if loc.removed_at %}
<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold align-middle"
title="Location no longer actively monitored — events shown are historical">removed</span>
{% endif %}
</span>
<span class="flex items-center gap-4 text-sm text-gray-600 dark:text-gray-400 whitespace-nowrap ml-3">
<span>{{ "{:,}".format(loc.event_count) }} event{{ '' if loc.event_count == 1 else 's' }}</span>
+143
View File
@@ -778,6 +778,61 @@
</div>
</div>
<!-- Remove Location Confirmation Modal —
Soft-removal: preserves historical events, closes active assignments,
cancels pending scheduled actions. Distinct from Delete (which is
permanent and only allowed when there's no history). -->
<div id="remove-location-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-amber-100 dark:bg-amber-900/40 rounded-lg">
<svg class="w-6 h-6 text-amber-600 dark:text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2a4 4 0 014-4h6m0 0l-3-3m3 3l-3 3M5 7h8a2 2 0 012 2v10a2 2 0 01-2 2H5a2 2 0 01-2-2V9a2 2 0 012-2z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">Remove location</h3>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
Mark <span id="remove-location-name" class="font-semibold text-gray-900 dark:text-white"></span> as no longer actively monitored.
</p>
<ul class="text-xs text-gray-500 dark:text-gray-400 mb-4 space-y-1 ml-4 list-disc">
<li>Closes any active unit assignment at this location</li>
<li>Cancels pending scheduled actions at this location</li>
<li>Historical events stay attributed (visible in reports + event lists)</li>
<li>Can be restored later if needed</li>
</ul>
<input type="hidden" id="remove-location-id">
<div class="mb-3">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Effective date</label>
<input type="datetime-local" id="remove-location-effective"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
<p class="text-[10px] text-gray-500 dark:text-gray-400 mt-1">Defaults to now. Backdate if the location was physically removed earlier.</p>
</div>
<div class="mb-4">
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">Reason (optional)</label>
<input type="text" id="remove-location-reason" maxlength="200"
placeholder="e.g. client dropped from scope"
class="w-full px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm">
</div>
<div id="remove-location-error" class="hidden text-sm text-red-600 mb-3"></div>
<div class="flex justify-end gap-2">
<button onclick="closeRemoveLocationModal()"
class="px-4 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700">
Cancel
</button>
<button onclick="confirmRemoveLocation()"
class="px-4 py-1.5 text-sm bg-amber-600 hover:bg-amber-700 text-white rounded-lg font-medium">
Remove
</button>
</div>
</div>
</div>
<!-- Delete Project Confirmation Modal -->
<div id="delete-project-modal" class="hidden fixed inset-0 bg-black bg-opacity-60 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-md m-4 p-6">
@@ -1213,6 +1268,94 @@ async function deleteLocation(locationId) {
}
}
// ── Remove / Restore location ────────────────────────────────────────
// Soft-removal: marks a location as no longer actively monitored without
// destroying it. Historical events stay attributed; active assignments
// are auto-closed and pending scheduled actions are auto-cancelled.
function openRemoveLocationModal(locationId, locationName) {
document.getElementById('remove-location-id').value = locationId;
document.getElementById('remove-location-name').textContent = locationName;
document.getElementById('remove-location-reason').value = '';
// Default effective_date to "now" in local datetime-input format.
const now = new Date();
const tzOffsetMin = now.getTimezoneOffset();
const local = new Date(now.getTime() - tzOffsetMin * 60000);
document.getElementById('remove-location-effective').value =
local.toISOString().slice(0, 16);
document.getElementById('remove-location-error').classList.add('hidden');
document.getElementById('remove-location-modal').classList.remove('hidden');
}
function closeRemoveLocationModal() {
document.getElementById('remove-location-modal').classList.add('hidden');
}
async function confirmRemoveLocation() {
const locationId = document.getElementById('remove-location-id').value;
const reason = document.getElementById('remove-location-reason').value.trim();
const effective = document.getElementById('remove-location-effective').value;
const errBox = document.getElementById('remove-location-error');
errBox.classList.add('hidden');
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/remove`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
reason: reason || null,
effective_date: effective || null,
}),
}
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove location');
}
const result = await response.json();
closeRemoveLocationModal();
refreshLocationLists();
refreshProjectDashboard();
// Lightweight feedback — the UI refresh already shows the location
// moving to the Removed section, but a toast confirms the cascade.
if (typeof showToast === 'function') {
const bits = [];
if (result.assignments_closed) bits.push(`${result.assignments_closed} assignment(s) closed`);
if (result.actions_cancelled) bits.push(`${result.actions_cancelled} action(s) cancelled`);
const tail = bits.length ? ` (${bits.join(', ')})` : '';
showToast(`Location removed${tail}`, 'success');
}
} catch (err) {
errBox.textContent = err.message || 'Failed to remove location.';
errBox.classList.remove('hidden');
}
}
async function restoreLocation(locationId, locationName) {
if (!confirm(`Restore "${locationName}" to active monitoring?\n\nNote: previously-closed assignments are NOT automatically re-opened — you'll need to re-assign units if you want to resume monitoring.`)) {
return;
}
try {
const response = await fetch(
`/api/projects/${projectId}/locations/${locationId}/restore`,
{ method: 'POST' }
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to restore location');
}
refreshLocationLists();
refreshProjectDashboard();
if (typeof showToast === 'function') {
showToast(`"${locationName}" restored to active`, 'success');
}
} catch (err) {
alert(err.message || 'Failed to restore location.');
}
}
// Assign modal functions
function openAssignModal(locationId, locationType) {
const safeType = locationType || 'sound';
+6 -1
View File
@@ -2286,12 +2286,17 @@ function _ueAttrCell(ev) {
if (a) {
const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—');
// If the attributed location has since been soft-removed, badge
// it so operators see at a glance this is historical attribution.
const removedBadge = a.location_removed_at
? '<span class="ml-1 text-[10px] uppercase tracking-wider px-1 py-0.5 rounded bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-300 font-semibold" title="Location no longer actively monitored">removed</span>'
: '';
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
onclick="event.stopPropagation()"
class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel} → ${locLabel}">
📍 ${locLabel}
</a>
</a>${removedBadge}
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
}
const n = ev.nearest_assignment;