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
+14 -1
View File
@@ -221,6 +221,19 @@ func ringCoverage(bucket, capacity int) int {
return capacity / 2 * bucket
}
// captureLagSec is how much further back than the window itself a ring has to
// reach to deliver a capture of it.
//
// A capture is not read out when its last sample arrives but captureMarginSec
// later, and then only on the next push tick — so by the time the window is
// extracted, its oldest sample is that much deeper in the ring. A ring holding
// exactly the window has already overwritten the front of its own capture, which
// is what made every shot at a short window come back missing its head. The
// pre/post split does not enter into it: the harvest is a post-window after the
// trigger and the read reaches a pre-window before it, so the two sum to the
// window whatever the split.
const captureLagSec = captureMarginSec + 1.0/30.0
// activeWindowSec is the timespan the buffers must cover. An armed trigger owns
// it: its pre-window has to already be in the ring when the trigger fires or
// there is nothing to back-fill the capture from. Otherwise it is the widest
@@ -228,7 +241,7 @@ func ringCoverage(bucket, capacity int) int {
func (h *Hub) activeWindowSec() float64 {
if h.trigger != nil && h.trigger.Active() {
if cfg := h.trigger.Config(); cfg.windowSec > 0 {
return cfg.windowSec
return cfg.windowSec + captureLagSec
}
}
widest := 0.0