From 8851d996f21c571915f29048acc14d070637da31 Mon Sep 17 00:00:00 2001 From: you Date: Sun, 5 Apr 2026 07:15:43 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20CoreScope=20TUI=20MVP=20=E2=80=94?= =?UTF-8?q?=20terminal=20dashboard=20+=20live=20packet=20feed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-view bubbletea TUI that connects to any CoreScope instance: View 1 - Fleet Dashboard: - Polls /api/observers/metrics/summary every 5s - Table: Observer, NF(dBm), Avg NF, Max NF, Battery, Samples - Sorted by worst noise floor first - Color coded: green (normal), yellow (>-100), red (>-85) View 2 - Live Packet Feed: - WebSocket connection to /ws - 500-packet ring buffer - Shows timestamp, type, observer, hops, RSSI/SNR, channel text - Auto-reconnect with exponential backoff (1s→30s) Navigation: Tab/1/2 to switch views, q to quit CLI: corescope-tui --url http://localhost:3000 Refs #609 --- cmd/tui/.gitignore | 1 + cmd/tui/go.mod | 30 +++ cmd/tui/go.sum | 47 ++++ cmd/tui/main.go | 560 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 638 insertions(+) create mode 100644 cmd/tui/.gitignore create mode 100644 cmd/tui/go.mod create mode 100644 cmd/tui/go.sum create mode 100644 cmd/tui/main.go diff --git a/cmd/tui/.gitignore b/cmd/tui/.gitignore new file mode 100644 index 00000000..66ec11ee --- /dev/null +++ b/cmd/tui/.gitignore @@ -0,0 +1 @@ +corescope-tui diff --git a/cmd/tui/go.mod b/cmd/tui/go.mod new file mode 100644 index 00000000..c5014c14 --- /dev/null +++ b/cmd/tui/go.mod @@ -0,0 +1,30 @@ +module github.com/corescope/tui + +go 1.22 + +require ( + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/gorilla/websocket v1.5.3 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sync v0.11.0 // indirect + golang.org/x/sys v0.30.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/cmd/tui/go.sum b/cmd/tui/go.sum new file mode 100644 index 00000000..72db3fd1 --- /dev/null +++ b/cmd/tui/go.sum @@ -0,0 +1,47 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= +golang.org/x/sync v0.11.0 h1:GGz8+XQP4FvTTrjZPzNKTMFtSXH80RAzG+5ghFPgK9w= +golang.org/x/sync v0.11.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/cmd/tui/main.go b/cmd/tui/main.go new file mode 100644 index 00000000..2d30c12d --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,560 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io" + "math" + "net/http" + "net/url" + "os" + "sort" + "strings" + "sync" + "time" + + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/gorilla/websocket" +) + +// --- Data types --- + +type ObserverSummary struct { + ObserverID string `json:"observer_id"` + ObserverName *string `json:"observer_name"` + CurrentNF *float64 `json:"current_noise_floor"` + AvgNF *float64 `json:"avg_noise_floor_24h"` + MaxNF *float64 `json:"max_noise_floor_24h"` + BatteryMv *int `json:"battery_mv"` + SampleCount int `json:"sample_count"` +} + +type Packet struct { + Timestamp string + Type string + ObserverName string + Hops string + RSSI string + SNR string + ChannelText string +} + +// --- Messages --- + +type summaryMsg []ObserverSummary +type summaryErrMsg struct{ err error } +type packetMsg Packet +type wsStatusMsg string +type tickMsg time.Time + +// --- Model --- + +type view int + +const ( + viewDashboard view = iota + viewLiveFeed +) + +type model struct { + baseURL string + currentView view + width int + height int + + // Dashboard + observers []ObserverSummary + lastRefresh time.Time + fetchErr error + + // Live feed + packets []Packet + wsStatus string + packetsMu sync.Mutex + packetChan chan Packet + wsDone chan struct{} +} + +func initialModel(baseURL string) model { + return model{ + baseURL: strings.TrimRight(baseURL, "/"), + packets: make([]Packet, 0, 500), + wsStatus: "disconnected", + packetChan: make(chan Packet, 100), + wsDone: make(chan struct{}), + } +} + +// --- Styles --- + +var ( + titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")) + greenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("42")) + yellowStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("226")) + redStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + dimStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + statusStyle = lipgloss.NewStyle().Background(lipgloss.Color("236")).Foreground(lipgloss.Color("252")).Padding(0, 1) + tabActive = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("69")).Underline(true) + tabInactive = lipgloss.NewStyle().Foreground(lipgloss.Color("241")) + headerStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("252")) +) + +// --- Commands --- + +func fetchSummary(baseURL string) tea.Cmd { + return func() tea.Msg { + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Get(baseURL + "/api/observers/metrics/summary?window=24h") + if err != nil { + return summaryErrMsg{err} + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return summaryErrMsg{err} + } + var result []ObserverSummary + if err := json.Unmarshal(body, &result); err != nil { + return summaryErrMsg{fmt.Errorf("json: %w (body: %.100s)", err, string(body))} + } + return summaryMsg(result) + } +} + +func tickEvery(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +func listenForPackets(ch <-chan Packet) tea.Cmd { + return func() tea.Msg { + p := <-ch + return packetMsg(p) + } +} + +// --- WebSocket goroutine --- + +func connectWS(baseURL string, packetChan chan<- Packet, statusChan chan<- string, done <-chan struct{}) { + u, err := url.Parse(baseURL) + if err != nil { + statusChan <- "invalid url" + return + } + scheme := "ws" + if u.Scheme == "https" { + scheme = "wss" + } + wsURL := scheme + "://" + u.Host + "/ws" + + backoff := time.Second + maxBackoff := 30 * time.Second + + for { + select { + case <-done: + return + default: + } + + statusChan <- "connecting..." + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + statusChan <- fmt.Sprintf("error: %v", err) + select { + case <-done: + return + case <-time.After(backoff): + } + backoff = time.Duration(math.Min(float64(backoff)*2, float64(maxBackoff))) + continue + } + + statusChan <- "connected" + backoff = time.Second + + for { + select { + case <-done: + conn.Close() + return + default: + } + + _, message, err := conn.ReadMessage() + if err != nil { + statusChan <- "disconnected" + conn.Close() + break + } + + pkt := parseWSMessage(message) + if pkt != nil { + select { + case packetChan <- *pkt: + default: // drop if buffer full + } + } + } + } +} + +func parseWSMessage(data []byte) *Packet { + var msg map[string]interface{} + if err := json.Unmarshal(data, &msg); err != nil { + return nil + } + + pkt := &Packet{} + + // Timestamp + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + if ts, ok := decoded["timestamp"].(string); ok { + if t, err := time.Parse(time.RFC3339, ts); err == nil { + pkt.Timestamp = t.Format("15:04:05") + } else { + pkt.Timestamp = ts + } + } + } + if pkt.Timestamp == "" { + if packet, ok := msg["packet"].(map[string]interface{}); ok { + if ts, ok := packet["timestamp"].(string); ok { + if t, err := time.Parse(time.RFC3339, ts); err == nil { + pkt.Timestamp = t.Format("15:04:05") + } else { + pkt.Timestamp = ts[:8] + } + } + } + } + if pkt.Timestamp == "" { + pkt.Timestamp = time.Now().Format("15:04:05") + } + + // Type + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + if t, ok := decoded["type"].(string); ok { + pkt.Type = t + } + } + if pkt.Type == "" { + if packet, ok := msg["packet"].(map[string]interface{}); ok { + if t, ok := packet["type"].(string); ok { + pkt.Type = t + } + } + } + if pkt.Type == "" { + pkt.Type = "UNKNOWN" + } + + // Observer name + if packet, ok := msg["packet"].(map[string]interface{}); ok { + if name, ok := packet["observer_name"].(string); ok { + pkt.ObserverName = name + } else if name, ok := packet["observer_id"].(string); ok { + pkt.ObserverName = name[:8] + } + } + + // Hops + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + if hops, ok := decoded["hops"].(float64); ok { + pkt.Hops = fmt.Sprintf("%d", int(hops)) + } + } + + // RSSI / SNR + if packet, ok := msg["packet"].(map[string]interface{}); ok { + if rssi, ok := packet["rssi"].(float64); ok { + pkt.RSSI = fmt.Sprintf("%.0f", rssi) + } + if snr, ok := packet["snr"].(float64); ok { + pkt.SNR = fmt.Sprintf("%.1f", snr) + } + } + + // Channel text + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + ch := "" + if name, ok := decoded["channel_name"].(string); ok { + ch = "#" + name + } + if text, ok := decoded["text"].(string); ok { + if ch != "" { + pkt.ChannelText = ch + " " + truncate(text, 40) + } else { + pkt.ChannelText = truncate(text, 40) + } + } + } + + return pkt +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +// --- Init / Update / View --- + +func (m model) Init() tea.Cmd { + // Start WS in background + statusChan := make(chan string, 10) + go connectWS(m.baseURL, m.packetChan, statusChan, m.wsDone) + go func() { + for s := range statusChan { + m.packetChan <- Packet{Type: "__status__", ObserverName: s} + } + }() + + return tea.Batch( + fetchSummary(m.baseURL), + tickEvery(5*time.Second), + listenForPackets(m.packetChan), + ) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch msg.String() { + case "q", "ctrl+c": + close(m.wsDone) + return m, tea.Quit + case "tab", "1": + if m.currentView == viewDashboard { + m.currentView = viewLiveFeed + } else { + m.currentView = viewDashboard + } + case "2": + m.currentView = viewLiveFeed + } + + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + + case summaryMsg: + m.observers = []ObserverSummary(msg) + m.lastRefresh = time.Now() + m.fetchErr = nil + + case summaryErrMsg: + m.fetchErr = msg.err + + case tickMsg: + return m, tea.Batch( + fetchSummary(m.baseURL), + tickEvery(5*time.Second), + ) + + case packetMsg: + p := Packet(msg) + if p.Type == "__status__" { + m.wsStatus = p.ObserverName + return m, listenForPackets(m.packetChan) + } + m.packets = append(m.packets, p) + if len(m.packets) > 500 { + m.packets = m.packets[len(m.packets)-500:] + } + return m, listenForPackets(m.packetChan) + } + + return m, nil +} + +func (m model) View() string { + var b strings.Builder + + // Title + b.WriteString(titleStyle.Render("šŸ„ CoreScope TUI")) + b.WriteString("\n") + + // Tabs + dash := tabInactive.Render("[1:Dashboard]") + live := tabInactive.Render("[2:Live Feed]") + if m.currentView == viewDashboard { + dash = tabActive.Render("[1:Dashboard]") + } else { + live = tabActive.Render("[2:Live Feed]") + } + b.WriteString(dash + " " + live + "\n\n") + + // Content + switch m.currentView { + case viewDashboard: + b.WriteString(m.viewDashboard()) + case viewLiveFeed: + b.WriteString(m.viewLiveFeed()) + } + + // Status bar + b.WriteString("\n") + wsIcon := "ā—" + wsColor := redStyle + if m.wsStatus == "connected" { + wsColor = greenStyle + } else if m.wsStatus == "connecting..." { + wsColor = yellowStyle + } + status := fmt.Sprintf(" WS: %s %s │ View: %s │ %s │ q:quit Tab:switch", + wsColor.Render(wsIcon), m.wsStatus, + viewName(m.currentView), + m.baseURL, + ) + b.WriteString(statusStyle.Render(status)) + + return b.String() +} + +func viewName(v view) string { + if v == viewDashboard { + return "Dashboard" + } + return "Live Feed" +} + +func (m model) viewDashboard() string { + var b strings.Builder + + if m.fetchErr != nil { + b.WriteString(redStyle.Render(fmt.Sprintf("Error: %v", m.fetchErr))) + b.WriteString("\n\n") + } + + // Sort by worst noise floor (highest = worst) + observers := make([]ObserverSummary, len(m.observers)) + copy(observers, m.observers) + sort.Slice(observers, func(i, j int) bool { + ni, nj := nfVal(observers[i].CurrentNF), nfVal(observers[j].CurrentNF) + return ni > nj // worst first + }) + + refreshStr := "" + if !m.lastRefresh.IsZero() { + refreshStr = m.lastRefresh.Format("15:04:05") + } + b.WriteString(fmt.Sprintf("Observers: %d │ Last refresh: %s\n\n", + len(observers), refreshStr)) + + // Header + b.WriteString(headerStyle.Render(fmt.Sprintf("%-24s %8s %8s %8s %10s %8s", + "Observer", "NF(dBm)", "Avg NF", "Max NF", "Battery", "Samples"))) + b.WriteString("\n") + b.WriteString(dimStyle.Render(strings.Repeat("─", 78))) + b.WriteString("\n") + + for _, o := range observers { + name := o.ObserverID[:8] + if o.ObserverName != nil && *o.ObserverName != "" { + name = truncate(*o.ObserverName, 24) + } + + nf := fmtNF(o.CurrentNF) + avg := fmtNF(o.AvgNF) + maxnf := fmtNF(o.MaxNF) + batt := "—" + if o.BatteryMv != nil { + batt = fmt.Sprintf("%dmV", *o.BatteryMv) + } + + // Color code NF + nfStyle := greenStyle + if o.CurrentNF != nil { + if *o.CurrentNF > -85 { + nfStyle = redStyle + } else if *o.CurrentNF > -100 { + nfStyle = yellowStyle + } + } + + line := fmt.Sprintf("%-24s %8s %8s %8s %10s %8d", + name, nfStyle.Render(nf), avg, maxnf, batt, o.SampleCount) + b.WriteString(line + "\n") + } + + return b.String() +} + +func nfVal(nf *float64) float64 { + if nf == nil { + return -999 + } + return *nf +} + +func fmtNF(nf *float64) string { + if nf == nil { + return "—" + } + return fmt.Sprintf("%.1f", *nf) +} + +func (m model) viewLiveFeed() string { + var b strings.Builder + + b.WriteString(fmt.Sprintf("Packets: %d/500 │ WS: %s\n\n", len(m.packets), m.wsStatus)) + + b.WriteString(headerStyle.Render(fmt.Sprintf("%-10s %-10s %-20s %5s %6s %6s %s", + "Time", "Type", "Observer", "Hops", "RSSI", "SNR", "Channel/Text"))) + b.WriteString("\n") + b.WriteString(dimStyle.Render(strings.Repeat("─", 85))) + b.WriteString("\n") + + // Show last N packets that fit the screen + maxLines := 20 + if m.height > 10 { + maxLines = m.height - 10 + } + start := 0 + if len(m.packets) > maxLines { + start = len(m.packets) - maxLines + } + + for _, p := range m.packets[start:] { + typeStyle := dimStyle + switch p.Type { + case "ADVERT": + typeStyle = greenStyle + case "GRP_TXT", "TXT_MSG": + typeStyle = yellowStyle + case "REQ": + typeStyle = redStyle + } + + line := fmt.Sprintf("%-10s %s %-20s %5s %6s %6s %s", + dimStyle.Render(p.Timestamp), + typeStyle.Render(fmt.Sprintf("%-10s", p.Type)), + truncate(p.ObserverName, 20), + p.Hops, p.RSSI, p.SNR, + dimStyle.Render(p.ChannelText), + ) + b.WriteString(line + "\n") + } + + return b.String() +} + +// --- Main --- + +func main() { + urlFlag := flag.String("url", "http://localhost:3000", "CoreScope server URL") + flag.Parse() + + m := initialModel(*urlFlag) + p := tea.NewProgram(m, tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} From 3e39776178365af38d7f8b90fd394bd4cc85d2e2 Mon Sep 17 00:00:00 2001 From: you Date: Sun, 5 Apr 2026 07:25:54 +0000 Subject: [PATCH 2/4] fix: TUI goroutine leaks, WS reconnect, ring buffer GC, panic recovery - Fix goroutine leak: statusChan goroutine in Init() never terminated. Replaced separate statusChan+packetChan with unified wsMsgChan that carries both wsStatusMsg and packetMsg as tea.Msg values. - Fix WS goroutine unable to exit on quit: ReadMessage blocked indefinitely. Added 2s read deadline so the done channel is checked periodically. - Add panic recovery in connectWS goroutine. - Fix ring buffer GC leak: old slicing kept backing array alive. Now copies to fresh slice when trimming. - Fix potential panic: ObserverID[:8] on short IDs. Added safePrefix(). - Fix potential panic: ts[:8] on short timestamp strings. - Send graceful WebSocket close frame on quit. - Remove unused sync.Mutex field. - Handle wsStatusMsg as proper tea.Msg type instead of sentinel packet. --- cmd/tui/main.go | 189 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 131 insertions(+), 58 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 2d30c12d..6edd19b4 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -11,7 +11,6 @@ import ( "os" "sort" "strings" - "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -58,6 +57,9 @@ const ( viewLiveFeed ) +// ringBufferMax is the maximum number of packets kept in the live feed. +const ringBufferMax = 500 + type model struct { baseURL string currentView view @@ -70,20 +72,21 @@ type model struct { fetchErr error // Live feed - packets []Packet - wsStatus string - packetsMu sync.Mutex - packetChan chan Packet - wsDone chan struct{} + packets []Packet + // wsMsgChan multiplexes packets and status updates from the WS goroutine + // into the bubbletea event loop. + wsMsgChan chan tea.Msg + wsStatus string + wsDone chan struct{} } func initialModel(baseURL string) model { return model{ - baseURL: strings.TrimRight(baseURL, "/"), - packets: make([]Packet, 0, 500), - wsStatus: "disconnected", - packetChan: make(chan Packet, 100), - wsDone: make(chan struct{}), + baseURL: strings.TrimRight(baseURL, "/"), + packets: make([]Packet, 0, ringBufferMax), + wsStatus: "disconnected", + wsMsgChan: make(chan tea.Msg, 100), + wsDone: make(chan struct{}), } } @@ -129,19 +132,39 @@ func tickEvery(d time.Duration) tea.Cmd { }) } -func listenForPackets(ch <-chan Packet) tea.Cmd { +// listenForWSMsg waits for the next message from the WebSocket goroutine and +// delivers it into the bubbletea event loop. Returns nil when the channel is +// closed (program shutting down). +func listenForWSMsg(ch <-chan tea.Msg) tea.Cmd { return func() tea.Msg { - p := <-ch - return packetMsg(p) + msg, ok := <-ch + if !ok { + return nil + } + return msg } } // --- WebSocket goroutine --- -func connectWS(baseURL string, packetChan chan<- Packet, statusChan chan<- string, done <-chan struct{}) { +// connectWS manages the WebSocket connection with exponential backoff reconnect. +// It sends packetMsg and wsStatusMsg on msgChan. It returns when done is closed. +func connectWS(baseURL string, msgChan chan<- tea.Msg, done <-chan struct{}) { + defer func() { + if r := recover(); r != nil { + select { + case msgChan <- wsStatusMsg(fmt.Sprintf("panic: %v", r)): + default: + } + } + }() + u, err := url.Parse(baseURL) if err != nil { - statusChan <- "invalid url" + select { + case msgChan <- wsStatusMsg("invalid url"): + case <-done: + } return } scheme := "ws" @@ -160,10 +183,10 @@ func connectWS(baseURL string, packetChan chan<- Packet, statusChan chan<- strin default: } - statusChan <- "connecting..." + sendStatus(msgChan, done, "connecting...") conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) if err != nil { - statusChan <- fmt.Sprintf("error: %v", err) + sendStatus(msgChan, done, fmt.Sprintf("error: %v", err)) select { case <-done: return @@ -173,35 +196,79 @@ func connectWS(baseURL string, packetChan chan<- Packet, statusChan chan<- strin continue } - statusChan <- "connected" + sendStatus(msgChan, done, "connected") backoff = time.Second - for { - select { - case <-done: - conn.Close() - return - default: - } + // readLoop reads messages until error or done. We use a read deadline + // so that ReadMessage unblocks periodically, letting us check done. + func() { + defer conn.Close() + for { + select { + case <-done: + // Send a graceful close frame before returning. + _ = conn.WriteMessage( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + ) + return + default: + } - _, message, err := conn.ReadMessage() - if err != nil { - statusChan <- "disconnected" - conn.Close() - break - } + // Set a deadline so ReadMessage doesn't block forever, allowing + // us to re-check the done channel. + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + _, message, err := conn.ReadMessage() + if err != nil { + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + sendStatus(msgChan, done, "disconnected") + return + } + // Timeout is expected — just loop back to check done. + if netErr, ok := err.(*websocket.CloseError); ok { + sendStatus(msgChan, done, fmt.Sprintf("closed: %d", netErr.Code)) + return + } + if isTimeoutError(err) { + continue + } + sendStatus(msgChan, done, "disconnected") + return + } - pkt := parseWSMessage(message) - if pkt != nil { - select { - case packetChan <- *pkt: - default: // drop if buffer full + pkt := parseWSMessage(message) + if pkt != nil { + select { + case msgChan <- packetMsg(*pkt): + case <-done: + return + } } } - } + }() + } +} + +// sendStatus sends a wsStatusMsg, respecting cancellation. +func sendStatus(msgChan chan<- tea.Msg, done <-chan struct{}, status string) { + select { + case msgChan <- wsStatusMsg(status): + case <-done: } } +// isTimeoutError checks if an error is a network timeout (read deadline exceeded). +func isTimeoutError(err error) bool { + // net.Error has a Timeout() method. + type timeout interface { + Timeout() bool + } + if t, ok := err.(timeout); ok { + return t.Timeout() + } + return false +} + func parseWSMessage(data []byte) *Packet { var msg map[string]interface{} if err := json.Unmarshal(data, &msg); err != nil { @@ -225,8 +292,10 @@ func parseWSMessage(data []byte) *Packet { if ts, ok := packet["timestamp"].(string); ok { if t, err := time.Parse(time.RFC3339, ts); err == nil { pkt.Timestamp = t.Format("15:04:05") - } else { + } else if len(ts) >= 8 { pkt.Timestamp = ts[:8] + } else { + pkt.Timestamp = ts } } } @@ -257,7 +326,7 @@ func parseWSMessage(data []byte) *Packet { if name, ok := packet["observer_name"].(string); ok { pkt.ObserverName = name } else if name, ok := packet["observer_id"].(string); ok { - pkt.ObserverName = name[:8] + pkt.ObserverName = safePrefix(name, 8) } } @@ -303,22 +372,23 @@ func truncate(s string, n int) string { return s[:n-1] + "…" } +// safePrefix returns the first n bytes of s, or s itself if shorter. +func safePrefix(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} + // --- Init / Update / View --- func (m model) Init() tea.Cmd { - // Start WS in background - statusChan := make(chan string, 10) - go connectWS(m.baseURL, m.packetChan, statusChan, m.wsDone) - go func() { - for s := range statusChan { - m.packetChan <- Packet{Type: "__status__", ObserverName: s} - } - }() + go connectWS(m.baseURL, m.wsMsgChan, m.wsDone) return tea.Batch( fetchSummary(m.baseURL), tickEvery(5*time.Second), - listenForPackets(m.packetChan), + listenForWSMsg(m.wsMsgChan), ) } @@ -357,17 +427,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { tickEvery(5*time.Second), ) + case wsStatusMsg: + m.wsStatus = string(msg) + return m, listenForWSMsg(m.wsMsgChan) + case packetMsg: p := Packet(msg) - if p.Type == "__status__" { - m.wsStatus = p.ObserverName - return m, listenForPackets(m.packetChan) - } m.packets = append(m.packets, p) - if len(m.packets) > 500 { - m.packets = m.packets[len(m.packets)-500:] + if len(m.packets) > ringBufferMax { + // Copy to a new slice so the old backing array can be GC'd. + trimmed := make([]Packet, ringBufferMax) + copy(trimmed, m.packets[len(m.packets)-ringBufferMax:]) + m.packets = trimmed } - return m, listenForPackets(m.packetChan) + return m, listenForWSMsg(m.wsMsgChan) } return m, nil @@ -455,7 +528,7 @@ func (m model) viewDashboard() string { b.WriteString("\n") for _, o := range observers { - name := o.ObserverID[:8] + name := safePrefix(o.ObserverID, 8) if o.ObserverName != nil && *o.ObserverName != "" { name = truncate(*o.ObserverName, 24) } @@ -503,7 +576,7 @@ func fmtNF(nf *float64) string { func (m model) viewLiveFeed() string { var b strings.Builder - b.WriteString(fmt.Sprintf("Packets: %d/500 │ WS: %s\n\n", len(m.packets), m.wsStatus)) + b.WriteString(fmt.Sprintf("Packets: %d/%d │ WS: %s\n\n", len(m.packets), ringBufferMax, m.wsStatus)) b.WriteString(headerStyle.Render(fmt.Sprintf("%-10s %-10s %-20s %5s %6s %6s %s", "Time", "Type", "Observer", "Hops", "RSSI", "SNR", "Channel/Text"))) From 12b8c176f117422192c8ab7cb8f6c676de8a1ab0 Mon Sep 17 00:00:00 2001 From: you Date: Sun, 5 Apr 2026 07:29:52 +0000 Subject: [PATCH 3/4] fix: address 4 must-fix review items from Carmack 1. Goroutine stall: always return listenForWSMsg() cmd from Update, even for unhandled message types, preventing wsMsgChan blocking. 2. Double-close panic: wrap close(m.wsDone) in sync.Once to prevent panic on repeated quit key presses. 3. Ring buffer allocations: replace slice append+copy with fixed-size array using head/tail indices. Zero allocations in steady state. 4. Unbounded HTTP read: wrap resp.Body with io.LimitReader(1MB) on the summary endpoint to cap memory usage. --- cmd/tui/main.go | 50 +++++++++++++++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 20 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 6edd19b4..55a4cc15 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -11,6 +11,7 @@ import ( "os" "sort" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -71,19 +72,21 @@ type model struct { lastRefresh time.Time fetchErr error - // Live feed - packets []Packet + // Live feed — ring buffer with head/tail indices, no allocations in steady state. + ringBuf [ringBufferMax]Packet + ringHead int // index of oldest element + ringLen int // number of elements in the buffer // wsMsgChan multiplexes packets and status updates from the WS goroutine // into the bubbletea event loop. - wsMsgChan chan tea.Msg - wsStatus string - wsDone chan struct{} + wsMsgChan chan tea.Msg + wsStatus string + wsDone chan struct{} + wsCloseOnce sync.Once } func initialModel(baseURL string) model { return model{ baseURL: strings.TrimRight(baseURL, "/"), - packets: make([]Packet, 0, ringBufferMax), wsStatus: "disconnected", wsMsgChan: make(chan tea.Msg, 100), wsDone: make(chan struct{}), @@ -114,7 +117,7 @@ func fetchSummary(baseURL string) tea.Cmd { return summaryErrMsg{err} } defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) if err != nil { return summaryErrMsg{err} } @@ -397,7 +400,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyMsg: switch msg.String() { case "q", "ctrl+c": - close(m.wsDone) + m.wsCloseOnce.Do(func() { close(m.wsDone) }) return m, tea.Quit case "tab", "1": if m.currentView == viewDashboard { @@ -425,6 +428,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch( fetchSummary(m.baseURL), tickEvery(5*time.Second), + listenForWSMsg(m.wsMsgChan), ) case wsStatusMsg: @@ -433,17 +437,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case packetMsg: p := Packet(msg) - m.packets = append(m.packets, p) - if len(m.packets) > ringBufferMax { - // Copy to a new slice so the old backing array can be GC'd. - trimmed := make([]Packet, ringBufferMax) - copy(trimmed, m.packets[len(m.packets)-ringBufferMax:]) - m.packets = trimmed + // Ring buffer: write at (head+len) % cap, no allocations. + if m.ringLen < ringBufferMax { + m.ringBuf[(m.ringHead+m.ringLen)%ringBufferMax] = p + m.ringLen++ + } else { + // Overwrite oldest, advance head. + m.ringBuf[m.ringHead] = p + m.ringHead = (m.ringHead + 1) % ringBufferMax } return m, listenForWSMsg(m.wsMsgChan) } - return m, nil + // Always keep the WS listener running, even for unhandled messages. + return m, listenForWSMsg(m.wsMsgChan) } func (m model) View() string { @@ -576,7 +583,7 @@ func fmtNF(nf *float64) string { func (m model) viewLiveFeed() string { var b strings.Builder - b.WriteString(fmt.Sprintf("Packets: %d/%d │ WS: %s\n\n", len(m.packets), ringBufferMax, m.wsStatus)) + b.WriteString(fmt.Sprintf("Packets: %d/%d │ WS: %s\n\n", m.ringLen, ringBufferMax, m.wsStatus)) b.WriteString(headerStyle.Render(fmt.Sprintf("%-10s %-10s %-20s %5s %6s %6s %s", "Time", "Type", "Observer", "Hops", "RSSI", "SNR", "Channel/Text"))) @@ -589,12 +596,15 @@ func (m model) viewLiveFeed() string { if m.height > 10 { maxLines = m.height - 10 } - start := 0 - if len(m.packets) > maxLines { - start = len(m.packets) - maxLines + // Calculate visible range from the ring buffer (most recent packets). + visible := m.ringLen + if visible > maxLines { + visible = maxLines } + startIdx := m.ringLen - visible // offset from oldest - for _, p := range m.packets[start:] { + for i := 0; i < visible; i++ { + p := m.ringBuf[(m.ringHead+startIdx+i)%ringBufferMax] typeStyle := dimStyle switch p.Type { case "ADVERT": From b0c9ff9b2b5951aac73f20c62a82640c6da5db90 Mon Sep 17 00:00:00 2001 From: you Date: Sun, 5 Apr 2026 14:32:18 +0000 Subject: [PATCH 4/4] fix: 3 critical bugs + 5 non-blocking review items Critical fixes: 1. API endpoint: /api/observers/metrics/summary doesn't exist in prod. Use /api/observers which returns observer data with noise_floor, battery_mv, packet_count, last_seen. Unwrap {observers:[...]} wrapper. 2. WS dead connection detection: add ping/pong keepalive (30s ping, 60s read deadline reset on pong). Replaces 2s polling deadline with proper keepalive that detects dead connections reliably. 3. WS packet parsing: server sends {type:'packet',data:{...}} envelope. parseWSMessage now unwraps the envelope and reads fields from the correct locations: decoded.header.payloadTypeName for type, top-level rssi/snr/observer_name, decoded.payload for text/hops. Non-blocking items (from Carmack review): A. Render coalescing: 16ms tick (60fps cap) decouples packet ingestion from rendering. Packets accumulate in Update, View only re-renders on renderTickMsg. B+D. Rune-aware truncation: truncate() and safePrefix() use []rune(s) for safe UTF-8 handling instead of byte slicing. E. Dashboard sort moved from View to Update: observers pre-sorted when data arrives, not on every render call. --- cmd/tui/main.go | 251 +++++++++++++++++++++++++++++------------------- 1 file changed, 152 insertions(+), 99 deletions(-) diff --git a/cmd/tui/main.go b/cmd/tui/main.go index 55a4cc15..d26c6159 100644 --- a/cmd/tui/main.go +++ b/cmd/tui/main.go @@ -22,13 +22,12 @@ import ( // --- Data types --- type ObserverSummary struct { - ObserverID string `json:"observer_id"` - ObserverName *string `json:"observer_name"` - CurrentNF *float64 `json:"current_noise_floor"` - AvgNF *float64 `json:"avg_noise_floor_24h"` - MaxNF *float64 `json:"max_noise_floor_24h"` + ObserverID string `json:"id"` + ObserverName *string `json:"name"` + NoiseFloor *float64 `json:"noise_floor"` BatteryMv *int `json:"battery_mv"` - SampleCount int `json:"sample_count"` + PacketCount int `json:"packet_count"` + LastSeen string `json:"last_seen"` } type Packet struct { @@ -48,6 +47,7 @@ type summaryErrMsg struct{ err error } type packetMsg Packet type wsStatusMsg string type tickMsg time.Time +type renderTickMsg time.Time // --- Model --- @@ -76,6 +76,7 @@ type model struct { ringBuf [ringBufferMax]Packet ringHead int // index of oldest element ringLen int // number of elements in the buffer + dirty bool // true when new data arrived since last render tick // wsMsgChan multiplexes packets and status updates from the WS goroutine // into the bubbletea event loop. wsMsgChan chan tea.Msg @@ -112,7 +113,7 @@ var ( func fetchSummary(baseURL string) tea.Cmd { return func() tea.Msg { client := &http.Client{Timeout: 10 * time.Second} - resp, err := client.Get(baseURL + "/api/observers/metrics/summary?window=24h") + resp, err := client.Get(baseURL + "/api/observers") if err != nil { return summaryErrMsg{err} } @@ -121,11 +122,14 @@ func fetchSummary(baseURL string) tea.Cmd { if err != nil { return summaryErrMsg{err} } - var result []ObserverSummary - if err := json.Unmarshal(body, &result); err != nil { + // The API returns {"observers": [...]} + var wrapper struct { + Observers []ObserverSummary `json:"observers"` + } + if err := json.Unmarshal(body, &wrapper); err != nil { return summaryErrMsg{fmt.Errorf("json: %w (body: %.100s)", err, string(body))} } - return summaryMsg(result) + return summaryMsg(wrapper.Observers) } } @@ -135,6 +139,13 @@ func tickEvery(d time.Duration) tea.Cmd { }) } +// renderTick fires every 16ms (~60fps) to coalesce packet renders. +func renderTick() tea.Cmd { + return tea.Tick(16*time.Millisecond, func(t time.Time) tea.Msg { + return renderTickMsg(t) + }) +} + // listenForWSMsg waits for the next message from the WebSocket goroutine and // delivers it into the bubbletea event loop. Returns nil when the channel is // closed (program shutting down). @@ -202,10 +213,39 @@ func connectWS(baseURL string, msgChan chan<- tea.Msg, done <-chan struct{}) { sendStatus(msgChan, done, "connected") backoff = time.Second - // readLoop reads messages until error or done. We use a read deadline - // so that ReadMessage unblocks periodically, letting us check done. + // readLoop reads messages until error or done. + // Ping/pong keepalive detects dead connections faster than relying on + // read deadline alone. We send pings every 30s; the pong handler resets + // the read deadline to 60s. If no pong arrives, ReadMessage times out. func() { defer conn.Close() + + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + conn.SetPongHandler(func(string) error { + conn.SetReadDeadline(time.Now().Add(60 * time.Second)) + return nil + }) + + // Periodic ping goroutine + pingDone := make(chan struct{}) + defer close(pingDone) + go func() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + if err := conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + case <-pingDone: + return + case <-done: + return + } + } + }() + for { select { case <-done: @@ -218,9 +258,11 @@ func connectWS(baseURL string, msgChan chan<- tea.Msg, done <-chan struct{}) { default: } - // Set a deadline so ReadMessage doesn't block forever, allowing - // us to re-check the done channel. - conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + // ReadMessage blocks until data arrives or the 60s read deadline + // expires. The pong handler resets the deadline on each pong. + // On timeout (dead connection), we break out and reconnect. + // We don't set a per-read deadline here — the pong handler and + // initial SetReadDeadline above manage it. _, message, err := conn.ReadMessage() if err != nil { if websocket.IsCloseError(err, websocket.CloseNormalClosure) { @@ -272,50 +314,45 @@ func isTimeoutError(err error) bool { return false } +// parseWSMessage parses a WebSocket broadcast frame. +// The server sends: {"type":"packet","data":{...}} where data contains +// top-level fields (observer_name, rssi, snr, timestamp, ...) plus +// nested "decoded" (with header.payloadTypeName, payload) and "packet". func parseWSMessage(data []byte) *Packet { - var msg map[string]interface{} - if err := json.Unmarshal(data, &msg); err != nil { + var envelope map[string]interface{} + if err := json.Unmarshal(data, &envelope); err != nil { + return nil + } + + // Unwrap the {"type":"packet","data":{...}} envelope + if t, _ := envelope["type"].(string); t != "packet" { + return nil // ignore non-packet messages (e.g. "status") + } + msg, ok := envelope["data"].(map[string]interface{}) + if !ok { return nil } pkt := &Packet{} - // Timestamp - if decoded, ok := msg["decoded"].(map[string]interface{}); ok { - if ts, ok := decoded["timestamp"].(string); ok { - if t, err := time.Parse(time.RFC3339, ts); err == nil { - pkt.Timestamp = t.Format("15:04:05") - } else { - pkt.Timestamp = ts - } - } - } - if pkt.Timestamp == "" { - if packet, ok := msg["packet"].(map[string]interface{}); ok { - if ts, ok := packet["timestamp"].(string); ok { - if t, err := time.Parse(time.RFC3339, ts); err == nil { - pkt.Timestamp = t.Format("15:04:05") - } else if len(ts) >= 8 { - pkt.Timestamp = ts[:8] - } else { - pkt.Timestamp = ts - } - } + // Timestamp — prefer top-level, fall back to nested packet + if ts, ok := msg["timestamp"].(string); ok { + if t, err := time.Parse(time.RFC3339, ts); err == nil { + pkt.Timestamp = t.Format("15:04:05") + } else if len(ts) >= 8 { + pkt.Timestamp = ts[:8] + } else { + pkt.Timestamp = ts } } if pkt.Timestamp == "" { pkt.Timestamp = time.Now().Format("15:04:05") } - // Type + // Type — from decoded.header.payloadTypeName (matches live.js) if decoded, ok := msg["decoded"].(map[string]interface{}); ok { - if t, ok := decoded["type"].(string); ok { - pkt.Type = t - } - } - if pkt.Type == "" { - if packet, ok := msg["packet"].(map[string]interface{}); ok { - if t, ok := packet["type"].(string); ok { + if header, ok := decoded["header"].(map[string]interface{}); ok { + if t, ok := header["payloadTypeName"].(string); ok { pkt.Type = t } } @@ -325,42 +362,42 @@ func parseWSMessage(data []byte) *Packet { } // Observer name - if packet, ok := msg["packet"].(map[string]interface{}); ok { - if name, ok := packet["observer_name"].(string); ok { - pkt.ObserverName = name - } else if name, ok := packet["observer_id"].(string); ok { - pkt.ObserverName = safePrefix(name, 8) - } + if name, ok := msg["observer_name"].(string); ok { + pkt.ObserverName = name + } else if id, ok := msg["observer_id"].(string); ok { + pkt.ObserverName = safePrefix(id, 8) } - // Hops + // Hops — from decoded.payload.hops or path if decoded, ok := msg["decoded"].(map[string]interface{}); ok { - if hops, ok := decoded["hops"].(float64); ok { - pkt.Hops = fmt.Sprintf("%d", int(hops)) + if payload, ok := decoded["payload"].(map[string]interface{}); ok { + if hops, ok := payload["hops"].(float64); ok { + pkt.Hops = fmt.Sprintf("%d", int(hops)) + } } } - // RSSI / SNR - if packet, ok := msg["packet"].(map[string]interface{}); ok { - if rssi, ok := packet["rssi"].(float64); ok { - pkt.RSSI = fmt.Sprintf("%.0f", rssi) - } - if snr, ok := packet["snr"].(float64); ok { - pkt.SNR = fmt.Sprintf("%.1f", snr) - } + // RSSI / SNR — top-level fields + if rssi, ok := msg["rssi"].(float64); ok { + pkt.RSSI = fmt.Sprintf("%.0f", rssi) + } + if snr, ok := msg["snr"].(float64); ok { + pkt.SNR = fmt.Sprintf("%.1f", snr) } - // Channel text + // Channel text — from decoded.payload if decoded, ok := msg["decoded"].(map[string]interface{}); ok { - ch := "" - if name, ok := decoded["channel_name"].(string); ok { - ch = "#" + name - } - if text, ok := decoded["text"].(string); ok { - if ch != "" { - pkt.ChannelText = ch + " " + truncate(text, 40) - } else { - pkt.ChannelText = truncate(text, 40) + if payload, ok := decoded["payload"].(map[string]interface{}); ok { + ch := "" + if name, ok := payload["channel_name"].(string); ok { + ch = "#" + name + } + if text, ok := payload["text"].(string); ok { + if ch != "" { + pkt.ChannelText = ch + " " + truncate(text, 40) + } else { + pkt.ChannelText = truncate(text, 40) + } } } } @@ -369,18 +406,20 @@ func parseWSMessage(data []byte) *Packet { } func truncate(s string, n int) string { - if len(s) <= n { + runes := []rune(s) + if len(runes) <= n { return s } - return s[:n-1] + "…" + return string(runes[:n-1]) + "…" } -// safePrefix returns the first n bytes of s, or s itself if shorter. +// safePrefix returns the first n characters of s (rune-aware), or s if shorter. func safePrefix(s string, n int) string { - if len(s) <= n { + runes := []rune(s) + if len(runes) <= n { return s } - return s[:n] + return string(runes[:n]) } // --- Init / Update / View --- @@ -392,6 +431,7 @@ func (m model) Init() tea.Cmd { fetchSummary(m.baseURL), tickEvery(5*time.Second), listenForWSMsg(m.wsMsgChan), + renderTick(), ) } @@ -418,6 +458,10 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case summaryMsg: m.observers = []ObserverSummary(msg) + // Pre-sort by worst noise floor (highest = worst) so View doesn't sort on every render. + sort.Slice(m.observers, func(i, j int) bool { + return nfVal(m.observers[i].NoiseFloor) > nfVal(m.observers[j].NoiseFloor) + }) m.lastRefresh = time.Now() m.fetchErr = nil @@ -446,7 +490,17 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ringBuf[m.ringHead] = p m.ringHead = (m.ringHead + 1) % ringBufferMax } + m.dirty = true return m, listenForWSMsg(m.wsMsgChan) + + case renderTickMsg: + // 60fps render coalescing: bubbletea re-renders when Update returns. + // By ticking at 16ms, we batch all packets that arrived between ticks + // into a single View() call instead of re-rendering per packet. + if m.dirty { + m.dirty = false + } + return m, renderTick() } // Always keep the WS listener running, even for unhandled messages. @@ -512,54 +566,53 @@ func (m model) viewDashboard() string { b.WriteString("\n\n") } - // Sort by worst noise floor (highest = worst) - observers := make([]ObserverSummary, len(m.observers)) - copy(observers, m.observers) - sort.Slice(observers, func(i, j int) bool { - ni, nj := nfVal(observers[i].CurrentNF), nfVal(observers[j].CurrentNF) - return ni > nj // worst first - }) - refreshStr := "" if !m.lastRefresh.IsZero() { refreshStr = m.lastRefresh.Format("15:04:05") } b.WriteString(fmt.Sprintf("Observers: %d │ Last refresh: %s\n\n", - len(observers), refreshStr)) + len(m.observers), refreshStr)) // Header - b.WriteString(headerStyle.Render(fmt.Sprintf("%-24s %8s %8s %8s %10s %8s", - "Observer", "NF(dBm)", "Avg NF", "Max NF", "Battery", "Samples"))) + b.WriteString(headerStyle.Render(fmt.Sprintf("%-24s %8s %10s %8s %10s", + "Observer", "NF(dBm)", "Battery", "Packets", "Last Seen"))) b.WriteString("\n") - b.WriteString(dimStyle.Render(strings.Repeat("─", 78))) + b.WriteString(dimStyle.Render(strings.Repeat("─", 68))) b.WriteString("\n") - for _, o := range observers { + for _, o := range m.observers { name := safePrefix(o.ObserverID, 8) if o.ObserverName != nil && *o.ObserverName != "" { name = truncate(*o.ObserverName, 24) } - nf := fmtNF(o.CurrentNF) - avg := fmtNF(o.AvgNF) - maxnf := fmtNF(o.MaxNF) + nf := fmtNF(o.NoiseFloor) batt := "—" if o.BatteryMv != nil { batt = fmt.Sprintf("%dmV", *o.BatteryMv) } + lastSeen := "—" + if o.LastSeen != "" { + if t, err := time.Parse(time.RFC3339, o.LastSeen); err == nil { + lastSeen = time.Since(t).Truncate(time.Second).String() + " ago" + if time.Since(t) < time.Minute { + lastSeen = "just now" + } + } + } // Color code NF nfStyle := greenStyle - if o.CurrentNF != nil { - if *o.CurrentNF > -85 { + if o.NoiseFloor != nil { + if *o.NoiseFloor > -85 { nfStyle = redStyle - } else if *o.CurrentNF > -100 { + } else if *o.NoiseFloor > -100 { nfStyle = yellowStyle } } - line := fmt.Sprintf("%-24s %8s %8s %8s %10s %8d", - name, nfStyle.Render(nf), avg, maxnf, batt, o.SampleCount) + line := fmt.Sprintf("%-24s %8s %10s %8d %10s", + name, nfStyle.Render(nf), batt, o.PacketCount, lastSeen) b.WriteString(line + "\n") }