update main to v0.10.0 #48
@@ -0,0 +1,295 @@
|
|||||||
|
/* event-modal.js — shared event-detail modal.
|
||||||
|
*
|
||||||
|
* Used by:
|
||||||
|
* - /sfm (admin Events tab)
|
||||||
|
* - /projects/{p}/nrl/{l} (project-location Events tab)
|
||||||
|
* - /unit/{id} (unit-detail SFM Events table)
|
||||||
|
*
|
||||||
|
* Pages must include partials/event_detail_modal.html in the body
|
||||||
|
* before this script is loaded.
|
||||||
|
*
|
||||||
|
* Public API:
|
||||||
|
* showEventDetail(eventId)
|
||||||
|
* Open the modal and fetch /api/sfm/db/events/{id}/sidecar to
|
||||||
|
* populate the rich BW report fields (peaks, ZC freq, sensor
|
||||||
|
* self-check, device info, etc.) into a tabbed/sectioned view.
|
||||||
|
*
|
||||||
|
* closeEventDetailModal()
|
||||||
|
* Close the modal.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Fetches sidecar live from SFM via terra-view's /api/sfm proxy.
|
||||||
|
* - Renders gracefully when the sidecar lacks a bw_report block
|
||||||
|
* (older events forwarded before the _ASCII.TXT pairing fix).
|
||||||
|
* - All functions are global on window so inline onclick handlers
|
||||||
|
* can reach them across all three host pages.
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
const MODAL_ID = 'event-detail-modal';
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
if (s == null) return '';
|
||||||
|
return String(s).replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fmt(v, digits = 4, suffix = '') {
|
||||||
|
if (v == null || (typeof v === 'number' && Number.isNaN(v))) return '—';
|
||||||
|
if (typeof v === 'number') {
|
||||||
|
return v.toFixed(digits) + (suffix ? ` ${suffix}` : '');
|
||||||
|
}
|
||||||
|
return _esc(v) + (suffix ? ` ${suffix}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
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 _kvCard(label, value, options = {}) {
|
||||||
|
// Single key-value tile. `value` is pre-rendered HTML (or text).
|
||||||
|
const colorCls = options.colorCls || '';
|
||||||
|
const valCls = `font-mono font-semibold ${colorCls}`;
|
||||||
|
return `<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-3">
|
||||||
|
<div class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">${_esc(label)}</div>
|
||||||
|
<div class="${valCls} mt-1">${value}</div>
|
||||||
|
${options.sub ? `<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">${options.sub}</div>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sectionHeader(title, sub) {
|
||||||
|
return `<h4 class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider mb-3 mt-5 first:mt-0">
|
||||||
|
${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
|
||||||
|
</h4>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section renderers ────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _renderEventHeader(s) {
|
||||||
|
const ev = s.event || {};
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
||||||
|
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Serial</span> <span class="font-mono font-semibold text-seismo-orange ml-1">${_esc(ev.serial)}</span></div>
|
||||||
|
<div><span class="text-gray-500">Timestamp</span> <span class="font-medium ml-1">${ts}</span></div>
|
||||||
|
<div><span class="text-gray-500">Record Type</span> <span class="font-medium ml-1">${_esc(ev.record_type || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Sample Rate</span> <span class="font-medium ml-1">${ev.sample_rate ?? '—'} sps</span></div>
|
||||||
|
<div><span class="text-gray-500">Rec Time</span> <span class="font-medium ml-1">${ev.rectime_seconds != null ? ev.rectime_seconds + ' s' : '—'}</span></div>
|
||||||
|
<div><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderProjectInfo(s) {
|
||||||
|
// The "user notes" metadata the operator typed into the BW device.
|
||||||
|
// These are the strings the future metadata-driven parser will use.
|
||||||
|
const p = s.project_info || {};
|
||||||
|
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Project</span> <span class="font-medium ml-1">${_esc(p.project || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Operator</span> <span class="font-medium ml-1">${_esc(p.operator || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Sensor Location</span> <span class="font-medium ml-1">${_esc(p.sensor_location || '—')}</span></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2 italic">
|
||||||
|
Values are as typed into the seismograph at session start — not the terra-view project/location assignment.
|
||||||
|
</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderPeakValues(s) {
|
||||||
|
// Prefer bw_report.peaks for richer per-channel data; fall back to peak_values.
|
||||||
|
const bwPeaks = (s.bw_report && s.bw_report.peaks) || null;
|
||||||
|
const pv = s.peak_values || {};
|
||||||
|
|
||||||
|
const tran = bwPeaks ? bwPeaks.tran?.ppv_ips : pv.transverse;
|
||||||
|
const vert = bwPeaks ? bwPeaks.vert?.ppv_ips : pv.vertical;
|
||||||
|
const lng = bwPeaks ? bwPeaks.long?.ppv_ips : pv.longitudinal;
|
||||||
|
const pvs = bwPeaks ? bwPeaks.vector_sum?.ips : pv.vector_sum;
|
||||||
|
const pvsAt = bwPeaks ? bwPeaks.vector_sum?.time_s : null;
|
||||||
|
|
||||||
|
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
${_kvCard('Transverse', `<span class="${_ppvClass(tran)}">${_fmt(tran, 4)}</span>`, { sub: 'in/s' })}
|
||||||
|
${_kvCard('Vertical', `<span class="${_ppvClass(vert)}">${_fmt(vert, 4)}</span>`, { sub: 'in/s' })}
|
||||||
|
${_kvCard('Longitudinal', `<span class="${_ppvClass(lng)}">${_fmt(lng, 4)}</span>`, { sub: 'in/s' })}
|
||||||
|
${_kvCard('Peak Vector Sum', `<span class="${_ppvClass(pvs)} text-base">${_fmt(pvs, 4)}</span>`, {
|
||||||
|
sub: pvsAt != null ? `in/s @ t=${_fmt(pvsAt, 2)}s` : 'in/s',
|
||||||
|
})}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderMic(s) {
|
||||||
|
const mic = (s.bw_report && s.bw_report.mic) || null;
|
||||||
|
const pv = s.peak_values || {};
|
||||||
|
|
||||||
|
if (!mic && pv.mic_psi == null) return '';
|
||||||
|
|
||||||
|
const dbl = mic?.pspl_dbl;
|
||||||
|
const psi = pv.mic_psi;
|
||||||
|
const zcHz = mic?.zc_freq_hz;
|
||||||
|
const tPk = mic?.time_of_peak_s;
|
||||||
|
const wt = mic?.weighting;
|
||||||
|
|
||||||
|
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })}
|
||||||
|
${_kvCard('Peak Mic psi', _fmt(psi, 4))}
|
||||||
|
${_kvCard('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
|
||||||
|
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _sensorRow(label, ch) {
|
||||||
|
if (!ch) {
|
||||||
|
return `<tr><td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm text-gray-400" colspan="3">—</td></tr>`;
|
||||||
|
}
|
||||||
|
const result = ch.result || '—';
|
||||||
|
const resultCls = result === 'Passed'
|
||||||
|
? 'text-green-600 dark:text-green-400'
|
||||||
|
: (result === 'Failed' ? 'text-red-600 dark:text-red-400 font-semibold' : 'text-gray-500');
|
||||||
|
|
||||||
|
// Geo channels have freq + ratio; mic has freq + amplitude.
|
||||||
|
const rightCol = (ch.amplitude_mv != null)
|
||||||
|
? `<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.amplitude_mv, 1, 'mV')}</td>`
|
||||||
|
: `<td class="px-3 py-2 text-sm font-mono">${ch.ratio != null ? ch.ratio.toFixed(1) + ' ratio' : '—'}</td>`;
|
||||||
|
|
||||||
|
return `<tr>
|
||||||
|
<td class="px-3 py-2 text-sm font-medium">${_esc(label)}</td>
|
||||||
|
<td class="px-3 py-2 text-sm font-mono">${_fmt(ch.freq_hz, 1, 'Hz')}</td>
|
||||||
|
${rightCol}
|
||||||
|
<td class="px-3 py-2 text-sm ${resultCls}">${_esc(result)}</td>
|
||||||
|
</tr>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderSensorCheck(s) {
|
||||||
|
const sc = s.bw_report && s.bw_report.sensor_check;
|
||||||
|
if (!sc) return '';
|
||||||
|
return `<table class="w-full text-left rounded overflow-hidden border border-gray-200 dark:border-gray-700">
|
||||||
|
<thead class="bg-gray-50 dark:bg-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Channel</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Frequency</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Amplitude/Ratio</th>
|
||||||
|
<th class="px-3 py-2 text-xs font-medium text-gray-600 dark:text-gray-400 uppercase">Result</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-gray-200 dark:divide-gray-700 bg-white dark:bg-slate-800">
|
||||||
|
${_sensorRow('Transverse', sc.tran)}
|
||||||
|
${_sensorRow('Vertical', sc.vert)}
|
||||||
|
${_sensorRow('Longitudinal', sc.long)}
|
||||||
|
${_sensorRow('Microphone', sc.mic)}
|
||||||
|
</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderDeviceMetadata(s) {
|
||||||
|
const bw = s.bw_report || {};
|
||||||
|
const dev = bw.device || {};
|
||||||
|
const rec = bw.recording || {};
|
||||||
|
return `<div class="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div><span class="text-gray-500">Firmware</span> <span class="font-mono text-xs ml-1">${_esc(bw.version || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Battery</span> <span class="font-medium ml-1">${dev.battery_volts != null ? dev.battery_volts.toFixed(2) + ' V' : '—'}</span></div>
|
||||||
|
<div><span class="text-gray-500">Calibrated</span> <span class="font-medium ml-1">${_esc(dev.calibration_date || '—')}${dev.calibration_by ? ' (' + _esc(dev.calibration_by) + ')' : ''}</span></div>
|
||||||
|
<div><span class="text-gray-500">Geo Range</span> <span class="font-medium ml-1">${rec.geo_range_ips != null ? rec.geo_range_ips + ' in/s' : '—'}</span></div>
|
||||||
|
<div><span class="text-gray-500">Stop Mode</span> <span class="font-medium ml-1">${_esc(rec.stop_mode || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Units</span> <span class="font-medium ml-1">${_esc(rec.units || '—')}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderFileInfo(s) {
|
||||||
|
const bw = s.blastware || {};
|
||||||
|
const src = s.source || {};
|
||||||
|
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
|
||||||
|
<div class="sm:col-span-2"><span class="text-gray-500">Blastware file</span> <span class="font-mono text-xs ml-1">${_esc(bw.filename || '—')}</span> ${bw.filesize ? `<span class="text-xs text-gray-500 ml-2">(${(bw.filesize / 1024).toFixed(1)} KB)</span>` : ''}</div>
|
||||||
|
<div class="sm:col-span-2"><span class="text-gray-500">SHA-256</span> <span class="font-mono text-xs ml-1 break-all">${_esc(bw.sha256 || '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Captured at</span> <span class="font-medium ml-1">${_esc(src.captured_at ? src.captured_at.slice(0, 19).replace('T', ' ') : '—')}</span></div>
|
||||||
|
<div><span class="text-gray-500">Tool version</span> <span class="font-mono text-xs ml-1">${_esc(src.tool_version || '—')}</span></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
window.showEventDetail = async function (eventId) {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
if (!modal) {
|
||||||
|
console.warn('event-modal: include event_detail_modal.html partial on this page.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
document.getElementById(MODAL_ID + '-title').textContent = 'Event Detail';
|
||||||
|
document.getElementById(MODAL_ID + '-content').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 event detail…
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
let s;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`);
|
||||||
|
if (!r.ok) {
|
||||||
|
throw new Error('HTTP ' + r.status + ' fetching sidecar');
|
||||||
|
}
|
||||||
|
s = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById(MODAL_ID + '-content').innerHTML = `
|
||||||
|
<div class="text-center py-8 text-red-500 text-sm">
|
||||||
|
Failed to load event detail: ${_esc(e.message)}
|
||||||
|
</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ev = s.event || {};
|
||||||
|
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '';
|
||||||
|
document.getElementById(MODAL_ID + '-title').textContent =
|
||||||
|
`Event — ${ev.serial || '?'} @ ${ts}`;
|
||||||
|
|
||||||
|
const hasReport = !!s.bw_report;
|
||||||
|
const reportNote = hasReport
|
||||||
|
? ''
|
||||||
|
: `<div class="bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg p-3 text-sm text-amber-800 dark:text-amber-300 mb-4">
|
||||||
|
<strong>No BW ASCII report paired with this event.</strong>
|
||||||
|
Older events forwarded before the watcher's <code class="font-mono text-xs">_ASCII.TXT</code> pairing fix landed lack this data.
|
||||||
|
PPV is still available from the binary event file.
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
document.getElementById(MODAL_ID + '-content').innerHTML = `
|
||||||
|
${reportNote}
|
||||||
|
|
||||||
|
${_sectionHeader('Event')}
|
||||||
|
${_renderEventHeader(s)}
|
||||||
|
|
||||||
|
${_sectionHeader('Project Info', '(operator-typed at session start)')}
|
||||||
|
${_renderProjectInfo(s)}
|
||||||
|
|
||||||
|
${_sectionHeader('Peak Particle Velocity')}
|
||||||
|
${_renderPeakValues(s)}
|
||||||
|
|
||||||
|
${(s.bw_report && (s.bw_report.mic || s.peak_values?.mic_psi != null)) ? `
|
||||||
|
${_sectionHeader('Microphone')}
|
||||||
|
${_renderMic(s)}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${hasReport ? `
|
||||||
|
${_sectionHeader('Sensor Self-Check')}
|
||||||
|
${_renderSensorCheck(s)}
|
||||||
|
|
||||||
|
${_sectionHeader('Device & Recording Metadata')}
|
||||||
|
${_renderDeviceMetadata(s)}
|
||||||
|
` : ''}
|
||||||
|
|
||||||
|
${_sectionHeader('Source File')}
|
||||||
|
${_renderFileInfo(s)}
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeEventDetailModal = function () {
|
||||||
|
const modal = document.getElementById(MODAL_ID);
|
||||||
|
if (modal) modal.classList.add('hidden');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Close on Escape.
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') window.closeEventDetailModal();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
{# Shared event detail modal.
|
||||||
|
|
||||||
|
Include this partial on any page that wants to call showEventDetail(eventId)
|
||||||
|
from event-modal.js. The partial provides only the modal shell — the
|
||||||
|
actual content is rendered by JS into #event-detail-modal-content.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
|
#}
|
||||||
|
<div id="event-detail-modal" class="fixed inset-0 z-50 hidden">
|
||||||
|
<div class="absolute inset-0 bg-black/60" onclick="closeEventDetailModal()"></div>
|
||||||
|
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-3xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[88vh] overflow-y-auto">
|
||||||
|
<div class="flex items-center justify-between mb-4 sticky top-0 bg-white dark:bg-slate-800 -mx-6 px-6 pb-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="event-detail-modal-title">Event Detail</h3>
|
||||||
|
<button onclick="closeEventDetailModal()"
|
||||||
|
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 transition-colors">
|
||||||
|
<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>
|
||||||
|
<div id="event-detail-modal-content"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
+6
-66
@@ -115,21 +115,9 @@
|
|||||||
|
|
||||||
</div><!-- end tab container -->
|
</div><!-- end tab container -->
|
||||||
|
|
||||||
<!-- Event detail modal -->
|
{# Shared event-detail modal — rendered by /static/event-modal.js #}
|
||||||
<div id="event-modal" class="fixed inset-0 z-50 hidden">
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
<div class="absolute inset-0 bg-black/60" onclick="closeEventModal()"></div>
|
<script src="/static/event-modal.js"></script>
|
||||||
<div class="absolute inset-x-4 top-1/2 -translate-y-1/2 max-w-2xl mx-auto bg-white dark:bg-slate-800 rounded-xl shadow-2xl p-6 max-h-[85vh] overflow-y-auto">
|
|
||||||
<div class="flex items-center justify-between mb-4">
|
|
||||||
<h3 class="text-lg font-bold text-gray-900 dark:text-white" id="modal-title">Event Detail</h3>
|
|
||||||
<button onclick="closeEventModal()" class="text-gray-400 hover:text-gray-600 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>
|
|
||||||
<div id="modal-content"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.sfm-tab {
|
.sfm-tab {
|
||||||
@@ -275,7 +263,7 @@ function renderEventsTable(events, total, container) {
|
|||||||
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 dark:text-yellow-200">FT</span>` : '';
|
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 dark:text-yellow-200">FT</span>` : '';
|
||||||
const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400">—</span>';
|
const proj = ev.project ? `<span class="truncate max-w-[120px] inline-block" title="${esc(ev.project)}">${esc(ev.project)}</span>` : '<span class="text-gray-400">—</span>';
|
||||||
|
|
||||||
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer" onclick="showEventDetail(${JSON.stringify(JSON.stringify(ev))})">
|
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 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">${esc(ev.serial)}</td>
|
<td class="px-4 py-2.5 text-sm font-mono font-medium text-seismo-orange">${esc(ev.serial)}</td>
|
||||||
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td>
|
<td class="px-4 py-2.5 text-sm text-gray-700 dark:text-gray-300 max-w-[140px]">${proj}</td>
|
||||||
@@ -354,56 +342,8 @@ async function toggleFalseTrigger(id, newValue, btn) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Event detail modal ───────────────────────────────────────────────────────
|
// Event detail modal now lives in /static/event-modal.js (shared component).
|
||||||
function showEventDetail(jsonStr) {
|
// `showEventDetail(eventId)` is exposed globally; row onclick handlers call it.
|
||||||
const ev = JSON.parse(jsonStr);
|
|
||||||
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
|
|
||||||
|
|
||||||
document.getElementById('modal-title').textContent = `Event — ${esc(ev.serial)} @ ${ts}`;
|
|
||||||
document.getElementById('modal-content').innerHTML = `
|
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
|
||||||
<div><span class="text-gray-500">Serial</span><span class="ml-2 font-medium">${esc(ev.serial)}</span></div>
|
|
||||||
<div><span class="text-gray-500">Key</span><span class="ml-2 font-mono text-xs">${esc(ev.waveform_key)}</span></div>
|
|
||||||
<div><span class="text-gray-500">Timestamp</span><span class="ml-2 font-medium">${ts}</span></div>
|
|
||||||
<div><span class="text-gray-500">Sample Rate</span><span class="ml-2 font-medium">${ev.sample_rate || '—'} sps</span></div>
|
|
||||||
<div><span class="text-gray-500">Record Type</span><span class="ml-2 font-medium">${ev.record_type || '—'}</span></div>
|
|
||||||
<div><span class="text-gray-500">False Trigger</span><span class="ml-2 font-medium">${ev.false_trigger ? '⚠ Yes' : 'No'}</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-x-6 gap-y-2 text-sm mb-4">
|
|
||||||
<div><span class="text-gray-500">Project</span><span class="ml-2 font-medium">${esc(ev.project || '—')}</span></div>
|
|
||||||
<div><span class="text-gray-500">Client</span><span class="ml-2 font-medium">${esc(ev.client || '—')}</span></div>
|
|
||||||
<div><span class="text-gray-500">Operator</span><span class="ml-2 font-medium">${esc(ev.operator || '—')}</span></div>
|
|
||||||
<div><span class="text-gray-500">Sensor Loc</span><span class="ml-2 font-medium">${esc(ev.sensor_location || '—')}</span></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-gray-50 dark:bg-slate-900/50 rounded-lg p-4">
|
|
||||||
<h4 class="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Peak Particle Velocity</h4>
|
|
||||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 text-center">
|
|
||||||
${ppvCard('Transverse', ev.tran_ppv)}
|
|
||||||
${ppvCard('Vertical', ev.vert_ppv)}
|
|
||||||
${ppvCard('Longitudinal', ev.long_ppv)}
|
|
||||||
${ppvCard('Peak Vector Sum', ev.peak_vector_sum, true)}
|
|
||||||
</div>
|
|
||||||
${ev.mic_ppv != null ? `<div class="mt-3 text-sm text-center text-gray-600 dark:text-gray-400">Mic: <span class="font-mono font-medium">${ev.mic_ppv.toFixed(3)}</span></div>` : ''}
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
document.getElementById('event-modal').classList.remove('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
function ppvCard(label, v, bold = false) {
|
|
||||||
const val = v != null ? v.toFixed(4) : '—';
|
|
||||||
const cls = ppvClass(v) + (bold ? ' text-lg' : '');
|
|
||||||
return `<div>
|
|
||||||
<div class="text-xs text-gray-500 mb-1">${label}</div>
|
|
||||||
<div class="font-mono font-${bold ? 'bold' : 'medium'} ${cls}">${val}</div>
|
|
||||||
<div class="text-xs text-gray-400">in/s</div>
|
|
||||||
</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEventModal() {
|
|
||||||
document.getElementById('event-modal').classList.add('hidden');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Units tab ────────────────────────────────────────────────────────────────
|
// ── Units tab ────────────────────────────────────────────────────────────────
|
||||||
async function loadUnits() {
|
async function loadUnits() {
|
||||||
|
|||||||
@@ -2221,19 +2221,21 @@ function _ueEsc(s) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _ueAttrCell(ev) {
|
function _ueAttrCell(ev) {
|
||||||
|
// Inline links use onclick="event.stopPropagation()" so clicking the
|
||||||
|
// project/location link navigates instead of opening the event-detail
|
||||||
|
// modal (which fires from the row-level onclick).
|
||||||
const a = ev.attribution;
|
const a = ev.attribution;
|
||||||
if (a) {
|
if (a) {
|
||||||
// Attributed: project / location link.
|
|
||||||
const projLabel = _ueEsc(a.project_name || '—');
|
const projLabel = _ueEsc(a.project_name || '—');
|
||||||
const locLabel = _ueEsc(a.location_name || '—');
|
const locLabel = _ueEsc(a.location_name || '—');
|
||||||
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
|
||||||
|
onclick="event.stopPropagation()"
|
||||||
class="text-seismo-orange hover:text-seismo-navy"
|
class="text-seismo-orange hover:text-seismo-navy"
|
||||||
title="${projLabel} → ${locLabel}">
|
title="${projLabel} → ${locLabel}">
|
||||||
📍 ${locLabel}
|
📍 ${locLabel}
|
||||||
</a>
|
</a>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
<div class="text-xs text-gray-500 dark:text-gray-400">${projLabel}</div>`;
|
||||||
}
|
}
|
||||||
// Unattributed: show nearest assignment + delta for context.
|
|
||||||
const n = ev.nearest_assignment;
|
const n = ev.nearest_assignment;
|
||||||
if (n) {
|
if (n) {
|
||||||
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
|
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
|
||||||
@@ -2243,7 +2245,7 @@ function _ueAttrCell(ev) {
|
|||||||
: `${days.toFixed(1)}d`;
|
: `${days.toFixed(1)}d`;
|
||||||
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
return `<span class="px-2 py-0.5 rounded text-xs bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300">⚠ Unattributed</span>
|
||||||
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
<div class="text-xs text-gray-500 dark:text-gray-400 mt-0.5">
|
||||||
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
${daysLabel} ${_ueEsc(sign)} <a href="/projects/${_ueEsc(n.project_id)}/nrl/${_ueEsc(n.location_id)}" onclick="event.stopPropagation()" class="text-seismo-orange hover:text-seismo-navy">${_ueEsc(n.location_name || '?')}</a>
|
||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
|
return `<span class="px-2 py-0.5 rounded text-xs bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300">⚠ No assignments</span>`;
|
||||||
@@ -2277,7 +2279,7 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
|
|||||||
? '<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>'
|
? '<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 ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}">
|
return `<tr class="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}" onclick="showEventDetail('${_dtEsc(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-900 dark:text-white whitespace-nowrap">${ts}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.tran_ppv)}">${tran}</td>
|
||||||
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.vert_ppv)}">${vert}</td>
|
||||||
@@ -2854,4 +2856,8 @@ function showToast(message, type = 'info') {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# Shared event-detail modal (clicking a row in the SFM Events table) #}
|
||||||
|
{% include 'partials/event_detail_modal.html' %}
|
||||||
|
<script src="/static/event-modal.js"></script>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -679,10 +679,10 @@ function renderEventTable(events, total, container) {
|
|||||||
? '<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>'
|
? '<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">
|
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 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">
|
<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">${esc(ev.serial)}</a>
|
<a href="/unit/${esc(ev.serial)}" class="hover:text-seismo-navy" onclick="event.stopPropagation()">${esc(ev.serial)}</a>
|
||||||
</td>
|
</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.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.vert_ppv)}">${vert}</td>
|
||||||
@@ -953,4 +953,8 @@ document.getElementById('swap-modal')?.addEventListener('click', function(e) {
|
|||||||
if (e.target === this) closeSwapModal();
|
if (e.target === this) closeSwapModal();
|
||||||
});
|
});
|
||||||
</script>
|
</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>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user