update to 0.16.0 #72
@@ -160,6 +160,7 @@ async def get_project_locations(
|
|||||||
# (sessions don't really exist for the watcher-forward pipeline).
|
# (sessions don't really exist for the watcher-forward pipeline).
|
||||||
# Sound locations skip this and keep showing session counts.
|
# Sound locations skip this and keep showing session counts.
|
||||||
event_counts: dict[str, int] = {}
|
event_counts: dict[str, int] = {}
|
||||||
|
last_events: dict[str, str] = {}
|
||||||
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
vibration_locations = [l for l in locations if l.location_type == "vibration"]
|
||||||
if vibration_locations:
|
if vibration_locations:
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -171,7 +172,10 @@ async def get_project_locations(
|
|||||||
for loc, res in zip(vibration_locations, results):
|
for loc, res in zip(vibration_locations, results):
|
||||||
if isinstance(res, Exception):
|
if isinstance(res, Exception):
|
||||||
continue # leave event_counts[loc.id] unset → template falls back
|
continue # leave event_counts[loc.id] unset → template falls back
|
||||||
event_counts[loc.id] = (res.get("stats") or {}).get("event_count", 0) or 0
|
stats = res.get("stats") or {}
|
||||||
|
event_counts[loc.id] = stats.get("event_count", 0) or 0
|
||||||
|
if stats.get("last_event"):
|
||||||
|
last_events[loc.id] = stats["last_event"]
|
||||||
|
|
||||||
# Enrich with assignment info, splitting active vs removed.
|
# Enrich with assignment info, splitting active vs removed.
|
||||||
active_data: list = []
|
active_data: list = []
|
||||||
@@ -205,6 +209,8 @@ async def get_project_locations(
|
|||||||
}
|
}
|
||||||
if location.id in event_counts:
|
if location.id in event_counts:
|
||||||
item["event_count"] = event_counts[location.id]
|
item["event_count"] = event_counts[location.id]
|
||||||
|
if location.id in last_events:
|
||||||
|
item["last_event"] = last_events[location.id]
|
||||||
if location.removed_at is None:
|
if location.removed_at is None:
|
||||||
active_data.append(item)
|
active_data.append(item)
|
||||||
else:
|
else:
|
||||||
@@ -1579,6 +1585,70 @@ async def get_project_vibration_summary(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/vibration-events", response_class=JSONResponse)
|
||||||
|
async def get_project_vibration_events(
|
||||||
|
project_id: str,
|
||||||
|
from_dt: Optional[datetime] = Query(None),
|
||||||
|
to_dt: Optional[datetime] = Query(None),
|
||||||
|
false_trigger: Optional[bool] = Query(None),
|
||||||
|
limit: int = Query(500, ge=1, le=5000),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Project-wide SFM events across every active vibration location.
|
||||||
|
|
||||||
|
Fans out events_for_location per location (each of which unions that
|
||||||
|
location's assignment windows), tags each event with its location, then
|
||||||
|
merges newest-first. Powers the Vibration tab's Events sub-tab.
|
||||||
|
"""
|
||||||
|
project = db.query(Project).filter_by(id=project_id).first()
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(status_code=404, detail="Project not found.")
|
||||||
|
|
||||||
|
locations = (
|
||||||
|
db.query(MonitoringLocation)
|
||||||
|
.filter(
|
||||||
|
MonitoringLocation.project_id == project_id,
|
||||||
|
MonitoringLocation.location_type == "vibration",
|
||||||
|
MonitoringLocation.removed_at.is_(None),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not locations:
|
||||||
|
return {"events": [], "count": 0, "location_count": 0}
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from backend.services.sfm_events import events_for_location
|
||||||
|
|
||||||
|
results = await asyncio.gather(
|
||||||
|
*(
|
||||||
|
events_for_location(
|
||||||
|
db, loc.id, from_dt=from_dt, to_dt=to_dt,
|
||||||
|
false_trigger=false_trigger, limit=limit,
|
||||||
|
)
|
||||||
|
for loc in locations
|
||||||
|
),
|
||||||
|
return_exceptions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = []
|
||||||
|
for loc, res in zip(locations, results):
|
||||||
|
if isinstance(res, Exception):
|
||||||
|
continue
|
||||||
|
for ev in res.get("events", []):
|
||||||
|
ev = dict(ev)
|
||||||
|
ev["location_id"] = loc.id
|
||||||
|
ev["location_name"] = loc.name
|
||||||
|
merged.append(ev)
|
||||||
|
|
||||||
|
merged.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
|
||||||
|
total = len(merged)
|
||||||
|
return {
|
||||||
|
"events": merged[:limit],
|
||||||
|
"count": total,
|
||||||
|
"location_count": len(locations),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
@router.get("/locations/{location_id}/events", response_class=JSONResponse)
|
||||||
async def get_location_events(
|
async def get_location_events(
|
||||||
project_id: str,
|
project_id: str,
|
||||||
|
|||||||
@@ -74,6 +74,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
<span class="italic text-gray-400 dark:text-gray-500">No active assignment</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if item.last_event %}
|
||||||
|
<span>Last event: <span class="text-gray-700 dark:text-gray-300">{{ item.last_event[:16] }}</span></span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -137,7 +137,10 @@
|
|||||||
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
|
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-seismo-orange text-seismo-orange -mb-px transition-colors whitespace-nowrap">
|
||||||
Locations
|
Locations
|
||||||
</button>
|
</button>
|
||||||
<!-- Future sub-tabs: Sessions, Data Files -->
|
<button id="vib-sub-events-btn" onclick="switchVibSubTab('events')"
|
||||||
|
class="vib-sub-tab px-4 py-2 text-sm font-medium border-b-2 border-transparent text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 -mb-px transition-colors whitespace-nowrap">
|
||||||
|
Events
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Vibration Locations sub-panel -->
|
<!-- Vibration Locations sub-panel -->
|
||||||
@@ -183,6 +186,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Vibration Events sub-panel — project-wide events across all locations -->
|
||||||
|
<div id="vib-sub-events" class="vib-sub-panel hidden">
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-lg p-6">
|
||||||
|
<div class="flex flex-wrap items-end gap-3 mb-4">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mr-auto">Project Events</h2>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
|
||||||
|
<input type="date" id="pve-from" onchange="loadProjectVibrationEvents()"
|
||||||
|
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="date" id="pve-to" onchange="loadProjectVibrationEvents()"
|
||||||
|
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">Events</label>
|
||||||
|
<select id="pve-ft" onchange="loadProjectVibrationEvents()"
|
||||||
|
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</option>
|
||||||
|
<option value="false">Real Only</option>
|
||||||
|
<option value="true">FT 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="pve-limit" onchange="loadProjectVibrationEvents()"
|
||||||
|
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="250">250</option>
|
||||||
|
<option value="500" selected>500</option>
|
||||||
|
<option value="1000">1000</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onclick="clearProjectEventFilters()"
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
<div id="pve-container" class="overflow-x-auto">
|
||||||
|
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sound Tab -->
|
<!-- Sound Tab -->
|
||||||
@@ -985,6 +1032,98 @@ function switchVibSubTab(name) {
|
|||||||
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
btn.classList.remove('border-transparent', 'text-gray-500', 'dark:text-gray-400');
|
||||||
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
|
btn.classList.add('border-seismo-orange', 'text-seismo-orange');
|
||||||
}
|
}
|
||||||
|
// Lazy-load the Events table on first open.
|
||||||
|
if (name === 'events' && !_projectEventsLoaded) {
|
||||||
|
_projectEventsLoaded = true;
|
||||||
|
loadProjectVibrationEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Vibration Events sub-tab ─────────────────────────────────────────────
|
||||||
|
let _projectEventsLoaded = false;
|
||||||
|
|
||||||
|
function clearProjectEventFilters() {
|
||||||
|
document.getElementById('pve-from').value = '';
|
||||||
|
document.getElementById('pve-to').value = '';
|
||||||
|
document.getElementById('pve-ft').value = '';
|
||||||
|
loadProjectVibrationEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pveFmtPPV(v) { return (v === null || v === undefined) ? '—' : Number(v).toFixed(4); }
|
||||||
|
function _pvePPVClass(v) {
|
||||||
|
if (v == null) return 'text-gray-400';
|
||||||
|
if (v >= 0.5) return 'text-red-500';
|
||||||
|
if (v >= 0.2) return 'text-amber-500';
|
||||||
|
return 'text-green-600 dark:text-green-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectVibrationEvents() {
|
||||||
|
const container = document.getElementById('pve-container');
|
||||||
|
if (!container) return;
|
||||||
|
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('pve-from').value;
|
||||||
|
const to = document.getElementById('pve-to').value;
|
||||||
|
const ft = document.getElementById('pve-ft').value;
|
||||||
|
const limit = document.getElementById('pve-limit').value;
|
||||||
|
if (from) params.set('from_dt', from + ' 00:00:00');
|
||||||
|
if (to) params.set('to_dt', to + ' 23:59:59');
|
||||||
|
if (ft) params.set('false_trigger', ft);
|
||||||
|
params.set('limit', limit);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/projects/${projectId}/vibration-events?${params.toString()}`);
|
||||||
|
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||||
|
const d = await r.json();
|
||||||
|
_renderProjectEvents(d.events || [], d.count || 0, container);
|
||||||
|
} catch (e) {
|
||||||
|
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${lsEsc(e.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProjectEvents(events, total, container) {
|
||||||
|
if (!events.length) {
|
||||||
|
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">No events for the current filter.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = events.map(ev => {
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
const mic = ev.mic_ppv != null ? Number(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('${lsEsc(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 text-gray-700 dark:text-gray-300">${lsEsc(ev.location_name || '—')}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono text-seismo-orange">
|
||||||
|
<a href="/unit/${lsEsc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${lsEsc(ev.serial)}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.tran_ppv)}">${_pveFmtPPV(ev.tran_ppv)}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.vert_ppv)}">${_pveFmtPPV(ev.vert_ppv)}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono ${_pvePPVClass(ev.long_ppv)}">${_pveFmtPPV(ev.long_ppv)}</td>
|
||||||
|
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_pvePPVClass(ev.peak_vector_sum)}">${_pveFmtPPV(ev.peak_vector_sum)}</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-1 pb-2">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">Location</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 switchSoundSubTab(name) {
|
function switchSoundSubTab(name) {
|
||||||
@@ -2413,4 +2552,15 @@ async function regeneratePassword() {
|
|||||||
} catch (e) { paToast('Could not generate a password.'); }
|
} catch (e) { paToast('Could not generate a password.'); }
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- Shared SFM event-detail modal, for the Vibration Events sub-tab rows. -->
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
|
<script>
|
||||||
|
// When an event's review (FT flag / notes) is saved in the modal, refresh
|
||||||
|
// the project events table so the FT badge updates without a full reload.
|
||||||
|
window.addEventListener('sfm-event-review-saved', () => {
|
||||||
|
if (_projectEventsLoaded) loadProjectVibrationEvents();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user