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>
121 lines
4.6 KiB
Go
121 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"embed"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"syscall"
|
|
|
|
"marte2/common/wshub"
|
|
)
|
|
|
|
var buildVersion = "dev"
|
|
|
|
//go:embed static
|
|
var staticFiles embed.FS
|
|
|
|
// multiFlag allows a flag to be repeated: --source a --source b
|
|
type multiFlag []string
|
|
|
|
func (f *multiFlag) String() string { return fmt.Sprintf("%v", []string(*f)) }
|
|
func (f *multiFlag) Set(v string) error { *f = append(*f, v); return nil }
|
|
|
|
// defaultHistoryDir is where samples are archived unless -history-dir says
|
|
// otherwise. History is on by default because it is what holds a trigger
|
|
// capture at full resolution: the in-memory rings roll past a captured window
|
|
// within seconds of it being taken, and a zoom after that has nothing but the
|
|
// capture's own decimated copy to draw. Per-signal files are bounded by
|
|
// -history-max-mpts, so the default costs a fixed amount of space.
|
|
func defaultHistoryDir() string {
|
|
return filepath.Join(os.TempDir(), "udpstreamer-history")
|
|
}
|
|
|
|
func main() {
|
|
var sourceArgs multiFlag
|
|
flag.Var(&sourceArgs, "source", `Data source in the form [label@]host:port[/multicastGroup:dataPort] (repeatable)`)
|
|
sourcesFile := flag.String("sources-file", "", "JSON file for persistent source list (load on start, save target)")
|
|
listenAddr := flag.String("addr", ":8080", "HTTP listen address")
|
|
histDir := flag.String("history-dir", defaultHistoryDir(), "Directory for disk-backed signal history (empty disables it)")
|
|
histWindow := flag.Float64("history-window-sec", 0, "Timespan the history files hold before any client says what it displays (0 keeps the 10 s default); the hub re-sizes them to the live or trigger window afterwards")
|
|
histDecim := flag.Int("history-decimation", 1, "Keep every Nth sample in the history files")
|
|
histFlush := flag.Int("history-flush-sec", 5, "Seconds between history header flushes")
|
|
histMinFree := flag.Int("history-min-free-mb", 500, "Pause history writing below this much free disk (negative disables the check)")
|
|
histMaxMPts := flag.Float64("history-max-mpts", 0, "Per-signal history budget in millions of points, also settable in the web UI (0 keeps the 16 MPts / 256 MB default)")
|
|
ringMPts := flag.Float64("ring-mpts", 0, "Per-signal in-memory buffer in millions of points (0 keeps the 10 MPts / 160 MB default)")
|
|
flag.Parse()
|
|
|
|
hub := wshub.NewHub()
|
|
// The budget bounds memory, not the window: a window too long to hold at the
|
|
// source rate is buffered as min/max pairs rather than truncated to the tail.
|
|
hub.SetRingBudget(int(*ringMPts * 1e6))
|
|
sm := wshub.NewSourceManager(hub, *sourcesFile)
|
|
hub.SetSourceManager(sm)
|
|
|
|
if err := hub.EnableHistory(wshub.HistoryConfig{
|
|
Directory: *histDir,
|
|
WindowSec: *histWindow,
|
|
Decimation: *histDecim,
|
|
FlushIntervalSec: *histFlush,
|
|
MinDiskFreeMB: *histMinFree,
|
|
MaxPointsPerSignal: int(*histMaxMPts * 1e6),
|
|
}); err != nil {
|
|
log.Fatalf("history: %v", err)
|
|
}
|
|
if *histDir == "" {
|
|
log.Print("history disabled: zooming into a trigger capture will fall back " +
|
|
"to the capture's own decimated copy once the rings roll past it")
|
|
} else {
|
|
log.Printf("history: %s", *histDir)
|
|
}
|
|
go hub.Run()
|
|
|
|
// Load sources from file first (if specified), then add any CLI --source flags.
|
|
if *sourcesFile != "" {
|
|
if err := sm.Load(*sourcesFile); err != nil && !errors.Is(err, os.ErrNotExist) {
|
|
log.Printf("sources-file load: %v", err)
|
|
}
|
|
}
|
|
for _, arg := range sourceArgs {
|
|
label, addr, mcastGroup, dataPort := wshub.ParseSourceArgFull(arg)
|
|
sm.Add(label, addr, mcastGroup, dataPort)
|
|
}
|
|
|
|
sub, err := fs.Sub(staticFiles, "static")
|
|
if err != nil {
|
|
log.Fatalf("static sub-fs: %v", err)
|
|
}
|
|
http.Handle("/", http.FileServer(http.FS(sub)))
|
|
http.HandleFunc("/ws", hub.HandleWebSocket)
|
|
http.HandleFunc("/api/zoom", hub.HandleZoom)
|
|
http.HandleFunc("/api/export", hub.HandleExport)
|
|
http.HandleFunc("/version", func(w http.ResponseWriter, r *http.Request) {
|
|
fmt.Fprint(w, buildVersion)
|
|
})
|
|
|
|
log.Printf("UDPStreamer WebUI listening on %s (build=%s)", *listenAddr, buildVersion)
|
|
|
|
// Serve in the background so Ctrl-C can flush the history files: the
|
|
// samples written since the last periodic flush are on disk but are not
|
|
// yet accounted for in the file headers, so exiting outright loses them.
|
|
srvErr := make(chan error, 1)
|
|
go func() { srvErr <- http.ListenAndServe(*listenAddr, nil) }()
|
|
|
|
sig := make(chan os.Signal, 1)
|
|
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
|
select {
|
|
case err := <-srvErr:
|
|
hub.CloseHistory()
|
|
log.Fatalf("http: %v", err)
|
|
case s := <-sig:
|
|
log.Printf("received %s, flushing history", s)
|
|
hub.CloseHistory()
|
|
}
|
|
}
|