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
+58 -6
View File
@@ -297,9 +297,9 @@ func TestCollectingIsBroadcast(t *testing.T) {
// later. It forgets any earlier measurement first, so the rate is the one
// asked for rather than a blend with it.
func setFill(te *triggerEngine, span, growth, now float64) {
te.setBuffered(0, false, now)
te.setBuffered(span-growth, true, now)
te.setBuffered(span, true, now+1)
te.setBuffered(0, 0, false, false, now)
te.setBuffered(span-growth, 0, false, true, now)
te.setBuffered(span, 0, false, true, now+1)
}
// What has to hold is that the buffer spans the whole window by the time the
@@ -417,9 +417,9 @@ func TestForceIgnoresFillGate(t *testing.T) {
// interval, so they refresh the span and leave the seeded rate alone.
func seedFillNow(te *triggerEngine, span, growth float64) {
now := float64(time.Now().UnixNano()) / 1e9
te.setBuffered(0, false, now-1)
te.setBuffered(span-growth, true, now-1)
te.setBuffered(span, true, now)
te.setBuffered(0, 0, false, false, now-1)
te.setBuffered(span-growth, 0, false, true, now-1)
te.setBuffered(span, 0, false, true, now)
}
// While it holds off, the trigger looks identical to one that is ignoring
@@ -505,3 +505,55 @@ func drainStates(t *testing.T, h *Hub) []map[string]any {
}
}
}
// A ring whose coverage saturates below the window (measured source rate that
// over-estimates the true one) can never satisfy the full-window need. The
// coverage clamp must open the gate once the ring is full, delivering a short
// capture rather than staying deaf forever.
func TestFillNeedClampedToCoverage(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
setFill(te, 50, 0, 100) // ring full at 50 s, no growth
te.mu.Lock()
te.bufCoverage = 50 // the ring can never reach further back
te.mu.Unlock()
if need := te.fillNeedLocked(); need != 50 {
t.Errorf("need = %v, want 50 (clamped to coverage, not the 60 s window)", need)
}
if f := te.fillLocked(); f < 1 {
t.Errorf("fillLocked = %v, want >= 1: a full ring below the window must still open the gate", f)
}
// Without the clamp the gate would stay shut forever.
te.mu.Lock()
te.bufCoverage = 0
te.mu.Unlock()
if f := te.fillLocked(); f >= 1 {
t.Errorf("baseline: fillLocked = %v, want < 1 without a coverage clamp", f)
}
}
// When the disk archive already spans the window it can back-fill the front of
// a capture, so the gate must only require the ring to have reached the
// pre-window, not the whole window.
func TestFillNeedArchiveLowersToPreWindow(t *testing.T) {
te := newTriggerEngine()
te.SetConfig(trigConfig{signalKey: "s:x", windowSec: 60, prePercent: 20, mode: "normal", holdoffSec: 0.2})
setFill(te, 30, 0, 100) // ring holds only 30 s, no growth → need 60 without archive
te.mu.Lock()
te.bufArchived = true
te.mu.Unlock()
if want := 12.0; te.fillNeedLocked() != want { // 60 * 0.20
t.Errorf("need = %v, want %v (archive lowers to the pre-window)", te.fillNeedLocked(), want)
}
// A ring holding just the pre-window opens the gate once archived.
te.mu.Lock()
te.bufSpan = 12
te.mu.Unlock()
if f := te.fillLocked(); f < 1 {
t.Errorf("fillLocked = %v, want >= 1 with pre-window buffered and the archive available", f)
}
}