event-modal: inline PDF preview + .TXT link + review form
Three additions to the shared event-detail modal, closing the gap
versus the standalone SFM webapp:
(1) "Show Event Report PDF" button toggles an inline iframe inside
the modal (no second-layer modal, no new tab). Lazy-loaded — src
isn't set until first reveal, so closing the modal without opening
the PDF never spends bandwidth. Sibling "Download PDF" link for
direct save. Iframe sized to 80vh / min 600px so the typical
letter-portrait single-page report fits with browser-native zoom
controls available.
(2) "Original .TXT report" download link, rendered only when
sidecar.source.txt_filename is present (post-2026-05-27 ingest
events). Hidden for legacy events to avoid 404 dead links.
(3) Inline Review form — false_trigger checkbox + reviewer text
input + notes textarea + Save button. PATCH /api/sfm/db/events/{id}/sidecar
with {"review": {...}}. On save, fires a CustomEvent
'sfm-event-review-saved' on window so table-owning pages
(/sfm, /unit/{id}, /admin/events, /projects/{p}/nrl/{l}) can
listen and refresh their FT badges without reload. Status line
shows the last-reviewed timestamp + Save success/failure feedback.
Smoke-tested end-to-end against a real BE12599 histogram event:
PATCH round-trip lands in the sidecar, GET reflects the change,
no 500s on /report.pdf or /sidecar paths through the proxy.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -245,6 +245,47 @@
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _renderReview(s, eventId) {
|
||||
const rev = s.review || {};
|
||||
const ft = !!rev.false_trigger;
|
||||
const reviewer = rev.reviewer || '';
|
||||
const notes = rev.notes || '';
|
||||
const reviewedAt = rev.reviewed_at
|
||||
? rev.reviewed_at.replace('T', ' ').slice(0, 19)
|
||||
: null;
|
||||
return `<div class="bg-gray-50 dark:bg-slate-900/40 border border-gray-200 dark:border-slate-700 rounded-lg p-4">
|
||||
<div class="flex flex-wrap items-center gap-x-6 gap-y-3">
|
||||
<label class="inline-flex items-center gap-2 text-sm cursor-pointer">
|
||||
<input type="checkbox" id="event-review-ft" ${ft ? 'checked' : ''}
|
||||
class="w-4 h-4 rounded text-seismo-orange focus:ring-seismo-orange">
|
||||
<span class="font-medium">Flag as false trigger</span>
|
||||
</label>
|
||||
<div class="flex items-center gap-2 text-sm flex-1 min-w-[180px]">
|
||||
<label for="event-review-reviewer" class="text-gray-500">Reviewer</label>
|
||||
<input type="text" id="event-review-reviewer" value="${_esc(reviewer)}"
|
||||
placeholder="Initials or name"
|
||||
class="flex-1 px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<label for="event-review-notes" class="block text-xs text-gray-500 mb-1">Notes</label>
|
||||
<textarea id="event-review-notes" rows="2"
|
||||
placeholder="Optional context — what caused the FT, follow-up actions, etc."
|
||||
class="w-full px-2 py-1 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-gray-600 rounded focus:outline-none focus:ring-2 focus:ring-seismo-orange">${_esc(notes)}</textarea>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3 mt-3">
|
||||
<span id="event-review-status" class="text-xs text-gray-500 dark:text-gray-400">
|
||||
${reviewedAt ? `Last reviewed ${reviewedAt}` : 'Not yet reviewed.'}
|
||||
</span>
|
||||
<button type="button"
|
||||
onclick="window.saveEventReview('${_esc(eventId)}')"
|
||||
class="px-4 py-1.5 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── Waveform / histogram chart helpers ──────────────────────────
|
||||
|
||||
async function _loadMicUnitPref() {
|
||||
@@ -527,27 +568,47 @@
|
||||
const src = s.source || {};
|
||||
const sizeKb = bw.filesize ? (bw.filesize / 1024).toFixed(1) : null;
|
||||
const canDownloadBinary = !!(bw.available && bw.filename && eventId);
|
||||
const txtFilename = src && src.txt_filename;
|
||||
const reportPdfUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/report.pdf`;
|
||||
const reportTxtUrl = `/api/sfm/db/events/${encodeURIComponent(eventId)}/ascii_report.txt`;
|
||||
|
||||
const downloadButtons = `
|
||||
<div class="flex flex-wrap gap-2 mb-4">
|
||||
<button type="button"
|
||||
onclick="window.toggleEventPdfPreview()"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||
<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="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
<span id="event-pdf-toggle-label">Show Event Report PDF</span>
|
||||
</button>
|
||||
<a href="${reportPdfUrl}" download
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download PDF
|
||||
</a>
|
||||
${canDownloadBinary ? `
|
||||
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/blastware_file"
|
||||
download="${_esc(bw.filename)}"
|
||||
class="inline-flex items-center gap-2 px-4 py-2 bg-seismo-orange hover:bg-seismo-navy text-white rounded-lg text-sm font-medium transition-colors">
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
</svg>
|
||||
Download Blastware file
|
||||
<span class="text-xs opacity-80 ml-1">(${_esc(bw.filename)}${sizeKb ? `, ${sizeKb} KB` : ''})</span>
|
||||
Blastware binary
|
||||
<span class="text-xs opacity-60 ml-1">${sizeKb ? `(${sizeKb} KB)` : ''}</span>
|
||||
</a>
|
||||
` : `
|
||||
<span class="inline-flex items-center gap-2 px-4 py-2 bg-gray-100 dark:bg-gray-800 text-gray-500 dark:text-gray-400 rounded-lg text-sm cursor-not-allowed">
|
||||
` : ''}
|
||||
${txtFilename ? `
|
||||
<a href="${reportTxtUrl}" download="${_esc(txtFilename)}"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
|
||||
</svg>
|
||||
Blastware file unavailable
|
||||
</span>
|
||||
`}
|
||||
Original .TXT report
|
||||
</a>
|
||||
` : ''}
|
||||
<button type="button"
|
||||
onclick="window.toggleEventJsonViewer()"
|
||||
class="inline-flex items-center gap-2 px-3 py-2 border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 rounded-lg text-sm transition-colors">
|
||||
@@ -565,6 +626,11 @@
|
||||
Download sidecar JSON
|
||||
</a>
|
||||
</div>
|
||||
<div id="event-pdf-preview" class="hidden mb-4 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden bg-gray-50 dark:bg-slate-900">
|
||||
<iframe id="event-pdf-iframe" title="Event Report PDF preview"
|
||||
class="w-full" style="height:80vh; min-height:600px; border:0;"
|
||||
data-pdf-url="${reportPdfUrl}"></iframe>
|
||||
</div>
|
||||
<div id="event-json-viewer" class="hidden mb-4">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wider">Sidecar JSON</span>
|
||||
@@ -660,6 +726,9 @@
|
||||
${_renderDeviceMetadata(s)}
|
||||
` : ''}
|
||||
|
||||
${_sectionHeader('Review')}
|
||||
${_renderReview(s, eventId)}
|
||||
|
||||
${_sectionHeader('Source File')}
|
||||
${_renderFileInfo(s, eventId)}
|
||||
`;
|
||||
@@ -704,6 +773,71 @@
|
||||
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
|
||||
};
|
||||
|
||||
window.toggleEventPdfPreview = function () {
|
||||
const preview = document.getElementById('event-pdf-preview');
|
||||
const iframe = document.getElementById('event-pdf-iframe');
|
||||
const label = document.getElementById('event-pdf-toggle-label');
|
||||
if (!preview || !iframe) return;
|
||||
const isHidden = preview.classList.toggle('hidden');
|
||||
// Lazy-load the PDF: only set the iframe src on first reveal, so
|
||||
// closing the event modal without opening the PDF never spends
|
||||
// bandwidth on it.
|
||||
if (!isHidden && !iframe.src) {
|
||||
iframe.src = iframe.dataset.pdfUrl || '';
|
||||
}
|
||||
if (label) label.textContent = isHidden ? 'Show Event Report PDF' : 'Hide Event Report PDF';
|
||||
// Scroll the iframe into view on first reveal so the operator
|
||||
// doesn't have to hunt for it after clicking.
|
||||
if (!isHidden) {
|
||||
preview.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
};
|
||||
|
||||
window.saveEventReview = async function (eventId) {
|
||||
const ft = document.getElementById('event-review-ft');
|
||||
const reviewer = document.getElementById('event-review-reviewer');
|
||||
const notes = document.getElementById('event-review-notes');
|
||||
const status = document.getElementById('event-review-status');
|
||||
if (!ft || !reviewer || !notes) return;
|
||||
|
||||
const payload = {
|
||||
review: {
|
||||
false_trigger: ft.checked,
|
||||
reviewer: reviewer.value.trim() || null,
|
||||
notes: notes.value.trim() || null,
|
||||
}
|
||||
};
|
||||
if (status) {
|
||||
status.textContent = 'Saving…';
|
||||
status.className = 'text-xs text-gray-500 dark:text-gray-400';
|
||||
}
|
||||
try {
|
||||
const r = await fetch(`/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const t = await r.text().catch(() => '');
|
||||
throw new Error('HTTP ' + r.status + (t ? ` — ${t.slice(0, 120)}` : ''));
|
||||
}
|
||||
if (status) {
|
||||
status.textContent = 'Saved.';
|
||||
status.className = 'text-xs text-green-600 dark:text-green-400';
|
||||
}
|
||||
// Notify the host page so its event-list FT badge / row state
|
||||
// can refresh. Pages opt in by listening for this event.
|
||||
window.dispatchEvent(new CustomEvent('sfm-event-review-saved', {
|
||||
detail: { eventId, review: payload.review },
|
||||
}));
|
||||
} catch (e) {
|
||||
if (status) {
|
||||
status.textContent = 'Save failed: ' + e.message;
|
||||
status.className = 'text-xs text-red-600 dark:text-red-400';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.copyEventJson = function () {
|
||||
const pre = document.getElementById('event-json-pre');
|
||||
const label = document.getElementById('event-json-copy-label');
|
||||
|
||||
Reference in New Issue
Block a user