Files
terra-view/templates/vibration_location_detail.html
T
serversdown 2905a327be admin_events: wire shared event-detail modal into the page
/admin/events previously rendered events as a flat table with no
detail view — admins had to copy an event ID and open the standalone
SFM webapp on port 8200 to see the chart, PDF, or sidecar metadata.

Adds:
- {% include 'partials/event_detail_modal.html' %} + script tag at
  the bottom of the page (mirrors the pattern in /sfm, /unit/{id},
  /projects/.../nrl/...).
- onclick on the table <tr> opens the modal via showEventDetail(id).
- event.stopPropagation() on the checkbox <td> so selection clicks
  don't also open the modal.
- Listener for the 'sfm-event-review-saved' CustomEvent fired by
  event-modal.js — reloads the table so any FT-flag changes made in
  the modal's review form land on the row without a full reload.

Also propagates the same listener pattern to the three other pages
that already include the modal (sfm.html, unit_detail.html,
vibration_location_detail.html) — they call their respective
loadEvents / loadUnitEvents / loadLocationEvents on the fire.  Keeps
the refresh-on-save UX consistent across every page that hosts the
modal.

Phase 1 of the SFM-into-Terra-View integration is now complete:
chart, PDF preview, .TXT download, review form, and per-unit + admin
event browsing are all native in Terra-View.  The standalone SFM
webapp on port 8200 remains as a diagnostic fallback but operators
no longer need to bounce to it for routine workflows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 01:06:44 +00:00

1002 lines
52 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ location.name }} - Monitoring Location{% endblock %}
{% block content %}
<!-- Breadcrumb Navigation -->
<div class="mb-6">
<nav class="flex items-center space-x-2 text-sm">
<a href="/projects" class="text-seismo-orange hover:text-seismo-navy flex items-center">
<svg class="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
Projects
</a>
<svg class="w-4 h-4 text-gray-400" 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"></path>
</svg>
<a href="/projects/{{ project_id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ project.name }}
</a>
<svg class="w-4 h-4 text-gray-400" 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"></path>
</svg>
<span class="text-gray-900 dark:text-white font-medium">{{ location.name }}</span>
</nav>
</div>
<!-- Header -->
<div class="mb-8">
<div class="flex justify-between items-start">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center">
<svg class="w-8 h-8 mr-3 text-seismo-orange" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
</svg>
{{ location.name }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-1">
Monitoring Location &bull; {{ project.name }}
</p>
</div>
<div class="flex gap-2">
{% if assigned_unit %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
Unit Assigned
</span>
{% else %}
<span class="px-3 py-1 rounded-full text-sm font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">
No Unit Assigned
</span>
{% endif %}
</div>
</div>
</div>
<!-- Tab Navigation -->
<div class="mb-6 border-b border-gray-200 dark:border-gray-700">
<nav class="flex space-x-6">
<button onclick="switchTab('overview')"
data-tab="overview"
class="tab-button px-4 py-3 border-b-2 font-medium text-sm transition-colors border-seismo-orange text-seismo-orange">
Overview
</button>
<button onclick="switchTab('events')"
data-tab="events"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Events
</button>
<button onclick="switchTab('settings')"
data-tab="settings"
class="tab-button px-4 py-3 border-b-2 border-transparent font-medium text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:border-gray-300 dark:hover:border-gray-600 transition-colors">
Settings
</button>
</nav>
</div>
<!-- Tab Content -->
<div id="tab-content">
<!-- Overview Tab -->
<div id="overview-tab" class="tab-panel">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Location Details Card -->
<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-4">Location Details</h2>
<div class="space-y-4">
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Name</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">{{ location.name }}</div>
</div>
{% if location.description %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Description</div>
<div class="text-gray-900 dark:text-white">{{ location.description }}</div>
</div>
{% endif %}
{% if location.address %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Address</div>
<div class="text-gray-900 dark:text-white">{{ location.address }}</div>
</div>
{% endif %}
{% if location.coordinates %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Coordinates</div>
<div class="text-gray-900 dark:text-white font-mono text-sm">{{ location.coordinates }}</div>
</div>
{% endif %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Created</div>
<div class="text-gray-900 dark:text-white">{{ location.created_at|local_datetime if location.created_at else 'N/A' }}</div>
</div>
</div>
</div>
<!-- Assignment Card -->
<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-4">Unit Assignment</h2>
{% if assigned_unit %}
<div class="space-y-4">
<!-- Seismograph row -->
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Seismograph</div>
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_unit.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_unit.id }}
</a>
</div>
{% if assigned_unit.unit_type %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">{{ assigned_unit.unit_type }}</div>
{% endif %}
</div>
<!-- Modem row -->
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Modem</div>
{% if assigned_modem %}
<div class="text-lg font-medium text-gray-900 dark:text-white">
<a href="/unit/{{ assigned_modem.id }}" class="text-seismo-orange hover:text-seismo-navy">
{{ assigned_modem.id }}
</a>
</div>
{% if assigned_modem.hardware_model or assigned_modem.ip_address %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
{{ assigned_modem.hardware_model or '' }}{% if assigned_modem.hardware_model and assigned_modem.ip_address %} &bull; {% endif %}{{ assigned_modem.ip_address or '' }}
</div>
{% endif %}
{% else %}
<div class="text-sm text-gray-400 dark:text-gray-500 italic">No modem paired</div>
{% endif %}
</div>
{% if assignment %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Assigned Since</div>
<div class="text-gray-900 dark:text-white">{{ assignment.assigned_at|local_datetime if assignment.assigned_at else 'N/A' }}</div>
</div>
{% if assignment.notes %}
<div>
<div class="text-sm text-gray-600 dark:text-gray-400">Notes</div>
<div class="text-gray-900 dark:text-white text-sm">{{ assignment.notes }}</div>
</div>
{% endif %}
{% endif %}
<div class="pt-2 flex gap-2 flex-wrap">
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors text-sm">
Swap Unit / Modem
</button>
<button onclick="unassignUnit('{{ assignment.id }}')"
class="px-4 py-2 bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-lg hover:bg-amber-200 dark:hover:bg-amber-900/50 transition-colors text-sm">
Unassign
</button>
</div>
</div>
{% else %}
<div class="text-center py-8">
<svg class="w-16 h-16 mx-auto mb-4 text-gray-400" 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 class="text-gray-500 dark:text-gray-400 mb-4">No unit currently assigned</p>
<button onclick="openSwapModal()"
class="px-4 py-2 bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy transition-colors">
Assign a Unit
</button>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Events Tab -->
<div id="events-tab" class="tab-panel hidden">
<!-- Summary stats -->
<div id="events-stats" class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
<span id="ev-stat-count" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Overall Peak</span>
<span id="ev-stat-peak" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span id="ev-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
<span id="ev-stat-last" class="text-lg font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 flex flex-col">
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
<span id="ev-stat-ft" class="text-3xl font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
</div>
<!-- Assignments used (transparency: which seismographs contributed events) -->
<div id="events-assignments-card" class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6 hidden">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">Seismographs deployed at this location</h3>
<span id="ev-assignments-count" class="text-xs text-gray-500 dark:text-gray-400"></span>
</div>
<div id="ev-assignments-list" class="divide-y divide-gray-200 dark:divide-gray-700"></div>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-3">
<span class="inline-block w-4 text-center"></span>
Click the pencil to backdate a deployment so historical events get attributed to this location.
</p>
</div>
<!-- Edit-assignment modal -->
<div id="assignment-edit-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 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">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">Edit Deployment Window</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
<span id="ae-unit-label" class="font-mono text-seismo-orange"></span>
</p>
</div>
<button onclick="closeAssignmentEditModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="assignment-edit-form" class="p-6 space-y-4">
<input type="hidden" id="ae-assignment-id">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Assigned From</label>
<input type="datetime-local" id="ae-assigned-at" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Assigned Until
<span class="text-xs text-gray-500 ml-1">(leave blank if still active)</span>
</label>
<input type="datetime-local" id="ae-assigned-until"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="ae-notes" rows="2" placeholder="Optional — e.g. 'backdated to reflect physical install date'"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white"></textarea>
</div>
<div id="ae-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeAssignmentEditModal()"
class="px-4 py-2 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 type="submit" id="ae-submit-btn"
class="px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save
</button>
</div>
</form>
</div>
</div>
<!-- Filters -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4 mb-6">
<div class="flex flex-wrap items-end gap-3">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ev-filter-from" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">To</label>
<input type="datetime-local" id="ev-filter-to" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">False Triggers</label>
<select id="ev-filter-ft" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="">All Events</option>
<option value="false">Real Events Only</option>
<option value="true">False Triggers Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Limit</label>
<select id="ev-filter-limit" onchange="loadLocationEvents()"
class="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-slate-700 text-gray-900 dark:text-white">
<option value="100">100</option>
<option value="250">250</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
</select>
</div>
<button onclick="clearEventFilters()"
class="px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white">
Clear
</button>
<button onclick="loadLocationEvents()"
class="ml-auto px-4 py-1.5 text-sm bg-seismo-orange text-white rounded-lg hover:bg-seismo-navy">
↻ Refresh
</button>
</div>
</div>
<!-- Event table -->
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg overflow-hidden">
<div id="events-container" class="overflow-x-auto">
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>
Loading events…
</div>
</div>
</div>
</div>
<!-- Settings Tab -->
<div id="settings-tab" class="tab-panel hidden">
<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-6">Location Settings</h2>
<form id="location-settings-form" class="space-y-6">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Name</label>
<input type="text" id="settings-name" value="{{ location.name }}"
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" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
<textarea id="settings-description" rows="3"
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">{{ location.description or '' }}</textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Address</label>
<input type="text" id="settings-address" value="{{ location.address or '' }}"
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">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Coordinates</label>
<input type="text" id="settings-coordinates" value="{{ location.coordinates or '' }}"
placeholder="40.7128,-74.0060"
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">
<p class="text-xs text-gray-500 mt-1">Format: latitude,longitude</p>
</div>
<div id="settings-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="window.location.href='/projects/{{ project_id }}'"
class="px-6 py-2 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 type="submit"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Save Changes
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Assign / Swap Modal -->
<div id="swap-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<div class="p-6 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between">
<div>
<h2 id="swap-modal-title" class="text-2xl font-bold text-gray-900 dark:text-white">Assign Unit</h2>
<p class="text-gray-600 dark:text-gray-400 mt-1">Select a seismograph and optionally a modem for this location</p>
</div>
<button onclick="closeSwapModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form id="swap-form" class="p-6 space-y-5">
<!-- Seismograph picker -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Seismograph <span class="text-red-500">*</span>
</label>
<input id="swap-unit-search" type="text" placeholder="Search by ID or model..."
oninput="filterSwapList('unit')"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
<div id="swap-unit-list"
class="max-h-48 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
</div>
<input type="hidden" id="swap-unit-id" name="unit_id" required>
<p id="swap-units-empty" class="hidden text-xs text-gray-500 mt-1">No available seismographs.</p>
</div>
<!-- Modem picker -->
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Modem <span class="text-xs text-gray-400">(optional)</span>
</label>
<input id="swap-modem-search" type="text" placeholder="Search by ID, model, or IP..."
oninput="filterSwapList('modem')"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white text-sm mb-2 focus:ring-2 focus:ring-seismo-orange">
<div id="swap-modem-list"
class="max-h-40 overflow-y-auto rounded-lg border border-gray-200 dark:border-gray-700 divide-y divide-gray-100 dark:divide-gray-700 bg-white dark:bg-gray-700">
<div class="px-3 py-6 text-center text-sm text-gray-400">Loading...</div>
</div>
<input type="hidden" id="swap-modem-id" name="modem_id">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
<textarea id="swap-notes" name="notes" rows="2"
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"></textarea>
</div>
<div id="swap-error" class="hidden text-sm text-red-600"></div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="closeSwapModal()"
class="px-6 py-2 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 type="submit" id="swap-submit-btn"
class="px-6 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg font-medium">
Assign
</button>
</div>
</form>
</div>
</div>
<script>
const projectId = "{{ project_id }}";
const locationId = "{{ location_id }}";
const hasAssignment = {{ 'true' if assigned_unit else 'false' }};
// Tab switching
function switchTab(tabName) {
document.querySelectorAll('.tab-panel').forEach(panel => {
panel.classList.add('hidden');
});
document.querySelectorAll('.tab-button').forEach(button => {
button.classList.remove('border-seismo-orange', 'text-seismo-orange');
button.classList.add('border-transparent', 'text-gray-600', 'dark:text-gray-400');
});
const panel = document.getElementById(`${tabName}-tab`);
if (panel) panel.classList.remove('hidden');
const button = document.querySelector(`[data-tab="${tabName}"]`);
if (button) {
button.classList.remove('border-transparent', 'text-gray-600', 'dark:text-gray-400');
button.classList.add('border-seismo-orange', 'text-seismo-orange');
}
// Lazy-load Events tab on first visit (or whenever it's reopened).
if (tabName === 'events' && !_eventsLoaded) {
loadLocationEvents();
}
}
// ── Events tab ───────────────────────────────────────────────────────────────
let _eventsLoaded = false;
function clearEventFilters() {
document.getElementById('ev-filter-from').value = '';
document.getElementById('ev-filter-to').value = '';
document.getElementById('ev-filter-ft').value = '';
document.getElementById('ev-filter-limit').value = '500';
loadLocationEvents();
}
async function loadLocationEvents() {
const container = document.getElementById('events-container');
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
const params = new URLSearchParams();
const from = document.getElementById('ev-filter-from').value;
const to = document.getElementById('ev-filter-to').value;
const ft = document.getElementById('ev-filter-ft').value;
const limit = document.getElementById('ev-filter-limit').value;
if (from) params.set('from_dt', from.replace('T', ' '));
if (to) params.set('to_dt', to.replace('T', ' '));
if (ft) params.set('false_trigger', ft);
params.set('limit', limit);
try {
const r = await fetch(`/api/projects/${projectId}/locations/${locationId}/events?${params.toString()}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
_eventsLoaded = true;
renderEventStats(d.stats);
renderAssignmentsUsed(d.assignments_used);
renderEventTable(d.events, d.count, container);
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
}
}
function renderEventStats(stats) {
const s = stats || {};
document.getElementById('ev-stat-count').textContent = (s.event_count ?? 0).toLocaleString();
document.getElementById('ev-stat-ft').textContent = (s.false_trigger_count ?? 0).toLocaleString();
if (s.peak_pvs != null) {
document.getElementById('ev-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
const who = s.peak_pvs_serial || '';
document.getElementById('ev-stat-peak-when').textContent = [when, who].filter(Boolean).join(' · ') || '—';
} else {
document.getElementById('ev-stat-peak').textContent = '—';
document.getElementById('ev-stat-peak-when').textContent = '—';
}
if (s.last_event) {
const dt = s.last_event.slice(0, 19).replace('T', ' ');
document.getElementById('ev-stat-last').textContent = dt;
} else {
document.getElementById('ev-stat-last').textContent = '—';
}
}
function renderAssignmentsUsed(assignments) {
const card = document.getElementById('events-assignments-card');
const listEl = document.getElementById('ev-assignments-list');
const countEl = document.getElementById('ev-assignments-count');
if (!assignments || assignments.length === 0) {
card.classList.add('hidden');
return;
}
card.classList.remove('hidden');
countEl.textContent = `${assignments.length} assignment${assignments.length === 1 ? '' : 's'}`;
listEl.innerHTML = assignments.map(a => {
const start = a.assigned_at ? a.assigned_at.slice(0, 10) : '?';
const end = a.assigned_until ? a.assigned_until.slice(0, 10) : 'present';
const isActive = !a.assigned_until;
const badge = isActive
? '<span class="ml-2 px-2 py-0.5 rounded text-xs bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">active</span>'
: '';
const editAttr = encodeURIComponent(JSON.stringify({
id: a.assignment_id,
unit_id: a.unit_id,
assigned_at: a.assigned_at,
assigned_until: a.assigned_until,
}));
return `<div class="py-2 flex items-center justify-between gap-3">
<div class="flex items-center gap-2 min-w-0">
<a href="/unit/${esc(a.unit_id)}" class="font-mono font-semibold text-seismo-orange hover:text-seismo-navy">${esc(a.unit_id)}</a>
${badge}
<span class="text-sm text-gray-600 dark:text-gray-400">${start}${end}</span>
<button type="button"
onclick="openAssignmentEditModal('${editAttr}')"
title="Edit deployment dates"
class="text-gray-400 hover:text-seismo-orange transition-colors p-1">
<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="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"></path>
</svg>
</button>
<button type="button"
onclick="deleteAssignment('${esc(a.assignment_id)}', '${esc(a.unit_id)}', '${start}${end}')"
title="Delete this assignment record (for mis-clicks / duplicates)"
class="text-gray-400 hover:text-red-600 transition-colors p-1">
<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 7h22M9 7V4a1 1 0 011-1h4a1 1 0 011 1v3"/>
</svg>
</button>
</div>
<span class="text-sm text-gray-700 dark:text-gray-300 whitespace-nowrap">${(a.events_in_window || 0).toLocaleString()} event${a.events_in_window === 1 ? '' : 's'}</span>
</div>`;
}).join('');
}
// ── Assignment-edit modal ───────────────────────────────────────────────────
function _isoToInputValue(iso) {
// Convert "2026-04-14T02:19:27" (or "2026-04-14 02:19:27") to "2026-04-14T02:19" for datetime-local input.
if (!iso) return '';
const cleaned = iso.replace(' ', 'T');
return cleaned.slice(0, 16);
}
function openAssignmentEditModal(encodedJson) {
const data = JSON.parse(decodeURIComponent(encodedJson));
document.getElementById('ae-assignment-id').value = data.id;
document.getElementById('ae-unit-label').textContent = data.unit_id;
document.getElementById('ae-assigned-at').value = _isoToInputValue(data.assigned_at);
document.getElementById('ae-assigned-until').value = _isoToInputValue(data.assigned_until);
document.getElementById('ae-notes').value = '';
document.getElementById('ae-error').classList.add('hidden');
document.getElementById('assignment-edit-modal').classList.remove('hidden');
}
async function deleteAssignment(assignmentId, unitId, windowLabel) {
// For mis-clicks / accidental duplicate assignments. Backend refuses
// if there's a real recording session inside the window — those should
// go through Edit or Unassign instead.
const msg = `Delete this assignment?\n\n`
+ `Unit: ${unitId}\n`
+ `Window: ${windowLabel}\n\n`
+ `This is for assignments created in error. Events that fell `
+ `in this window will become unattributed. The unit's deployment `
+ `history will log the deletion for audit.\n\n`
+ `If the unit actually was deployed here, use Edit or Unassign instead.`;
if (!confirm(msg)) return;
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'DELETE',
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
await loadLocationEvents(); // Refresh stats + table without this assignment.
} catch (err) {
alert(err.message || 'Failed to delete assignment.');
}
}
function closeAssignmentEditModal() {
document.getElementById('assignment-edit-modal').classList.add('hidden');
}
document.getElementById('assignment-edit-form').addEventListener('submit', async function(e) {
e.preventDefault();
const errEl = document.getElementById('ae-error');
errEl.classList.add('hidden');
const assignmentId = document.getElementById('ae-assignment-id').value;
const assignedAt = document.getElementById('ae-assigned-at').value;
const assignedUntil = document.getElementById('ae-assigned-until').value;
const notes = document.getElementById('ae-notes').value.trim();
if (!assignedAt) {
errEl.textContent = 'Assigned From is required.';
errEl.classList.remove('hidden');
return;
}
const payload = { assigned_at: assignedAt };
payload.assigned_until = assignedUntil || null;
if (notes) payload.notes = notes;
const btn = document.getElementById('ae-submit-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const r = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}`, {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload),
});
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
closeAssignmentEditModal();
await loadLocationEvents(); // Refresh stats + table with new window.
} catch (err) {
errEl.textContent = err.message || 'Failed to update assignment.';
errEl.classList.remove('hidden');
} finally {
btn.disabled = false; btn.textContent = 'Save';
}
});
document.getElementById('assignment-edit-modal').addEventListener('click', function(e) {
if (e.target === this) closeAssignmentEditModal();
});
function renderEventTable(events, total, container) {
if (!events || events.length === 0) {
const haveAssignments = !document.getElementById('events-assignments-card').classList.contains('hidden');
const msg = haveAssignments
? 'No events recorded for the assignments above within the current filter.'
: 'No seismographs have been assigned to this location yet. Assign one to start collecting events.';
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = fmtPPV(ev.tran_ppv);
const vert = fmtPPV(ev.vert_ppv);
const lng = fmtPPV(ev.long_ppv);
const pvs = fmtPPV(ev.peak_vector_sum);
const mic = ev.mic_ppv != null ? ev.mic_ppv.toFixed(3) : '—';
const ft = ev.false_trigger
? '<span class="px-2 py-0.5 rounded text-xs bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300">FT</span>'
: '';
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail('${esc(ev.id)}')">
<td class="px-4 py-2.5 text-sm text-gray-900 dark:text-white whitespace-nowrap">${ts}</td>
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${esc(ev.serial)}</a>
</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.tran_ppv)}">${tran}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${ppvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${ppvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm font-mono text-gray-600 dark:text-gray-400">${mic}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3">Showing ${events.length} of ${total.toLocaleString()} events</div>
<table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
function fmtPPV(v) {
if (v == null) return '—';
return v.toFixed(4);
}
function ppvClass(v) {
if (v == null) return 'text-gray-400';
if (v < 0.5) return 'text-green-600 dark:text-green-400';
if (v < 2.0) return 'text-amber-600 dark:text-amber-400';
return 'text-red-600 dark:text-red-400 font-semibold';
}
function esc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Location settings form submission
document.getElementById('location-settings-form').addEventListener('submit', async function(e) {
e.preventDefault();
const payload = {
name: document.getElementById('settings-name').value.trim(),
description: document.getElementById('settings-description').value.trim() || null,
address: document.getElementById('settings-address').value.trim() || null,
coordinates: document.getElementById('settings-coordinates').value.trim() || null,
};
try {
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}`, {
method: 'PUT',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to update location');
}
window.location.reload();
} catch (err) {
const errorEl = document.getElementById('settings-error');
errorEl.textContent = err.message || 'Failed to update location.';
errorEl.classList.remove('hidden');
}
});
// Swap / Assign modal
async function openSwapModal() {
document.getElementById('swap-modal').classList.remove('hidden');
document.getElementById('swap-modal-title').textContent = hasAssignment ? 'Swap Unit / Modem' : 'Assign Unit';
document.getElementById('swap-submit-btn').textContent = hasAssignment ? 'Swap' : 'Assign';
document.getElementById('swap-error').classList.add('hidden');
document.getElementById('swap-notes').value = '';
document.getElementById('swap-unit-search').value = '';
document.getElementById('swap-modem-search').value = '';
document.getElementById('swap-unit-id').value = '';
document.getElementById('swap-modem-id').value = '';
await Promise.all([loadSwapUnits(), loadSwapModems()]);
}
function closeSwapModal() {
document.getElementById('swap-modal').classList.add('hidden');
}
let _swapUnits = [];
let _swapModems = [];
function _fuzzyMatch(query, text) {
if (!query) return true;
const q = query.toLowerCase();
const t = text.toLowerCase();
// Substring match first (fast), then character-sequence fuzzy
if (t.includes(q)) return true;
let qi = 0;
for (let i = 0; i < t.length && qi < q.length; i++) {
if (t[i] === q[qi]) qi++;
}
return qi === q.length;
}
function _renderSwapList(type, items, selectedId, noSelectionLabel) {
const listEl = document.getElementById(`swap-${type}-list`);
if (!items.length) {
listEl.innerHTML = `<div class="px-3 py-4 text-center text-sm text-gray-400">No results</div>`;
return;
}
listEl.innerHTML = items.map(item => {
const isSelected = item.value === selectedId;
return `<button type="button"
onclick="selectSwapItem('${type}', '${item.value}', this)"
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${isSelected ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
<div>
<span class="font-medium text-gray-900 dark:text-white text-sm">${item.primary}</span>
${item.secondary ? `<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">${item.secondary}</span>` : ''}
</div>
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${isSelected ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
</button>`;
}).join('');
}
function selectSwapItem(type, value, btn) {
document.getElementById(`swap-${type}-id`).value = value;
// Update visual state
const list = document.getElementById(`swap-${type}-list`);
list.querySelectorAll('button').forEach(b => {
b.classList.remove('bg-orange-50', 'dark:bg-orange-900/20');
b.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-gray-400 dark:border-gray-500';
});
btn.classList.add('bg-orange-50', 'dark:bg-orange-900/20');
btn.querySelector('.rounded-full').className = 'w-4 h-4 rounded-full border-2 shrink-0 ml-3 border-seismo-orange bg-seismo-orange';
}
function filterSwapList(type) {
const query = document.getElementById(`swap-${type}-search`).value;
const items = type === 'unit' ? _swapUnits : _swapModems;
const selectedId = document.getElementById(`swap-${type}-id`).value;
const filtered = items.filter(item =>
_fuzzyMatch(query, item.primary + ' ' + (item.secondary || '') + ' ' + (item.searchText || ''))
);
_renderSwapList(type, filtered, selectedId, type === 'modem' ? 'No modem' : null);
// Re-add "No modem" option for modems
if (type === 'modem') {
const listEl = document.getElementById('swap-modem-list');
const noModemBtn = `<button type="button"
onclick="selectSwapItem('modem', '', this)"
class="w-full text-left px-3 py-2.5 flex items-center justify-between hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors ${!selectedId ? 'bg-orange-50 dark:bg-orange-900/20' : ''}">
<span class="text-sm text-gray-500 dark:text-gray-400 italic">No modem</span>
<div class="w-4 h-4 rounded-full border-2 shrink-0 ml-3 ${!selectedId ? 'border-seismo-orange bg-seismo-orange' : 'border-gray-400 dark:border-gray-500'}"></div>
</button>`;
listEl.insertAdjacentHTML('afterbegin', noModemBtn);
}
}
async function loadSwapUnits() {
try {
const response = await fetch(`/api/projects/${projectId}/available-units?location_type=vibration`);
if (!response.ok) throw new Error('Failed to load units');
const data = await response.json();
if (!data.length) {
document.getElementById('swap-units-empty').classList.remove('hidden');
document.getElementById('swap-unit-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">No available seismographs.</div>';
return;
}
document.getElementById('swap-units-empty').classList.add('hidden');
_swapUnits = data.map(u => ({
value: u.id,
primary: u.id,
secondary: [u.model, u.location].filter(Boolean).join(' — '),
searchText: u.model + ' ' + u.location,
}));
_renderSwapList('unit', _swapUnits, document.getElementById('swap-unit-id').value);
} catch (err) {
document.getElementById('swap-error').textContent = 'Failed to load seismographs.';
document.getElementById('swap-error').classList.remove('hidden');
}
}
async function loadSwapModems() {
try {
const response = await fetch(`/api/projects/${projectId}/available-modems`);
if (!response.ok) throw new Error('Failed to load modems');
const data = await response.json();
_swapModems = data.map(m => ({
value: m.id,
primary: m.id,
secondary: [m.hardware_model, m.ip_address].filter(Boolean).join(' — '),
searchText: (m.hardware_model || '') + ' ' + (m.ip_address || ''),
}));
filterSwapList('modem'); // renders with "No modem" prepended
} catch (err) {
console.warn('Failed to load modems:', err);
document.getElementById('swap-modem-list').innerHTML = '<div class="px-3 py-4 text-center text-sm text-gray-400">Could not load modems.</div>';
}
}
document.getElementById('swap-form').addEventListener('submit', async function(e) {
e.preventDefault();
const unitId = document.getElementById('swap-unit-id').value;
const modemId = document.getElementById('swap-modem-id').value;
const notes = document.getElementById('swap-notes').value.trim();
if (!unitId) {
document.getElementById('swap-error').textContent = 'Please select a seismograph.';
document.getElementById('swap-error').classList.remove('hidden');
return;
}
try {
const formData = new FormData();
formData.append('unit_id', unitId);
if (modemId) formData.append('modem_id', modemId);
if (notes) formData.append('notes', notes);
const response = await fetch(`/api/projects/${projectId}/locations/${locationId}/swap`, {
method: 'POST',
body: formData
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to assign unit');
}
window.location.reload();
} catch (err) {
document.getElementById('swap-error').textContent = err.message || 'Failed to assign unit.';
document.getElementById('swap-error').classList.remove('hidden');
}
});
async function unassignUnit(assignmentId) {
if (!confirm('Unassign this unit from the location?')) return;
try {
const response = await fetch(`/api/projects/${projectId}/assignments/${assignmentId}/unassign`, {
method: 'POST'
});
if (!response.ok) {
const data = await response.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to unassign unit');
}
window.location.reload();
} catch (err) {
alert(err.message || 'Failed to unassign unit.');
}
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') closeSwapModal();
});
document.getElementById('swap-modal')?.addEventListener('click', function(e) {
if (e.target === this) closeSwapModal();
});
</script>
{# Shared event-detail modal (clicking an event row in the Events tab) #}
{% include 'partials/event_detail_modal.html' %}
<script src="/static/event-modal.js"></script>
<script>
// Refresh the location's events table when the modal's review form saves.
window.addEventListener('sfm-event-review-saved', () => {
if (typeof loadLocationEvents === 'function') loadLocationEvents();
});
</script>
{% endblock %}