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:
co-authored by
Claude Opus 4.6
parent
cf815e1d3f
commit
f334995865
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user