feat(events): event modal + sortable tables polish

Event modal (event-modal.js):
- Record Type now derived from Blastware filename's last-char code
  (H=Histogram, W=Waveform, M=Manual, E=Event, C=Combo).  Falls back to
  whatever SFM reported if the code isn't recognized.  Client-side
  workaround — SFM still hardcodes "Waveform" server-side and needs a
  proper fix in its sidecar parser.
- PSI mic tile dropped; mic section now renders 3 tiles (dB(L), ZC
  Frequency, Time of Peak) instead of 4.
- New "View JSON" toggle exposes a prettified inline JSON viewer with
  a Copy-to-clipboard button alongside the existing "Download sidecar
  JSON" link.
- "Project Info" section header renamed to "User Notes" to reflect
  that these are operator-typed fields, not the terra-view project
  assignment.

Sortable tables (sfm.html + unit_detail.html):
- Both Events tables now have clickable column headers with ↕/↓/↑
  indicators.  Default sort is Timestamp DESC.  Clicking the same
  column toggles direction; clicking a different column switches and
  resets to DESC.  Sort is purely client-side over the cached rowset,
  so no extra fetches.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 17:53:28 +00:00
parent 583af1948e
commit 155f0b007a
3 changed files with 203 additions and 28 deletions
+76 -8
View File
@@ -62,6 +62,27 @@
</div>`; </div>`;
} }
function _deriveRecordType(filename, fallback) {
// SFM currently hardcodes record_type="Waveform" for every event.
// The actual type is encoded in the LAST character of the Blastware
// filename's extension (e.g. "O121LL5E.IS0H" → "H" → Histogram).
// We derive it client-side until SFM is fixed; if the suffix isn't
// a known code we fall back to whatever SFM reported.
if (!filename) return fallback || '—';
const dotIdx = filename.lastIndexOf('.');
if (dotIdx < 0 || dotIdx === filename.length - 1) return fallback || '—';
const ext = filename.slice(dotIdx + 1);
const lastChar = ext.slice(-1).toUpperCase();
const typeMap = {
'H': 'Histogram',
'W': 'Waveform',
'M': 'Manual',
'E': 'Event',
'C': 'Combo',
};
return typeMap[lastChar] || (fallback || '—');
}
function _sectionHeader(title, sub) { 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"> 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>` : ''} ${_esc(title)}${sub ? ` <span class="text-xs text-gray-400 normal-case font-normal ml-2">${_esc(sub)}</span>` : ''}
@@ -72,21 +93,23 @@
function _renderEventHeader(s) { function _renderEventHeader(s) {
const ev = s.event || {}; const ev = s.event || {};
const bw = s.blastware || {};
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const recType = _deriveRecordType(bw.filename || ev.blastware_filename, ev.record_type);
return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-x-6 gap-y-2 text-sm"> 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">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">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">Record Type</span> <span class="font-medium ml-1">${_esc(recType)}</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">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">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><span class="text-gray-500">Waveform Key</span> <span class="font-mono text-xs ml-1">${_esc(ev.waveform_key || '—')}</span></div>
</div>`; </div>`;
} }
function _renderProjectInfo(s) { function _renderUserNotes(s) {
// The "user notes" metadata the operator typed into the BW device. // The "user notes" metadata the operator typed into the BW device.
// These are the strings the future metadata-driven parser will use. // These are the strings the future metadata-driven parser will use.
const p = s.project_info || {}; const p = s.user_notes || {};
return `<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm"> 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">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">Client</span> <span class="font-medium ml-1">${_esc(p.client || '—')}</span></div>
@@ -120,20 +143,21 @@
} }
function _renderMic(s) { function _renderMic(s) {
// Operators only care about dB(L); PSI tile was dropped 2026-05.
// We still render the row if any mic data is present so ZC freq /
// time-of-peak stay visible even when bw_report.mic is missing.
const mic = (s.bw_report && s.bw_report.mic) || null; const mic = (s.bw_report && s.bw_report.mic) || null;
const pv = s.peak_values || {}; const pv = s.peak_values || {};
if (!mic && pv.mic_psi == null) return ''; if (!mic && pv.mic_psi == null) return '';
const dbl = mic?.pspl_dbl; const dbl = mic?.pspl_dbl;
const psi = pv.mic_psi;
const zcHz = mic?.zc_freq_hz; const zcHz = mic?.zc_freq_hz;
const tPk = mic?.time_of_peak_s; const tPk = mic?.time_of_peak_s;
const wt = mic?.weighting; const wt = mic?.weighting;
return `<div class="grid grid-cols-2 sm:grid-cols-4 gap-3"> return `<div class="grid grid-cols-1 sm:grid-cols-3 gap-3">
${_kvCard('Peak Mic dB(L)', _fmt(dbl, 1), { sub: wt || '' })} ${_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('ZC Frequency', _fmt(zcHz, 1, 'Hz'))}
${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')} ${_kvCard('Time of Peak', tPk != null ? _fmt(tPk, 2, 's') : '—')}
</div>`; </div>`;
@@ -223,6 +247,14 @@
Blastware file unavailable Blastware file unavailable
</span> </span>
`} `}
<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">
<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="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"></path>
</svg>
<span id="event-json-toggle-label">View JSON</span>
</button>
<a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar" <a href="/api/sfm/db/events/${encodeURIComponent(eventId)}/sidecar"
download="${_esc((bw.filename || 'event') + '.sfm.json')}" download="${_esc((bw.filename || 'event') + '.sfm.json')}"
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"> 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">
@@ -232,6 +264,16 @@
Download sidecar JSON Download sidecar JSON
</a> </a>
</div> </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>
<button type="button" onclick="window.copyEventJson()"
class="text-xs text-seismo-orange hover:text-seismo-navy">
<span id="event-json-copy-label">Copy</span>
</button>
</div>
<pre id="event-json-pre" class="bg-gray-900 dark:bg-black text-gray-200 font-mono text-xs p-4 rounded-lg max-h-96 overflow-auto whitespace-pre">${_esc(JSON.stringify(s, null, 2))}</pre>
</div>
`; `;
return `${downloadButtons} return `${downloadButtons}
@@ -294,8 +336,8 @@
${_sectionHeader('Event')} ${_sectionHeader('Event')}
${_renderEventHeader(s)} ${_renderEventHeader(s)}
${_sectionHeader('Project Info', '(operator-typed at session start)')} ${_sectionHeader('User Notes')}
${_renderProjectInfo(s)} ${_renderUserNotes(s)}
${_sectionHeader('Peak Particle Velocity')} ${_sectionHeader('Peak Particle Velocity')}
${_renderPeakValues(s)} ${_renderPeakValues(s)}
@@ -323,6 +365,32 @@
if (modal) modal.classList.add('hidden'); if (modal) modal.classList.add('hidden');
}; };
window.toggleEventJsonViewer = function () {
const viewer = document.getElementById('event-json-viewer');
const label = document.getElementById('event-json-toggle-label');
if (!viewer) return;
const isHidden = viewer.classList.toggle('hidden');
if (label) label.textContent = isHidden ? 'View JSON' : 'Hide JSON';
};
window.copyEventJson = function () {
const pre = document.getElementById('event-json-pre');
const label = document.getElementById('event-json-copy-label');
if (!pre) return;
navigator.clipboard.writeText(pre.textContent).then(() => {
if (label) {
label.textContent = 'Copied!';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
}).catch(err => {
console.error('clipboard write failed', err);
if (label) {
label.textContent = 'Failed';
setTimeout(() => { label.textContent = 'Copy'; }, 1500);
}
});
};
// Close on Escape. // Close on Escape.
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') window.closeEventDetailModal(); if (e.key === 'Escape') window.closeEventDetailModal();
+59 -11
View File
@@ -220,6 +220,12 @@ async function loadStats() {
} }
// ── Events tab ─────────────────────────────────────────────────────────────── // ── Events tab ───────────────────────────────────────────────────────────────
// Module-level cache so sort can re-render without re-fetching.
let _eventsCache = [];
let _eventsTotal = 0;
let _eventsSortKey = 'timestamp';
let _eventsSortDir = 'desc'; // 'asc' | 'desc'
async function loadEvents() { async function loadEvents() {
const container = document.getElementById('events-container'); const container = document.getElementById('events-container');
container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>'; container.innerHTML = '<div class="text-center py-8 text-gray-500"><div class="animate-spin rounded-full h-8 w-8 border-b-2 border-seismo-orange mx-auto mb-3"></div>Loading events…</div>';
@@ -241,19 +247,61 @@ async function loadEvents() {
const r = await fetch('/api/sfm/db/events?' + params.toString()); const r = await fetch('/api/sfm/db/events?' + params.toString());
if (!r.ok) { throw new Error('HTTP ' + r.status); } if (!r.ok) { throw new Error('HTTP ' + r.status); }
const d = await r.json(); const d = await r.json();
renderEventsTable(d.events, d.count, container); _eventsCache = d.events || [];
_eventsTotal = d.count || 0;
renderEventsTable(_eventsCache, _eventsTotal, container);
} catch (e) { } catch (e) {
container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`; container.innerHTML = `<div class="text-center py-8 text-red-500">Failed to load events: ${e.message}</div>`;
} }
} }
function sortEvents(key) {
// Toggle direction if same column clicked; otherwise default to desc.
if (_eventsSortKey === key) {
_eventsSortDir = _eventsSortDir === 'desc' ? 'asc' : 'desc';
} else {
_eventsSortKey = key;
_eventsSortDir = 'desc';
}
renderEventsTable(_eventsCache, _eventsTotal, document.getElementById('events-container'));
}
function _applySort(events) {
const key = _eventsSortKey;
const dir = _eventsSortDir === 'asc' ? 1 : -1;
return [...events].sort((a, b) => {
let av = a[key], bv = b[key];
// Nulls always sort last regardless of dir.
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
}
function _sortIndicator(key) {
if (_eventsSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
return _eventsSortDir === 'desc'
? '<span class="text-seismo-orange ml-1">↓</span>'
: '<span class="text-seismo-orange ml-1">↑</span>';
}
function _sortableTh(label, key) {
return `<th onclick="sortEvents('${key}')"
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
${label}${_sortIndicator(key)}
</th>`;
}
function renderEventsTable(events, total, container) { function renderEventsTable(events, total, container) {
if (!events || events.length === 0) { if (!events || events.length === 0) {
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><p class="text-sm">No events found matching the current filters.</p></div>'; container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400"><p class="text-sm">No events found matching the current filters.</p></div>';
return; return;
} }
const rows = events.map(ev => { const sorted = _applySort(events);
const rows = sorted.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = fmtPPV(ev.tran_ppv); const tran = fmtPPV(ev.tran_ppv);
const vert = fmtPPV(ev.vert_ppv); const vert = fmtPPV(ev.vert_ppv);
@@ -288,15 +336,15 @@ function renderEventsTable(events, total, container) {
<table class="w-full text-left"> <table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600"> <thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr> <tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th> ${_sortableTh('Timestamp', 'timestamp')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Serial</th> ${_sortableTh('Serial', 'serial')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Project</th> ${_sortableTh('Project', 'project')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th> ${_sortableTh('Tran', 'tran_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th> ${_sortableTh('Vert', 'vert_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th> ${_sortableTh('Long', 'long_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th> ${_sortableTh('PVS', 'peak_vector_sum')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Mic</th> ${_sortableTh('Mic', 'mic_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th> ${_sortableTh('Flags', 'false_trigger')}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
+68 -9
View File
@@ -2142,6 +2142,15 @@ function clearUnitEventFilters() {
loadUnitEvents(); loadUnitEvents();
} }
// Module-level state for the unit-events table sort. Cache lets us re-sort
// without a refetch when the user clicks a column header.
let _ueEventsCache = [];
let _ueEventsTotal = 0;
let _ueEventsBucket = 'all';
let _ueAssignmentsTotal = 0;
let _ueSortKey = 'timestamp';
let _ueSortDir = 'desc';
async function loadUnitEvents() { async function loadUnitEvents() {
if (!currentUnit || currentUnit.device_type !== 'seismograph') return; if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
const container = document.getElementById('ue-events-container'); const container = document.getElementById('ue-events-container');
@@ -2166,13 +2175,62 @@ async function loadUnitEvents() {
throw new Error(err.detail || 'HTTP ' + r.status); throw new Error(err.detail || 'HTTP ' + r.status);
} }
const d = await r.json(); const d = await r.json();
_ueEventsCache = d.events || [];
_ueEventsTotal = d.count || 0;
_ueEventsBucket = bucket;
_ueAssignmentsTotal = d.assignments_total || 0;
renderUnitEventStats(d.stats); renderUnitEventStats(d.stats);
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total); renderUnitEventTable(_ueEventsCache, _ueEventsTotal, container, bucket, _ueAssignmentsTotal);
} catch (e) { } catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`; container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
} }
} }
function sortUnitEvents(key) {
if (_ueSortKey === key) {
_ueSortDir = _ueSortDir === 'desc' ? 'asc' : 'desc';
} else {
_ueSortKey = key;
_ueSortDir = 'desc';
}
renderUnitEventTable(_ueEventsCache, _ueEventsTotal,
document.getElementById('ue-events-container'), _ueEventsBucket, _ueAssignmentsTotal);
}
function _ueApplySort(events) {
const key = _ueSortKey;
const dir = _ueSortDir === 'asc' ? 1 : -1;
return [...events].sort((a, b) => {
let av, bv;
if (key === 'attribution') {
// Sort by location name so attributed rows group together.
av = a.attribution ? (a.attribution.location_name || '') : '';
bv = b.attribution ? (b.attribution.location_name || '') : '';
} else {
av = a[key]; bv = b[key];
}
if (av == null && bv == null) return 0;
if (av == null) return 1;
if (bv == null) return -1;
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
return String(av).localeCompare(String(bv)) * dir;
});
}
function _ueSortIndicator(key) {
if (_ueSortKey !== key) return '<span class="text-gray-400 opacity-50 ml-1">↕</span>';
return _ueSortDir === 'desc'
? '<span class="text-seismo-orange ml-1">↓</span>'
: '<span class="text-seismo-orange ml-1">↑</span>';
}
function _ueSortableTh(label, key) {
return `<th onclick="sortUnitEvents('${key}')"
class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider cursor-pointer select-none hover:bg-gray-100 dark:hover:bg-slate-600 transition-colors">
${label}${_ueSortIndicator(key)}
</th>`;
}
function renderUnitEventStats(stats) { function renderUnitEventStats(stats) {
const s = stats || {}; const s = stats || {};
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString(); document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
@@ -2269,7 +2327,8 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
return; return;
} }
const rows = events.map(ev => { const sorted = _ueApplySort(events);
const rows = sorted.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—'; const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = _ueFmtPPV(ev.tran_ppv); const tran = _ueFmtPPV(ev.tran_ppv);
const vert = _ueFmtPPV(ev.vert_ppv); const vert = _ueFmtPPV(ev.vert_ppv);
@@ -2295,13 +2354,13 @@ function renderUnitEventTable(events, total, container, bucket, assignmentsTotal
<table class="w-full text-left"> <table class="w-full text-left">
<thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600"> <thead class="bg-gray-50 dark:bg-slate-700 border-b border-gray-200 dark:border-gray-600">
<tr> <tr>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Timestamp</th> ${_ueSortableTh('Timestamp', 'timestamp')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Tran</th> ${_ueSortableTh('Tran', 'tran_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Vert</th> ${_ueSortableTh('Vert', 'vert_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Long</th> ${_ueSortableTh('Long', 'long_ppv')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">PVS</th> ${_ueSortableTh('PVS', 'peak_vector_sum')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Flags</th> ${_ueSortableTh('Flags', 'false_trigger')}
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th> ${_ueSortableTh('Attribution', 'attribution')}
</tr> </tr>
</thead> </thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody> <tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>