Improved uo and added timerarraygam for testing
This commit is contained in:
+179
-26
@@ -111,6 +111,7 @@ function connectWS() {
|
||||
if (msg.type === 'sources') onSources(msg);
|
||||
else if (msg.type === 'config') onConfig(msg);
|
||||
else if (msg.type === 'data') onData(msg);
|
||||
else if (msg.type === 'stats') onStats(msg);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -745,6 +746,26 @@ function drawCursorLines(u, p) {
|
||||
drawLine(cursors.tB, 'rgba(249,226,175,0.85)', 'B');
|
||||
}
|
||||
|
||||
// Compute the rolling-window anchor ("newest common timestamp") for a plot.
|
||||
// Returns the min-of-max timestamp across all sources contributing traces to p,
|
||||
// so no source shows a blank right edge. Falls back to Date.now()/1000 if no data.
|
||||
function computePlotNow(p) {
|
||||
const sourceNewest = {};
|
||||
p.traces.forEach(key => {
|
||||
const colon = key.indexOf(':');
|
||||
if (colon < 0) return;
|
||||
const srcId = key.slice(0, colon);
|
||||
const buf = buffers[key];
|
||||
if (!buf || buf.size === 0) return;
|
||||
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
if (sourceNewest[srcId] === undefined || t > sourceNewest[srcId]) sourceNewest[srcId] = t;
|
||||
});
|
||||
const srcVals = Object.values(sourceNewest);
|
||||
let now = srcVals.length > 0 ? Math.min(...srcVals) : -Infinity;
|
||||
if (!isFinite(now)) now = Date.now() / 1000;
|
||||
return now;
|
||||
}
|
||||
|
||||
// Build uPlot opts for a given plot object
|
||||
function makeUPlotOpts(p, inTrigMode) {
|
||||
const seriesArr = [{}]; // time (index 0)
|
||||
@@ -774,11 +795,16 @@ function makeUPlotOpts(p, inTrigMode) {
|
||||
},
|
||||
select: { show: true },
|
||||
scales: {
|
||||
x: {
|
||||
time: false, auto: false,
|
||||
min: p.xRange ? p.xRange[0] : 0,
|
||||
max: p.xRange ? p.xRange[1] : windowSec
|
||||
},
|
||||
x: (() => {
|
||||
let xMin, xMax;
|
||||
if (p.xRange) {
|
||||
xMin = p.xRange[0]; xMax = p.xRange[1];
|
||||
} else {
|
||||
const now = computePlotNow(p);
|
||||
xMin = now - windowSec; xMax = now;
|
||||
}
|
||||
return { time: false, auto: false, min: xMin, max: xMax };
|
||||
})(),
|
||||
y: { auto: true },
|
||||
},
|
||||
series: seriesArr,
|
||||
@@ -1005,16 +1031,8 @@ function resampleLinear(tSrc, vSrc, tDst) {
|
||||
function buildLiveData(p) {
|
||||
if (p.traces.length === 0) return [new Float64Array(0)];
|
||||
|
||||
// plotNow = newest timestamp across ALL traces
|
||||
let plotNow = -Infinity;
|
||||
p.traces.forEach(key => {
|
||||
const buf = buffers[key];
|
||||
if (buf && buf.size > 0) {
|
||||
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
if (t > plotNow) plotNow = t;
|
||||
}
|
||||
});
|
||||
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
|
||||
// plotNow = min(newest per source) so no source shows a blank right edge.
|
||||
const plotNow = computePlotNow(p);
|
||||
|
||||
const t0 = p.xRange ? p.xRange[0] : plotNow - windowSec;
|
||||
const t1 = p.xRange ? p.xRange[1] : plotNow;
|
||||
@@ -1564,7 +1582,10 @@ function applyLayout(cls) {
|
||||
// Recreate all uPlot instances once the CSS grid has sized the cells.
|
||||
// This also updates axis visibility (which axes show labels depends on grid position).
|
||||
requestAnimationFrame(() => {
|
||||
plots.forEach(p => createUPlot(p));
|
||||
plots.forEach(p => {
|
||||
createUPlot(p);
|
||||
p.needsRedraw = true; // force immediate data+scale refresh so x/y ranges are preserved
|
||||
});
|
||||
setTimeout(() => plots.forEach(p => {
|
||||
if (p.uplot) p.uplot.setSize({ width: p.div.clientWidth, height: p.div.clientHeight });
|
||||
}), 60);
|
||||
@@ -1888,16 +1909,8 @@ function renderDirtyPlots() {
|
||||
// Zoomed: re-apply so scale is correct after setData
|
||||
p.uplot.setScale('x', { min: p.xRange[0], max: p.xRange[1] });
|
||||
} else {
|
||||
// Rolling window: compute plotNow fresh each frame (same anchor as buildLiveData)
|
||||
// and set the scale immediately after setData for correct alignment.
|
||||
let plotNow = -Infinity;
|
||||
p.traces.forEach(key => {
|
||||
const buf = buffers[key];
|
||||
if (!buf || buf.size === 0) return;
|
||||
const t = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
if (t > plotNow) plotNow = t;
|
||||
});
|
||||
if (!isFinite(plotNow)) plotNow = Date.now() / 1000;
|
||||
// Rolling window: use same anchor as buildLiveData (min-of-max per source).
|
||||
const plotNow = computePlotNow(p);
|
||||
p.uplot.setScale('x', { min: plotNow - windowSec, max: plotNow });
|
||||
}
|
||||
zoomGuard = false;
|
||||
@@ -1965,6 +1978,7 @@ function onSources(msg) {
|
||||
}
|
||||
});
|
||||
buildSidebar();
|
||||
if (statsOpen) _refreshStatsSelector();
|
||||
}
|
||||
|
||||
function addSourceWS(label, addr) {
|
||||
@@ -2110,6 +2124,139 @@ function initSignalMenu() {
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') hideSignalMenu(); });
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Source Statistics panel
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
const sourceStats = {}; // sourceId → StatInfo (from backend 1 Hz message)
|
||||
let statsOpen = false;
|
||||
let statsSelectedSrc = null; // currently displayed source id
|
||||
|
||||
function onStats(msg) {
|
||||
const incoming = msg.sources || {};
|
||||
Object.keys(incoming).forEach(id => { sourceStats[id] = incoming[id]; });
|
||||
if (statsOpen) renderStats();
|
||||
}
|
||||
|
||||
// Rebuild the source selector options; preserve selection when possible.
|
||||
function _refreshStatsSelector() {
|
||||
const sel = document.getElementById('stats-source-sel');
|
||||
if (!sel) return;
|
||||
const prev = statsSelectedSrc;
|
||||
sel.innerHTML = '';
|
||||
const srcs = Object.values(sourcesMap);
|
||||
srcs.forEach(src => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = src.id;
|
||||
opt.textContent = src.label || src.id;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
// Restore previous selection or default to first
|
||||
if (prev && sourcesMap[prev]) {
|
||||
sel.value = prev;
|
||||
} else if (srcs.length > 0) {
|
||||
sel.value = srcs[0].id;
|
||||
}
|
||||
statsSelectedSrc = sel.value || null;
|
||||
}
|
||||
|
||||
// Frontend latency for one source: wallNow − newest calibrated buffer timestamp.
|
||||
function sourceLatencyMs(srcId) {
|
||||
const prefix = srcId + ':';
|
||||
const wallNow = Date.now() / 1000;
|
||||
let best = null;
|
||||
Object.keys(buffers).forEach(key => {
|
||||
if (!key.startsWith(prefix)) return;
|
||||
const buf = buffers[key];
|
||||
if (!buf || buf.size === 0) return;
|
||||
const newest = buf.t[(buf.head - 1 + buf.cap) % buf.cap];
|
||||
const lag = (wallNow - newest) * 1000;
|
||||
if (best === null || lag < best) best = lag;
|
||||
});
|
||||
return best;
|
||||
}
|
||||
|
||||
function _fmtMs(v) { return v != null && isFinite(v) ? v.toFixed(2) + ' ms' : '—'; }
|
||||
function _fmtHz(v) { return v != null && isFinite(v) && v > 0 ? v.toFixed(2) + ' Hz' : '—'; }
|
||||
function _fmtKB(v) { return v != null && isFinite(v) ? (v / 1024).toFixed(2) + ' KB' : '—'; }
|
||||
|
||||
function _statsKV(label, value, cls) {
|
||||
return `<div class="stats-kv"><span class="stats-k">${label}</span><span class="stats-v${cls ? ' ' + cls : ''}">${value}</span></div>`;
|
||||
}
|
||||
|
||||
function _histHTML(si) {
|
||||
if (!si.cycleHist || !si.cycleHist.length) return '';
|
||||
const maxC = Math.max(...si.cycleHist, 1);
|
||||
const bars = si.cycleHist.map(c => {
|
||||
const pct = Math.max(Math.round((c / maxC) * 100), 1);
|
||||
return `<div class="hist-bar" style="height:${pct}%" title="${c} samples"></div>`;
|
||||
}).join('');
|
||||
return `<div class="stats-hist">
|
||||
<div class="hist-bars">${bars}</div>
|
||||
<div class="hist-labels"><span>${si.cycleHistMin.toFixed(3)}</span><span>${si.cycleHistMax.toFixed(3)} ms</span></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const body = document.getElementById('stats-body');
|
||||
if (!body) return;
|
||||
|
||||
const src = statsSelectedSrc ? sourcesMap[statsSelectedSrc] : null;
|
||||
if (!src) {
|
||||
body.innerHTML = '<span class="stats-empty">No source selected</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const si = sourceStats[src.id];
|
||||
const latMs = sourceLatencyMs(src.id);
|
||||
const lossColor = si && si.totalLost > 0 ? 'warn' : 'ok';
|
||||
const lossText = si ? `${si.totalLost} / ${si.totalReceived}` : '—';
|
||||
|
||||
body.innerHTML = `
|
||||
<div class="stats-section">
|
||||
<div class="stats-section-label">Connection</div>
|
||||
<div class="stats-row">
|
||||
${_statsKV('Address', src.addr)}
|
||||
${_statsKV('Latency', _fmtMs(latMs))}
|
||||
${_statsKV('Lost / Rx', lossText, lossColor)}
|
||||
${_statsKV('Loss %', si && si.totalReceived > 0 ? (si.totalLost / si.totalReceived * 100).toFixed(2) + ' %' : '—', si && si.totalLost > 0 ? 'warn' : 'ok')}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="stats-sep">
|
||||
<div class="stats-section">
|
||||
<div class="stats-section-label">Cycle rate</div>
|
||||
<div class="stats-row">
|
||||
${_statsKV('avg', si ? _fmtHz(si.rateHz) : '—')}
|
||||
${_statsKV('± σ', si ? _fmtHz(si.rateStdHz) : '—')}
|
||||
${_statsKV('Pkts / cycle', si ? si.fragsPerCycle.toFixed(1) : '—')}
|
||||
${_statsKV('KB / cycle', si ? _fmtKB(si.bytesPerCycle) : '—')}
|
||||
</div>
|
||||
</div>
|
||||
<hr class="stats-sep">
|
||||
<div class="stats-section stats-section-grow">
|
||||
<div class="stats-section-label">Cycle time histogram</div>
|
||||
<div class="stats-row" style="margin-bottom:6px">
|
||||
${_statsKV('avg', si ? _fmtMs(si.cycleAvgMs) : '—')}
|
||||
${_statsKV('σ', si ? _fmtMs(si.cycleStdMs) : '—')}
|
||||
${_statsKV('min', si ? _fmtMs(si.cycleMinMs) : '—')}
|
||||
${_statsKV('max', si ? _fmtMs(si.cycleMaxMs) : '—')}
|
||||
</div>
|
||||
${si ? _histHTML(si) : '<span class="stats-empty">No data yet</span>'}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleStats() {
|
||||
statsOpen = !statsOpen;
|
||||
document.getElementById('stats-panel').classList.toggle('open', statsOpen);
|
||||
document.getElementById('btn-stats').classList.toggle('active', statsOpen);
|
||||
if (statsOpen) {
|
||||
_refreshStatsSelector();
|
||||
renderStats();
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh latency and stats every second while panel is open.
|
||||
setInterval(() => { if (statsOpen) renderStats(); }, 1000);
|
||||
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
Init
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
@@ -2118,6 +2265,12 @@ applyLayout('l1x1');
|
||||
buildSidebar(); // show "Add Source" section even before WS connection
|
||||
initSignalMenu();
|
||||
document.getElementById('btn-csv-all').addEventListener('click', exportAllCSV);
|
||||
document.getElementById('btn-stats').addEventListener('click', toggleStats);
|
||||
document.getElementById('btn-stats-close').addEventListener('click', toggleStats);
|
||||
document.getElementById('stats-source-sel').addEventListener('change', e => {
|
||||
statsSelectedSrc = e.target.value || null;
|
||||
renderStats();
|
||||
});
|
||||
connectWS();
|
||||
requestAnimationFrame(renderDirtyPlots);
|
||||
fetch('/version').then(r => r.text()).then(v => {
|
||||
|
||||
@@ -97,12 +97,22 @@
|
||||
<div id="plot-grid" class="l1x1"></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- ── Stats panel ─────────────────────────────────────────────── -->
|
||||
<div id="stats-panel">
|
||||
<div id="stats-panel-hdr">
|
||||
<span class="stats-hdr-label">Source Statistics</span>
|
||||
<select id="stats-source-sel" class="stats-source-sel"></select>
|
||||
<button id="btn-stats-close">✕</button>
|
||||
</div>
|
||||
<div id="stats-body"></div>
|
||||
</div>
|
||||
<!-- ── Status bar ─────────────────────────────────────────────── -->
|
||||
<div id="statusbar">
|
||||
<div id="sb-left">
|
||||
<div id="status-led"></div>
|
||||
<span id="status-text">Disconnected</span>
|
||||
<span id="sb-tsage"></span>
|
||||
<button id="btn-stats" class="ctrl-btn" style="height:16px;padding:0 7px;font-size:10px;line-height:1">📊 Stats</button>
|
||||
</div>
|
||||
<span id="build-version"></span>
|
||||
</div>
|
||||
|
||||
@@ -377,6 +377,70 @@ input[type=range].trig-range::-webkit-slider-thumb {
|
||||
.save-src-btn { color:var(--green); }
|
||||
.save-src-btn:hover { background:rgba(166,227,161,0.1); border-color:var(--green); }
|
||||
|
||||
/* ── Stats panel ─────────────────────────────────────────────── */
|
||||
#stats-panel {
|
||||
position:fixed; bottom:var(--statusbar-h); left:0; right:0;
|
||||
height:0; overflow:hidden;
|
||||
background:var(--mantle); border-top:2px solid var(--surface0);
|
||||
z-index:89; /* below topbar / sidebar */
|
||||
transition:height var(--transition);
|
||||
}
|
||||
#stats-panel.open { height:290px; }
|
||||
#stats-panel-hdr {
|
||||
display:flex; align-items:center; gap:8px;
|
||||
padding:4px 10px; border-bottom:1px solid var(--surface0); flex-shrink:0;
|
||||
font-size:11px; font-weight:700; color:var(--subtext1); letter-spacing:0.5px; text-transform:uppercase;
|
||||
}
|
||||
.stats-hdr-label { flex-shrink:0; }
|
||||
.stats-source-sel {
|
||||
flex:1; min-width:0;
|
||||
background:var(--surface0); border:1px solid var(--surface1); border-radius:var(--radius);
|
||||
color:var(--text); font-size:11px; padding:1px 5px; cursor:pointer;
|
||||
}
|
||||
.stats-source-sel:focus { outline:none; border-color:var(--accent); }
|
||||
#btn-stats-close {
|
||||
background:none; border:none; color:var(--overlay0); flex-shrink:0;
|
||||
cursor:pointer; font-size:14px; line-height:1; padding:2px;
|
||||
transition:color var(--transition);
|
||||
}
|
||||
#btn-stats-close:hover { color:var(--red); }
|
||||
#stats-body {
|
||||
overflow-x:hidden; overflow-y:auto;
|
||||
display:flex; flex-direction:column; gap:0;
|
||||
padding:8px 18px;
|
||||
height:calc(290px - 30px); box-sizing:border-box;
|
||||
}
|
||||
.stats-section {
|
||||
display:flex; flex-direction:column; gap:5px; padding:4px 0;
|
||||
}
|
||||
.stats-section-grow { flex:1; }
|
||||
.stats-empty { font-size:11px; color:var(--overlay0); }
|
||||
.stats-section-label {
|
||||
font-size:9px; color:var(--overlay0); text-transform:uppercase; letter-spacing:0.6px;
|
||||
}
|
||||
.stats-row { display:flex; gap:16px; flex-wrap:wrap; align-items:flex-end; }
|
||||
.stats-kv { display:flex; flex-direction:column; gap:1px; min-width:70px; }
|
||||
.stats-k { font-size:9px; color:var(--overlay0); text-transform:uppercase; letter-spacing:0.5px; }
|
||||
.stats-v { font-size:12px; color:var(--text); font-family:monospace; font-weight:600; }
|
||||
.stats-v.warn { color:var(--yellow); }
|
||||
.stats-v.ok { color:var(--green); }
|
||||
.stats-sep { border:none; border-top:1px solid var(--surface0); margin:2px 0; }
|
||||
/* Histogram */
|
||||
.stats-hist { width:100%; }
|
||||
.hist-bars {
|
||||
display:flex; align-items:flex-end; gap:1px; height:72px;
|
||||
background:var(--crust); border-radius:3px; padding:2px 3px;
|
||||
}
|
||||
.hist-bar {
|
||||
flex:1; background:var(--accent); border-radius:1px 1px 0 0; min-height:1px;
|
||||
opacity:0.65; transition:opacity 0.1s;
|
||||
}
|
||||
.hist-bar:hover { opacity:1; }
|
||||
.hist-labels {
|
||||
display:flex; justify-content:space-between;
|
||||
font-size:9px; color:var(--overlay0); font-family:monospace; margin-top:2px;
|
||||
}
|
||||
|
||||
/* ── Empty state ──────────────────────────────────────────────── */
|
||||
#empty-state {
|
||||
position:absolute; top:50%; left:50%; transform:translate(-50%,-50%);
|
||||
|
||||
Reference in New Issue
Block a user