feat(webui): sporadic-trigger capture, CSV export and UI rework

Brings the Go hub and web SPA work developed on feature/udpscope onto
main, without the udpscope client itself.

The trigger engine could not capture a sporadic event: it armed on the
live tail only, so a burst shorter than one push window was already past
by the time the FSM looked for it. It now searches the ring history for
the crossing, which also makes a capture reproducible from the same data
rather than dependent on push timing (wshub/trigger.go, ringbuf.go,
history.go).

Adds CSV/JSON export of the visible window (wshub/export.go) and reworks
the SPA: per-signal axis controls, a readable trigger panel, and a fix
for the flicker caused by repainting on every push instead of on a frame
tick (static/app.js, index.html, style.css).

BUFFER_AND_TRIGGER.md documents the ring/decimation/trigger interaction,
which is otherwise only inferable from the three files that implement it.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Martino Ferrari
2026-09-02 01:18:46 +02:00
co-authored by Claude Opus 4.6
parent cf815e1d3f
commit f334995865
18 changed files with 1980 additions and 164 deletions
+26 -17
View File
@@ -422,6 +422,26 @@ func (hw *historyWriter) window() float64 {
return hw.windowSec
}
// coversWindow reports whether the archive file for key currently spans at
// least sec seconds. When true, backfillCaptureHead can reconstruct a capture's
// front out of the archive, so the trigger need not wait for the ring to cover
// the whole window on its own.
func (hw *historyWriter) coversWindow(key string, sec float64) bool {
if !hw.enabled() || !(sec > 0) {
return false
}
hw.mu.RLock()
hf, ok := hw.files[key]
hw.mu.RUnlock()
if !ok {
return false
}
hf.mu.RLock()
span := hf.tNewest - hf.tOldest
hf.mu.RUnlock()
return span >= sec
}
// setWindow points the archive at the timespan the clients are looking at, and
// re-sizes the files that no longer match it. It reports whether any file's
// geometry changed, which invalidates what clients know about the archive.
@@ -842,10 +862,7 @@ func (hf *histFile) readAfter(after, t0, t1 float64, max int) ([]byte, float64,
// The run wraps at most once, so it costs at most two reads.
buf := make([]byte, n*histPairSize)
start := (oldest + lo) % capacity
head := int(capacity-start) * histPairSize
if head > len(buf) {
head = len(buf)
}
head := min(int(capacity-start)*histPairSize, len(buf))
if _, err := hf.f.ReadAt(buf[:head], int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
return nil, 0, err
}
@@ -898,10 +915,8 @@ func (hf *histFile) writePairs(t, v []float64) error {
binary.LittleEndian.PutUint64(buf[i*histPairSize+8:], math.Float64bits(v[i]))
}
first := int(hf.capacity - hf.head)
if first > n {
first = n
}
first := min(int(hf.capacity-hf.head), n)
off := int64(histHeaderSize) + int64(hf.head)*histPairSize
if _, err := hf.f.WriteAt(buf[:first*histPairSize], off); err != nil {
return err
@@ -1089,10 +1104,7 @@ func (hw *historyWriter) readRange(key string, t0, t1 float64, maxOut int) ([]fl
// Read in contiguous runs: the range wraps at most once.
buf := make([]byte, n*histPairSize)
start := (oldest + lo) % capacity
first := int(capacity - start)
if first > n {
first = n
}
first := min(int(capacity-start), n)
if _, err := hf.f.ReadAt(buf[:first*histPairSize],
int64(histHeaderSize)+int64(start)*histPairSize); err != nil {
return nil, nil
@@ -1265,7 +1277,7 @@ func (h *Hub) handleSetHistoryBudget(env map[string]interface{}) {
// handleHistoryZoom answers a historyZoom request from disk. Same request and
// reply shape as "zoom", so clients can fall back to it transparently when a
// window reaches further back than the in-memory rings hold.
func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]interface{}) {
func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]any) {
if !h.hist.enabled() {
msg, _ := json.Marshal(map[string]any{
"type": "historyZoom", "reqId": env["reqId"],
@@ -1287,10 +1299,7 @@ func (h *Hub) handleHistoryZoom(c *wsClient, env map[string]interface{}) {
// oversampled relative to the plot's point budget and thinned afterwards.
// The cap keeps a request for "no decimation" over a multi-hour window from
// pulling the whole file into memory.
readCap := n * histReadOversample
if readCap > histMaxReadPoints {
readCap = histMaxReadPoints
}
readCap := min(n*histReadOversample, histDefaultMaxPoints)
signals := make(map[string]sigData)
for _, k := range strings.Split(sigCSV, ",") {