Merge branch 'dev' into feat/ftp-report-pipeline

pulled in the live slm stuff
This commit is contained in:
2026-06-11 01:54:21 +00:00
8 changed files with 632 additions and 112 deletions
+39 -29
View File
@@ -2,7 +2,14 @@
{% if units %}
{% for unit in units %}
<div class="bg-gray-50 dark:bg-gray-800 rounded-lg p-4 border border-transparent hover:border-seismo-orange transition-colors relative">
<div class="absolute top-3 right-3 flex gap-2">
<div class="absolute top-3 right-3 flex gap-2 z-10">
<button onclick="event.preventDefault(); event.stopPropagation(); refreshSlmUnit('{{ unit.id }}', this);"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="Refresh {{ unit.id }} from device">
<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 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
</button>
<button onclick="event.preventDefault(); event.stopPropagation(); showLiveChart('{{ unit.id }}');"
class="text-gray-500 hover:text-seismo-orange dark:text-gray-400 dark:hover:text-seismo-orange"
title="View live chart">
@@ -20,41 +27,44 @@
</button>
</div>
<a href="/slm/{{ unit.id }}" class="block">
<div class="flex items-start justify-between gap-4">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
{% if unit.slm_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
{% endif %}
</div>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
{% elif unit.location %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
<a href="/slm/{{ unit.id }}" class="block pr-24">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="font-semibold text-gray-900 dark:text-white">{{ unit.id }}</span>
{% if unit.slm_model %}
<span class="text-xs text-gray-500 dark:text-gray-400">• {{ unit.slm_model }}</span>
{% endif %}
</div>
{% if unit.retired %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif not unit.deployed %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state == "Start" %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %}
<span class="shrink-0 px-2 py-1 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
{% if unit.address %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.address }}</p>
{% elif unit.location %}
<p class="text-sm text-gray-600 dark:text-gray-400 truncate mt-1">{{ unit.location }}</p>
{% endif %}
</div>
<div class="mt-2 text-xs text-gray-500 dark:text-gray-400">
{% if unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }}
<!-- Status badge + last-check on one line (moved off the top-right so it
no longer collides with the refresh/chart/gear action icons). -->
<div class="mt-2 flex items-center gap-2 flex-wrap">
{% if unit.retired %}
<span class="px-2 py-0.5 text-xs font-medium bg-gray-200 text-gray-700 dark:bg-gray-700 dark:text-gray-300 rounded-full">Retired</span>
{% elif not unit.deployed %}
<span class="px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 rounded-full">Benched</span>
{% elif unit.measurement_state in ["Start", "Measure"] %}
<span class="px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300 rounded-full">Measuring</span>
{% elif unit.is_recent %}
<span class="px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300 rounded-full">Active</span>
{% else %}
No recent check-in
<span class="px-2 py-0.5 text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-300 rounded-full">Idle</span>
{% endif %}
<span class="text-xs text-gray-500 dark:text-gray-400">
{% if unit.cache_last_seen %}
Last check: {{ unit.cache_last_seen|local_datetime }}
{% elif unit.slm_last_check %}
Last check: {{ unit.slm_last_check|local_datetime }}
{% else %}
No recent check-in
{% endif %}
</span>
</div>
</a>
</div>
+94 -9
View File
@@ -143,6 +143,8 @@
</svg>
Stop Live Stream
</button>
<span id="live-feed-status" class="ml-3 self-center" style="display: none;"></span>
</div>
</div>
@@ -432,6 +434,24 @@ function initializeChart() {
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'L1',
data: [],
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
},
{
label: 'L10',
data: [],
borderColor: 'rgb(245, 158, 11)',
backgroundColor: 'rgba(245, 158, 11, 0.1)',
tension: 0.3,
borderWidth: 2,
pointRadius: 0
}
]
},
@@ -493,7 +513,37 @@ if (typeof window.currentWebSocket === 'undefined') {
window.currentWebSocket = null;
}
function initLiveDataStream(unitId) {
// Backfill the chart with the recent DOD trail so it opens with context.
async function backfillChart(unitId) {
try {
const r = await fetch(`/api/slmm/${encodeURIComponent(unitId)}/history?hours=2`);
if (!r.ok) return;
const d = await r.json();
const readings = d.readings || [];
if (!window.chartData) return;
for (const row of readings) {
// Trail timestamps are naive UTC; append 'Z' so they convert to local
// consistently with the live frames (which use local Date.now()).
window.chartData.timestamps.push(row.timestamp ? new Date(row.timestamp + 'Z').toLocaleTimeString() : '');
window.chartData.lp.push(parseFloat(row.lp || 0));
window.chartData.leq.push(parseFloat(row.leq || 0));
window.chartData.ln1.push(parseFloat(row.ln1 || 0));
window.chartData.ln2.push(parseFloat(row.ln2 || 0));
}
if (window.liveChart) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none');
}
} catch (e) {
console.warn('Chart backfill failed:', e);
}
}
async function initLiveDataStream(unitId) {
// Close existing connection if any
if (window.currentWebSocket) {
window.currentWebSocket.close();
@@ -504,17 +554,24 @@ function initLiveDataStream(unitId) {
window.chartData.timestamps = [];
window.chartData.lp = [];
window.chartData.leq = [];
window.chartData.ln1 = [];
window.chartData.ln2 = [];
}
if (window.liveChart && window.liveChart.data && window.liveChart.data.datasets) {
window.liveChart.data.labels = [];
window.liveChart.data.datasets[0].data = [];
window.liveChart.data.datasets[1].data = [];
window.liveChart.data.datasets.forEach(ds => ds.data = []);
window.liveChart.update();
}
// WebSocket URL for SLMM backend via proxy
// Seed the chart with recent history BEFORE opening the live socket, so live
// frames append after the backfill (right order) and the chart isn't blank.
await backfillChart(unitId);
// WebSocket URL for SLMM backend via proxy.
// /monitor = the shared fan-out DOD feed (many viewers, one device connection,
// and it carries L1/L10 which the DRD /stream cannot).
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/live`;
const wsUrl = `${wsProtocol}//${window.location.host}/api/slmm/${unitId}/monitor`;
window.currentWebSocket = new WebSocket(wsUrl);
@@ -530,7 +587,11 @@ function initLiveDataStream(unitId) {
window.currentWebSocket.onmessage = function(event) {
try {
const data = JSON.parse(event.data);
console.log('WebSocket data received:', data);
// The DOD monitor sends keepalive 'heartbeat' frames (no metrics) and a
// 'feed_status' on each frame. Reflect status, but don't let a heartbeat
// or an 'unreachable' frame blank the cards / spike the chart with zeros.
updateFeedStatus(data.feed_status);
if (data.heartbeat || data.feed_status === 'unreachable') return;
updateLiveMetrics(data);
updateLiveChart(data);
} catch (error) {
@@ -559,6 +620,21 @@ function stopLiveDataStream() {
}
}
// Reflect device reachability from the monitor feed's feed_status. Safe no-op
// if the badge element isn't on the page.
function updateFeedStatus(status) {
const el = document.getElementById('live-feed-status');
if (!el || status == null) return;
if (status === 'unreachable') {
el.textContent = 'Device offline';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300';
} else {
el.textContent = 'Live';
el.className = 'text-xs font-medium px-2 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300';
}
el.style.display = '';
}
// Update metrics display
function updateLiveMetrics(data) {
if (document.getElementById('live-lp')) {
@@ -592,7 +668,9 @@ if (typeof window.chartData === 'undefined') {
window.chartData = {
timestamps: [],
lp: [],
leq: []
leq: [],
ln1: [],
ln2: []
};
}
@@ -602,12 +680,17 @@ function updateLiveChart(data) {
window.chartData.timestamps.push(now.toLocaleTimeString());
window.chartData.lp.push(parseFloat(data.lp || 0));
window.chartData.leq.push(parseFloat(data.leq || 0));
window.chartData.ln1.push(parseFloat(data.ln1 || 0));
window.chartData.ln2.push(parseFloat(data.ln2 || 0));
// Keep only last 60 data points
if (window.chartData.timestamps.length > 60) {
// Keep a rolling window large enough to hold the ~2h backfill (one point/min)
// plus a good run of live points before the oldest scroll off.
if (window.chartData.timestamps.length > 600) {
window.chartData.timestamps.shift();
window.chartData.lp.shift();
window.chartData.leq.shift();
window.chartData.ln1.shift();
window.chartData.ln2.shift();
}
// Update chart if available
@@ -615,6 +698,8 @@ function updateLiveChart(data) {
window.liveChart.data.labels = window.chartData.timestamps;
window.liveChart.data.datasets[0].data = window.chartData.lp;
window.liveChart.data.datasets[1].data = window.chartData.leq;
window.liveChart.data.datasets[2].data = window.chartData.ln1;
window.liveChart.data.datasets[3].data = window.chartData.ln2;
window.liveChart.update('none');
}
}
+5 -3
View File
@@ -528,7 +528,7 @@ async function saveSLMSettings(event) {
if (typeof checkFTPStatus === 'function') {
checkFTPStatus(unitId);
}
if (typeof htmx !== 'undefined') {
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load');
}
}, 1500);
@@ -604,8 +604,10 @@ async function toggleSLMDeployed() {
successDiv.classList.remove('hidden');
setTimeout(() => successDiv.classList.add('hidden'), 3000);
// Refresh any SLM list on the page
if (typeof htmx !== 'undefined') {
// Refresh any SLM list on the page (only if one is actually present —
// the detail/dashboard pages have no #slm-list, and htmx.trigger on a
// null target throws "can't access property dispatchEvent, e is null").
if (typeof htmx !== 'undefined' && document.getElementById('slm-list')) {
htmx.trigger('#slm-list', 'load');
}
} catch (error) {