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
@@ -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, ",") {
|
||||
|
||||
Reference in New Issue
Block a user