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() } }