Improved uo and added timerarraygam for testing

This commit is contained in:
Martino Ferrari
2026-05-20 00:15:38 +02:00
parent 620542a722
commit 62545503c4
13 changed files with 960 additions and 58 deletions
+97 -5
View File
@@ -156,6 +156,9 @@ type Hub struct {
// ringsMu protects the map structure; each sigRing has its own RWMutex for data.
ringsMu sync.RWMutex
rings map[string]*sigRing // "sourceId:signalKey" → ring
statsMu sync.RWMutex
statsMap map[string]*SourceStat
}
// NewHub creates an initialised Hub.
@@ -168,6 +171,7 @@ func NewHub() *Hub {
dataCh: make(chan taggedSample, 256),
commandCh: make(chan hubCmd, 64),
rings: make(map[string]*sigRing),
statsMap: make(map[string]*SourceStat),
}
}
@@ -321,6 +325,9 @@ func (h *Hub) Run() {
ticker := time.NewTicker(time.Second / 30)
defer ticker.Stop()
statsTicker := time.NewTicker(time.Second)
defer statsTicker.Stop()
sourcesMap := make(map[string]*sourceHubState)
var sourcesMsg []byte
@@ -367,6 +374,9 @@ func (h *Hub) Run() {
connState: "connecting",
timeSigCalib: make(map[string]float64),
}
h.statsMu.Lock()
h.statsMap[cmd.sourceID] = &SourceStat{}
h.statsMu.Unlock()
rebuildSources()
case "removeSource":
@@ -380,6 +390,9 @@ func (h *Hub) Run() {
}
}
h.ringsMu.Unlock()
h.statsMu.Lock()
delete(h.statsMap, cmd.sourceID)
h.statsMu.Unlock()
rebuildSources()
case "setSourceState":
@@ -416,7 +429,7 @@ func (h *Hub) Run() {
}
for _, sig := range cmd.sigs {
ne := sig.NumElements()
isTemporal := ne > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample)
isTemporal := ne > 1 && sig.TimeMode != TimeModePacket
if isTemporal {
h.rings[pfxUpd+sig.Name] = newSigRing(ringCapTemporal)
} else if ne == 1 {
@@ -466,6 +479,18 @@ func (h *Hub) Run() {
h.broadcast(msg)
}
}
case <-statsTicker.C:
h.statsMu.RLock()
snap := make(map[string]StatInfo, len(h.statsMap))
for id, st := range h.statsMap {
snap[id] = st.Snapshot()
}
h.statsMu.RUnlock()
if len(snap) > 0 {
msg, _ := json.Marshal(map[string]any{"type": "stats", "sources": snap})
h.broadcast(msg)
}
}
}
}
@@ -574,14 +599,19 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
for _, sig := range sigs {
n := sig.NumElements()
isTemporal := n > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample)
switch {
case isTemporal:
case n > 1 && (sig.TimeMode == TimeModeFirstSample || sig.TimeMode == TimeModeLastSample):
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
var timeSigName string
// uint64 signals carry nanoseconds (HRT); everything else is assumed microseconds.
timerToSec := 1e-6
if hasTimeSig {
timeSigName = sigs[sig.TimeSignalIdx].Name
ts := sigs[sig.TimeSignalIdx]
timeSigName = ts.Name
if ts.TypeCode == 6 { // uint64 → nanoseconds
timerToSec = 1e-9
}
}
dt := 0.0
if sig.SamplingRate > 0 {
@@ -600,7 +630,7 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
if hasTimeSig {
tVals, tOk := s.Values[timeSigName]
if tOk && len(tVals) >= 1 {
timerS := tVals[0] * 1e-6
timerS := tVals[0] * timerToSec
wallT := float64(s.WallTime.UnixNano()) / 1e9
if _, exists := src.timeSigCalib[timeSigName]; !exists {
src.timeSigCalib[timeSigName] = wallT - timerS
@@ -635,6 +665,58 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
case n > 1 && sig.TimeMode == TimeModeFullArray:
// The time signal has the same N elements as the data signal.
// Each element pair (timeSig[k], dataSig[k]) is one (t, v) sample.
hasTimeSig := sig.TimeSignalIdx != NoTimeSignal && int(sig.TimeSignalIdx) < len(sigs)
var timeSigName string
timerToSec := 1e-6
if hasTimeSig {
ts := sigs[sig.TimeSignalIdx]
timeSigName = ts.Name
if ts.TypeCode == 6 { // uint64 → nanoseconds
timerToSec = 1e-9
}
}
allT := make([]float64, 0, len(batch)*n)
allV := make([]float64, 0, len(batch)*n)
for _, s := range batch {
vals, ok := s.Values[sig.Name]
if !ok || len(vals) < n {
continue
}
if hasTimeSig {
tVals, tOk := s.Values[timeSigName]
if tOk && len(tVals) >= n {
// Calibrate once: map timer ticks to wall-clock seconds.
if _, exists := src.timeSigCalib[timeSigName]; !exists {
wallT := float64(s.WallTime.UnixNano()) / 1e9
src.timeSigCalib[timeSigName] = wallT - tVals[0]*timerToSec
}
calib := src.timeSigCalib[timeSigName]
for k := 0; k < n; k++ {
allT = append(allT, calib+tVals[k]*timerToSec)
allV = append(allV, vals[k])
}
continue
}
}
// Fallback: stamp all elements with packet arrival time.
wallT := float64(s.WallTime.UnixNano()) / 1e9
for k := 0; k < n; k++ {
allT = append(allT, wallT)
allV = append(allV, vals[k])
}
}
ringT, ringV := lttbDecimate(allT, allV, maxRingPoints)
if rb := h.getRing(pfx + sig.Name); rb != nil {
rb.write(ringT, ringV)
}
decimT, decimV := lttbDecimate(allT, allV, maxPushPoints)
out[pfx+sig.Name] = sigData{T: decimT, V: decimV}
case n == 1:
ts := make([]float64, 0, len(batch))
vs := make([]float64, 0, len(batch))
@@ -681,6 +763,16 @@ func (h *Hub) buildDataMessageForSource(src *sourceHubState, batch []DataSample)
return result
}
// RecordDataFragment is called by UDPClient for every incoming DATA datagram.
func (h *Hub) RecordDataFragment(sourceID string, counter uint32, nBytes int, arrivalNs int64, complete bool) {
h.statsMu.RLock()
st := h.statsMap[sourceID]
h.statsMu.RUnlock()
if st != nil {
st.RecordFragment(counter, nBytes, arrivalNs, complete)
}
}
// arrayKey returns the buffer key for element i of an array signal.
func arrayKey(name string, i int) string {
return name + "[" + itoa(i) + "]"
+179 -26
View File
@@ -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 => {
+10
View File
@@ -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>
+64
View File
@@ -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%);
+155
View File
@@ -0,0 +1,155 @@
package main
import (
"math"
"sync"
)
const statRingSize = 512
// SourceStat accumulates per-source UDP performance metrics.
// Thread-safe; RecordFragment is called from the UDPClient goroutine.
type SourceStat struct {
mu sync.Mutex
seenFirst bool
lastCounter uint32
TotalRx uint64
TotalLost uint64
// Cycle-time ring (seconds between consecutive DATA completions)
ctRing [statRingSize]float64
ctHead int
ctFull bool
lastRxNs int64
// Per-cycle accumulators (reset after each DATA completion)
fragCount int
byteCount int
fragRing [statRingSize]int
byteRing [statRingSize]int
}
// RecordFragment is called for every UDP datagram of a DATA packet.
// complete: this fragment completed the DATA reassembly.
// nBytes: raw datagram size (header+payload).
func (s *SourceStat) RecordFragment(counter uint32, nBytes int, arrivalNs int64, complete bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.fragCount++
s.byteCount += nBytes
if !complete {
return
}
s.TotalRx++
if s.seenFirst {
if delta := counter - s.lastCounter; delta > 1 {
s.TotalLost += uint64(delta - 1)
}
} else {
s.seenFirst = true
}
s.lastCounter = counter
if s.lastRxNs != 0 {
idx := s.ctHead
s.ctRing[idx] = float64(arrivalNs-s.lastRxNs) * 1e-9
s.fragRing[idx] = s.fragCount
s.byteRing[idx] = s.byteCount
s.ctHead = (s.ctHead + 1) % statRingSize
if s.ctHead == 0 {
s.ctFull = true
}
}
s.lastRxNs = arrivalNs
s.fragCount = 0
s.byteCount = 0
}
// Snapshot computes and returns a StatInfo for broadcast.
func (s *SourceStat) Snapshot() StatInfo {
s.mu.Lock()
defer s.mu.Unlock()
n := s.ctHead
if s.ctFull {
n = statRingSize
}
si := StatInfo{TotalReceived: s.TotalRx, TotalLost: s.TotalLost}
if n == 0 {
return si
}
sum, sumSq := 0.0, 0.0
minV, maxV := math.MaxFloat64, 0.0
fragSum, byteSum := 0, 0
for i := 0; i < n; i++ {
v := s.ctRing[i]
sum += v
sumSq += v * v
if v < minV {
minV = v
}
if v > maxV {
maxV = v
}
fragSum += s.fragRing[i]
byteSum += s.byteRing[i]
}
avg := sum / float64(n)
variance := sumSq/float64(n) - avg*avg
if variance < 0 {
variance = 0
}
stdv := math.Sqrt(variance)
si.CycleAvgMs = avg * 1e3
si.CycleStdMs = stdv * 1e3
si.CycleMinMs = minV * 1e3
si.CycleMaxMs = maxV * 1e3
si.RateHz = 1.0 / avg
si.RateStdHz = stdv / (avg * avg)
si.FragsPerCycle = float64(fragSum) / float64(n)
si.BytesPerCycle = float64(byteSum) / float64(n)
const nBins = 20
si.CycleHistMin = minV * 1e3
si.CycleHistMax = maxV * 1e3
si.CycleHist = make([]int, nBins)
span := maxV - minV
for i := 0; i < n; i++ {
var bin int
if span > 0 {
bin = int((s.ctRing[i] - minV) / span * float64(nBins))
if bin >= nBins {
bin = nBins - 1
}
} else {
bin = nBins / 2
}
si.CycleHist[bin]++
}
return si
}
// StatInfo is the JSON snapshot for one source sent to the frontend.
type StatInfo struct {
TotalReceived uint64 `json:"totalReceived"`
TotalLost uint64 `json:"totalLost"`
RateHz float64 `json:"rateHz"`
RateStdHz float64 `json:"rateStdHz"`
FragsPerCycle float64 `json:"fragsPerCycle"`
BytesPerCycle float64 `json:"bytesPerCycle"`
CycleAvgMs float64 `json:"cycleAvgMs"`
CycleStdMs float64 `json:"cycleStdMs"`
CycleMinMs float64 `json:"cycleMinMs"`
CycleMaxMs float64 `json:"cycleMaxMs"`
CycleHist []int `json:"cycleHist"`
CycleHistMin float64 `json:"cycleHistMin"`
CycleHistMax float64 `json:"cycleHistMax"`
}
+3
View File
@@ -110,6 +110,9 @@ func (u *UDPClient) runSession() error {
copy(payload, buf[HeaderSize:n])
complete, ok := reassembler.AddFragment(hdr, payload)
if hdr.Type == PktData {
u.hub.RecordDataFragment(u.sourceID, hdr.Counter, n, arrivalTime.UnixNano(), ok)
}
if !ok {
continue
}