feat(sfm): per-unit event history with attribution + Unattributed bucket

Phase 2 of the SFM integration.  Adds a "SFM Events" section to the
seismograph unit detail page (/unit/{id}).  Every event SFM has for the
serial is shown, with each event annotated by which project/location
assignment window it falls into.  Events outside every assignment window
get the "⚠ Unattributed" badge plus a "<N>d before/after <nearest location>"
hint — that's the operator's signal that backdating an assignment (Phase 1
edit-pencil) will absorb the orphan events.

Backend:
- backend/services/sfm_events.py: new events_for_unit() helper.  Fetches
  all events for the serial via SFM /db/events (one call, ceiling 5000),
  loads every UnitAssignment for the unit + resolves MonitoringLocation +
  Project names, then annotates each event with attribution or
  nearest_assignment (signed delta_days).  Bucket filter: all /
  attributed / unattributed.  Stats always reflect the full event set so
  the "Unattributed" KPI tile is meaningful regardless of which bucket
  is being viewed.

- backend/routers/units.py: new GET /api/units/{unit_id}/events with
  bucket / date-range / false_trigger / limit query params.  404s on
  unknown unit_id; returns an empty payload for non-seismograph
  device_types so the page can render the section conditionally.

Frontend (templates/unit_detail.html):
- New "SFM Events" section between "Deployment History" and "Timeline",
  styled to match the existing card pattern (border-t divider, same
  heading weight).
- Hidden by default; revealed only when currentUnit.device_type ===
  'seismograph' after the unit data loads.
- Four KPI tiles: Total Events / Unattributed (highlighted amber when
  > 0) / Peak PVS / Last Event.
- Filters: Bucket (all|attributed|unattributed), From/To, False
  Triggers, Limit, + Refresh.
- Event table with Attribution column.  Attributed rows link to the
  project/location detail page; unattributed rows are tinted amber
  and show "<N>d before/after <nearest location>" with a link to the
  nearest location.
- Empty-state copy varies by bucket: e.g. unattributed-with-zero shows
  " All events for this unit are attributed to a project/location".

Verified end-to-end against BE11529 (81 events total, 24 attributed,
57 unattributed — all 57 unattributed events emitted within hours of
the assignment start, which means backdating the assignment by a day
would attribute every one of them).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-11 22:38:46 +00:00
parent 09db988a35
commit bc5a151faa
3 changed files with 492 additions and 3 deletions
+66 -2
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
from typing import Dict, Any from typing import Dict, Any, Optional
from backend.database import get_db from backend.database import get_db
from backend.services.snapshot import emit_status_snapshot from backend.services.snapshot import emit_status_snapshot
@@ -72,3 +72,67 @@ def get_unit_by_id(unit_id: str, db: Session = Depends(get_db)):
"slm_serial_number": unit.slm_serial_number, "slm_serial_number": unit.slm_serial_number,
"deployed_with_modem_id": unit.deployed_with_modem_id "deployed_with_modem_id": unit.deployed_with_modem_id
} }
@router.get("/units/{unit_id}/events")
async def get_unit_events(
unit_id: str,
bucket: str = Query("all", regex="^(all|attributed|unattributed)$"),
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),
):
"""
Return SFM events for a single unit, annotated with assignment attribution.
Each event includes an `attribution` object pointing at the project/location
it falls into (or null if outside every assignment window). Unattributed
events also carry a `nearest_assignment` field with `delta_days` so the
operator can see how far off the nearest assignment is — useful for
deciding whether to backdate the assignment to absorb the event.
Bucket filter:
- all (default): every event
- attributed: only events inside an assignment window
- unattributed: only orphan events (the diagnostic bucket)
Non-seismograph units return an empty events list. The route does not
404 for SLMs/modems so the unit detail page can render the section
conditionally without depending on the response shape.
"""
unit = db.query(RosterUnit).filter_by(id=unit_id).first()
if not unit:
raise HTTPException(status_code=404, detail=f"Unit {unit_id} not found")
if unit.device_type != "seismograph":
return {
"events": [],
"count": 0,
"stats": {
"event_count": 0,
"unattributed_count": 0,
"peak_pvs": None,
"peak_pvs_at": None,
"peak_pvs_serial": None,
"last_event": None,
"false_trigger_count": 0,
},
"assignments_total": 0,
"device_type": unit.device_type,
}
from backend.services.sfm_events import events_for_unit
result = await events_for_unit(
db,
unit_id,
bucket=bucket,
from_dt=from_dt,
to_dt=to_dt,
false_trigger=false_trigger,
limit=limit,
)
result["device_type"] = unit.device_type
return result
+163 -1
View File
@@ -29,7 +29,7 @@ from typing import Optional
import httpx import httpx
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from backend.models import UnitAssignment, RosterUnit from backend.models import UnitAssignment, RosterUnit, MonitoringLocation, Project
log = logging.getLogger("backend.services.sfm_events") log = logging.getLogger("backend.services.sfm_events")
@@ -252,6 +252,168 @@ async def events_for_location(
} }
# ── Per-unit (cross-project) view ─────────────────────────────────────────────
async def events_for_unit(
db: Session,
unit_id: str,
*,
bucket: str = "all", # "all" | "attributed" | "unattributed"
from_dt: Optional[datetime] = None,
to_dt: Optional[datetime] = None,
false_trigger: Optional[bool] = None,
limit: int = 500,
) -> dict:
"""Return events for a unit annotated with their assignment attribution.
Unlike events_for_location (which queries SFM per assignment window), this
helper queries SFM for ALL events for the serial within the optional
[from_dt, to_dt] filter, then walks each event against the unit's
UnitAssignment intervals to compute attribution.
Bucket semantics:
- "all": every event, attributed or not
- "attributed": events that fall inside at least one assignment window
- "unattributed": events with no overlapping assignment (the diagnostic
bucket — operator should fix assignment dates to
attribute these)
Each event gets an extra `attribution` field:
{assignment_id, location_id, location_name, project_id, project_name,
assigned_at, assigned_until} or None
Unattributed events also get a `nearest_assignment` field with the
same shape plus `delta_days` (signed; negative = event before assignment).
"""
# 1. Pull all assignments for this unit (any device_type — caller has
# already filtered by seismograph in the route). Order matters: we
# want the earliest-start assignment first so attribution prefers the
# chronologically-first overlap when there are simultaneous active
# assignments at different locations (rare but possible).
assignments = (
db.query(UnitAssignment)
.filter(UnitAssignment.unit_id == unit_id)
.order_by(UnitAssignment.assigned_at.asc())
.all()
)
# Resolve location + project names once.
loc_ids = {a.location_id for a in assignments}
proj_ids = {a.project_id for a in assignments}
loc_map = {
l.id: l for l in db.query(MonitoringLocation).filter(
MonitoringLocation.id.in_(loc_ids)
).all()
} if loc_ids else {}
proj_map = {
p.id: p for p in db.query(Project).filter(
Project.id.in_(proj_ids)
).all()
} if proj_ids else {}
now = datetime.utcnow()
def _attr_dict(a: UnitAssignment) -> dict:
loc = loc_map.get(a.location_id)
proj = proj_map.get(a.project_id)
return {
"assignment_id": a.id,
"location_id": a.location_id,
"location_name": loc.name if loc else None,
"project_id": a.project_id,
"project_name": proj.name if proj else None,
"assigned_at": _iso_utc(a.assigned_at),
"assigned_until": _iso_utc(a.assigned_until),
}
# 2. Fetch all events for this serial in one shot.
async with httpx.AsyncClient(timeout=_SFM_TIMEOUT_SECONDS) as client:
events = await _fetch_events_for_serial(
client,
serial=unit_id,
from_dt=from_dt or datetime(1970, 1, 1),
to_dt=to_dt or now,
false_trigger=false_trigger,
limit=_SFM_FETCH_CEILING,
)
# 3. For each event, walk the assignment list and find the first
# overlapping window. O(N * M) but both are small in practice.
for ev in events:
ts_str = ev.get("timestamp")
if not ts_str:
ev["attribution"] = None
continue
try:
# SFM returns ISO with "T" separator; tolerate both.
ts = datetime.fromisoformat(ts_str.replace(" ", "T"))
except ValueError:
ev["attribution"] = None
continue
matched: Optional[UnitAssignment] = None
for a in assignments:
a_end = a.assigned_until or now
if a.assigned_at <= ts <= a_end:
matched = a
break
if matched is not None:
ev["attribution"] = _attr_dict(matched)
else:
ev["attribution"] = None
# Find the nearest assignment (chronologically) for diagnostic.
if assignments:
nearest = min(
assignments,
key=lambda a: min(
abs((ts - a.assigned_at).total_seconds()),
abs((ts - (a.assigned_until or now)).total_seconds()),
),
)
# Signed delta in days from the nearest boundary
# (negative = event BEFORE that boundary).
if ts < nearest.assigned_at:
delta_seconds = (ts - nearest.assigned_at).total_seconds()
elif ts > (nearest.assigned_until or now):
delta_seconds = (ts - (nearest.assigned_until or now)).total_seconds()
else:
delta_seconds = 0
ev["nearest_assignment"] = {
**_attr_dict(nearest),
"delta_days": round(delta_seconds / 86400, 1),
}
# 4. Apply bucket filter.
if bucket == "attributed":
filtered = [e for e in events if e.get("attribution") is not None]
elif bucket == "unattributed":
filtered = [e for e in events if e.get("attribution") is None]
else:
filtered = events
filtered.sort(key=lambda e: e.get("timestamp") or "", reverse=True)
total_count = len(filtered)
capped = filtered[:limit]
# 5. Stats: compute over the ENTIRE event set (not the filtered bucket)
# so the unattributed_count tile is always meaningful regardless of
# which bucket the operator has selected.
base_stats = _compute_stats(events)
unattributed_count = sum(
1 for e in events if e.get("attribution") is None
)
base_stats["unattributed_count"] = unattributed_count
return {
"events": capped,
"count": total_count,
"stats": base_stats,
"assignments_total": len(assignments),
}
# ── Stats helpers ───────────────────────────────────────────────────────────── # ── Stats helpers ─────────────────────────────────────────────────────────────
+263
View File
@@ -294,6 +294,91 @@
</div> </div>
</div> </div>
<!-- SFM Events (seismographs only) -->
<div id="sfmEventsSection" class="border-t border-gray-200 dark:border-gray-700 pt-6 hidden">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">SFM Events</h3>
<button onclick="loadUnitEvents()" class="px-3 py-1.5 text-sm rounded-lg border border-gray-300 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300">
↻ Refresh
</button>
</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 id="ue-stat-total" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></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">Unattributed</span>
<span id="ue-stat-unattr" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span class="text-xs text-gray-500 dark:text-gray-400 mt-1">outside any assignment window</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>
<span id="ue-stat-peak" class="text-2xl font-bold text-gray-900 dark:text-white mt-1"></span>
<span id="ue-stat-peak-when" class="text-xs text-gray-500 dark:text-gray-400 mt-1"></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">Last Event</span>
<span id="ue-stat-last" class="text-base font-bold text-gray-900 dark:text-white mt-1"></span>
</div>
</div>
<!-- Filters -->
<div class="flex flex-wrap items-end gap-3 mb-4">
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">Bucket</label>
<select id="ue-filter-bucket" onchange="loadUnitEvents()"
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">All Events</option>
<option value="attributed">Attributed Only</option>
<option value="unattributed">Unattributed Only</option>
</select>
</div>
<div class="flex flex-col gap-1">
<label class="text-xs text-gray-500 dark:text-gray-400">From</label>
<input type="datetime-local" id="ue-filter-from" onchange="loadUnitEvents()"
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="datetime-local" id="ue-filter-to" onchange="loadUnitEvents()"
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">False Triggers</label>
<select id="ue-filter-ft" onchange="loadUnitEvents()"
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="ue-filter-limit" onchange="loadUnitEvents()"
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="100">100</option>
<option value="250">250</option>
<option value="500" selected>500</option>
<option value="1000">1000</option>
</select>
</div>
<button onclick="clearUnitEventFilters()"
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>
<!-- Event table -->
<div id="ue-events-container" class="overflow-x-auto rounded-lg border border-gray-200 dark:border-gray-700">
<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">
Loading events…
</div>
</div>
</div>
<!-- Unit History Timeline --> <!-- Unit History Timeline -->
<div class="border-t border-gray-200 dark:border-gray-700 pt-6"> <div class="border-t border-gray-200 dark:border-gray-700 pt-6">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3> <h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">Timeline</h3>
@@ -1886,8 +1971,186 @@ loadUnitData().then(() => {
loadPhotos(); loadPhotos();
loadUnitHistory(); loadUnitHistory();
loadDeploymentHistory(); loadDeploymentHistory();
if (currentUnit && currentUnit.device_type === 'seismograph') {
document.getElementById('sfmEventsSection').classList.remove('hidden');
loadUnitEvents();
}
}); });
// ── SFM Events section ──────────────────────────────────────────────────────
function clearUnitEventFilters() {
document.getElementById('ue-filter-bucket').value = 'all';
document.getElementById('ue-filter-from').value = '';
document.getElementById('ue-filter-to').value = '';
document.getElementById('ue-filter-ft').value = '';
document.getElementById('ue-filter-limit').value = '500';
loadUnitEvents();
}
async function loadUnitEvents() {
if (!currentUnit || currentUnit.device_type !== 'seismograph') return;
const container = document.getElementById('ue-events-container');
container.innerHTML = '<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">Loading events…</div>';
const params = new URLSearchParams();
const bucket = document.getElementById('ue-filter-bucket').value;
const from = document.getElementById('ue-filter-from').value;
const to = document.getElementById('ue-filter-to').value;
const ft = document.getElementById('ue-filter-ft').value;
const limit = document.getElementById('ue-filter-limit').value;
params.set('bucket', bucket);
if (from) params.set('from_dt', from.replace('T', ' '));
if (to) params.set('to_dt', to.replace('T', ' '));
if (ft) params.set('false_trigger', ft);
params.set('limit', limit);
try {
const r = await fetch(`/api/units/${currentUnit.id}/events?${params.toString()}`);
if (!r.ok) {
const err = await r.json().catch(() => ({detail: 'HTTP ' + r.status}));
throw new Error(err.detail || 'HTTP ' + r.status);
}
const d = await r.json();
renderUnitEventStats(d.stats);
renderUnitEventTable(d.events, d.count, container, bucket, d.assignments_total);
} catch (e) {
container.innerHTML = `<div class="text-center py-12 text-red-500 text-sm">Failed to load events: ${e.message}</div>`;
}
}
function renderUnitEventStats(stats) {
const s = stats || {};
document.getElementById('ue-stat-total').textContent = (s.event_count ?? 0).toLocaleString();
const unattrEl = document.getElementById('ue-stat-unattr');
unattrEl.textContent = (s.unattributed_count ?? 0).toLocaleString();
// Highlight unattributed in amber/red if non-zero — visual signal that
// the operator has assignment-window cleanup to do.
unattrEl.className = 'text-2xl font-bold mt-1 ' + (
(s.unattributed_count ?? 0) > 0
? 'text-amber-600 dark:text-amber-400'
: 'text-gray-900 dark:text-white'
);
if (s.peak_pvs != null) {
document.getElementById('ue-stat-peak').textContent = s.peak_pvs.toFixed(4) + ' in/s';
const when = s.peak_pvs_at ? s.peak_pvs_at.slice(0, 10) : '';
document.getElementById('ue-stat-peak-when').textContent = when || '—';
} else {
document.getElementById('ue-stat-peak').textContent = '—';
document.getElementById('ue-stat-peak-when').textContent = '—';
}
if (s.last_event) {
const dt = s.last_event.slice(0, 19).replace('T', ' ');
document.getElementById('ue-stat-last').textContent = dt;
} else {
document.getElementById('ue-stat-last').textContent = '—';
}
}
function _ueFmtPPV(v) {
if (v == null) return '—';
return v.toFixed(4);
}
function _uePpvClass(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 _ueEsc(s) {
if (s == null) return '';
return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
function _ueAttrCell(ev) {
const a = ev.attribution;
if (a) {
// Attributed: project / location link.
const projLabel = _ueEsc(a.project_name || '—');
const locLabel = _ueEsc(a.location_name || '—');
return `<a href="/projects/${_ueEsc(a.project_id)}/nrl/${_ueEsc(a.location_id)}"
class="text-seismo-orange hover:text-seismo-navy"
title="${projLabel}${locLabel}">
📍 ${locLabel}
</a>
<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;
if (n) {
const sign = n.delta_days < 0 ? 'before' : (n.delta_days > 0 ? 'after' : 'within boundary');
const days = Math.abs(n.delta_days);
const daysLabel = days < 1
? `<${(days * 24).toFixed(1)}h`
: `${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>
<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>
</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>`;
}
function renderUnitEventTable(events, total, container, bucket, assignmentsTotal) {
if (!events || events.length === 0) {
let msg;
if (bucket === 'unattributed') {
msg = assignmentsTotal === 0
? 'No assignments yet — every event from this unit is unattributed. Assign it to a project location to start attributing events.'
: '✅ All events for this unit are attributed to a project/location.';
} else if (bucket === 'attributed') {
msg = assignmentsTotal === 0
? 'No assignments yet for this unit.'
: 'No events recorded inside any assignment window with the current filter.';
} else {
msg = 'No events found for this unit with the current filter.';
}
container.innerHTML = `<div class="text-center py-12 text-gray-500 dark:text-gray-400 text-sm">${msg}</div>`;
return;
}
const rows = events.map(ev => {
const ts = ev.timestamp ? ev.timestamp.replace('T', ' ').slice(0, 19) : '—';
const tran = _ueFmtPPV(ev.tran_ppv);
const vert = _ueFmtPPV(ev.vert_ppv);
const lng = _ueFmtPPV(ev.long_ppv);
const pvs = _ueFmtPPV(ev.peak_vector_sum);
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 ${ev.attribution ? '' : 'bg-amber-50/40 dark:bg-amber-900/10'}">
<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.vert_ppv)}">${vert}</td>
<td class="px-4 py-2.5 text-sm font-mono ${_uePpvClass(ev.long_ppv)}">${lng}</td>
<td class="px-4 py-2.5 text-sm font-mono font-semibold ${_uePpvClass(ev.peak_vector_sum)}">${pvs}</td>
<td class="px-4 py-2.5 text-sm">${ft}</td>
<td class="px-4 py-2.5 text-sm">${_ueAttrCell(ev)}</td>
</tr>`;
}).join('');
container.innerHTML = `
<div class="text-xs text-gray-500 dark:text-gray-400 px-4 pt-3 pb-1">Showing ${events.length} of ${total.toLocaleString()} event${total === 1 ? '' : 's'}</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">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">Flags</th>
<th class="px-4 py-3 text-xs font-medium text-gray-700 dark:text-gray-300 uppercase tracking-wider">Attribution</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">${rows}</tbody>
</table>`;
}
// ===== Pair Device Modal Functions ===== // ===== Pair Device Modal Functions =====
let pairModalModems = []; // Cache loaded modems let pairModalModems = []; // Cache loaded modems
let pairModalDeviceType = ''; // Current device type let pairModalDeviceType = ''; // Current device type