63bd6ad8a2
Phase 3 of the SFM integration. Adds a "Project-wide vibration events"
KPI card to the Vibration tab of every project detail page, summarising
event activity across all of that project's vibration MonitoringLocations.
Backend:
- backend/services/sfm_events.py: vibration_summary_for_project() helper.
Concurrently fans out events_for_location() across every vibration
location in the project; aggregates total events, peak PVS (with the
location it occurred at), last-event timestamp, false-trigger count;
and produces a per-location breakdown sorted by event count.
- backend/routers/project_locations.py: new GET /api/projects/{p}/
vibration_summary endpoint returning an HTML partial (HTMX-friendly,
matches the locations-list HTMX pattern already used on this page).
Frontend:
- templates/partials/projects/vibration_summary.html: new partial with
four KPI tiles (total, peak PVS + linked location + date, last event,
false triggers) and a "Top locations by activity" mini-list showing
the top 5 by event count. Empty-state copy when the project has no
vibration locations yet.
- templates/projects/detail.html: HTMX-load the new summary above the
locations list inside the Vibration tab.
Verified against terra-view-alpha: 24 events across "Loc 1 - 78 poop
street", peak PVS 14.1351 in/s.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
77 lines
4.4 KiB
HTML
77 lines
4.4 KiB
HTML
{# Project-wide vibration events roll-up. Loaded via HTMX. #}
|
|
{% if summary.vibration_location_count == 0 %}
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-4">
|
|
<div class="text-sm text-gray-500 dark:text-gray-400">
|
|
No vibration monitoring locations yet. Add one to start collecting events.
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-5">
|
|
<div class="flex items-center justify-between mb-3">
|
|
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300 uppercase tracking-wider">
|
|
Project-wide vibration events
|
|
</h3>
|
|
<span class="text-xs text-gray-500 dark:text-gray-400">
|
|
across {{ summary.vibration_location_count }} location{{ '' if summary.vibration_location_count == 1 else 's' }}
|
|
</span>
|
|
</div>
|
|
|
|
<!-- KPI tiles -->
|
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Total Events</span>
|
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "{:,}".format(summary.total_events) }}</span>
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Peak PVS</span>
|
|
{% if summary.peak_pvs is not none %}
|
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">{{ "%.4f"|format(summary.peak_pvs) }} <span class="text-sm font-normal">in/s</span></span>
|
|
<a href="/projects/{{ summary.project_id }}/nrl/{{ summary.peak_pvs_location_id }}"
|
|
class="text-xs text-seismo-orange hover:text-seismo-navy truncate mt-1"
|
|
title="{{ summary.peak_pvs_location_name }}">
|
|
{{ summary.peak_pvs_location_name }}
|
|
{% if summary.peak_pvs_at %} · {{ summary.peak_pvs_at[:10] }}{% endif %}
|
|
</a>
|
|
{% else %}
|
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Last Event</span>
|
|
{% if summary.last_event %}
|
|
<span class="text-base font-bold text-gray-900 dark:text-white mt-1">{{ summary.last_event[:19].replace('T', ' ') }}</span>
|
|
{% else %}
|
|
<span class="text-2xl font-bold text-gray-900 dark:text-white mt-1">—</span>
|
|
{% endif %}
|
|
</div>
|
|
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3 flex flex-col">
|
|
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">False Triggers</span>
|
|
<span class="text-2xl font-bold {% if summary.false_trigger_count > 0 %}text-amber-600 dark:text-amber-400{% else %}text-gray-900 dark:text-white{% endif %} mt-1">{{ "{:,}".format(summary.false_trigger_count) }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{% if summary.per_location and summary.total_events > 0 %}
|
|
<!-- Top locations by activity -->
|
|
<div>
|
|
<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-2">Top locations by activity</h4>
|
|
<div class="space-y-1.5">
|
|
{% for loc in summary.per_location[:5] %}
|
|
<a href="/projects/{{ summary.project_id }}/nrl/{{ loc.location_id }}"
|
|
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 }}
|
|
</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>
|
|
{% if loc.peak_pvs is not none %}
|
|
<span class="text-xs text-gray-500">peak {{ "%.4f"|format(loc.peak_pvs) }}</span>
|
|
{% endif %}
|
|
</span>
|
|
</a>
|
|
{% endfor %}
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
</div>
|
|
{% endif %}
|