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..d26c6159 --- /dev/null +++ b/cmd/tui/main.go @@ -0,0 +1,696 @@ +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:"id"` + ObserverName *string `json:"name"` + NoiseFloor *float64 `json:"noise_floor"` + BatteryMv *int `json:"battery_mv"` + PacketCount int `json:"packet_count"` + LastSeen string `json:"last_seen"` +} + +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 +type renderTickMsg time.Time + +// --- Model --- + +type view int + +const ( + viewDashboard view = iota + viewLiveFeed +) + +// ringBufferMax is the maximum number of packets kept in the live feed. +const ringBufferMax = 500 + +type model struct { + baseURL string + currentView view + width int + height int + + // Dashboard + observers []ObserverSummary + lastRefresh time.Time + fetchErr error + + // 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 + 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 + wsStatus string + wsDone chan struct{} + wsCloseOnce sync.Once +} + +func initialModel(baseURL string) model { + return model{ + baseURL: strings.TrimRight(baseURL, "/"), + wsStatus: "disconnected", + wsMsgChan: make(chan tea.Msg, 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") + if err != nil { + return summaryErrMsg{err} + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return summaryErrMsg{err} + } + // 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(wrapper.Observers) + } +} + +func tickEvery(d time.Duration) tea.Cmd { + return tea.Tick(d, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// 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). +func listenForWSMsg(ch <-chan tea.Msg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return nil + } + return msg + } +} + +// --- WebSocket goroutine --- + +// 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 { + select { + case msgChan <- wsStatusMsg("invalid url"): + case <-done: + } + 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: + } + + sendStatus(msgChan, done, "connecting...") + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) + if err != nil { + sendStatus(msgChan, done, fmt.Sprintf("error: %v", err)) + select { + case <-done: + return + case <-time.After(backoff): + } + backoff = time.Duration(math.Min(float64(backoff)*2, float64(maxBackoff))) + continue + } + + sendStatus(msgChan, done, "connected") + backoff = time.Second + + // 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: + // Send a graceful close frame before returning. + _ = conn.WriteMessage( + websocket.CloseMessage, + websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), + ) + return + default: + } + + // 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) { + 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 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 +} + +// 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 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 — 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 — from decoded.header.payloadTypeName (matches live.js) + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + if header, ok := decoded["header"].(map[string]interface{}); ok { + if t, ok := header["payloadTypeName"].(string); ok { + pkt.Type = t + } + } + } + if pkt.Type == "" { + pkt.Type = "UNKNOWN" + } + + // Observer name + 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 — from decoded.payload.hops or path + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + 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 — 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 — from decoded.payload + if decoded, ok := msg["decoded"].(map[string]interface{}); ok { + 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) + } + } + } + } + + return pkt +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n-1]) + "…" +} + +// safePrefix returns the first n characters of s (rune-aware), or s if shorter. +func safePrefix(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) +} + +// --- Init / Update / View --- + +func (m model) Init() tea.Cmd { + go connectWS(m.baseURL, m.wsMsgChan, m.wsDone) + + return tea.Batch( + fetchSummary(m.baseURL), + tickEvery(5*time.Second), + listenForWSMsg(m.wsMsgChan), + renderTick(), + ) +} + +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": + m.wsCloseOnce.Do(func() { 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) + // 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 + + case summaryErrMsg: + m.fetchErr = msg.err + + case tickMsg: + return m, tea.Batch( + fetchSummary(m.baseURL), + tickEvery(5*time.Second), + listenForWSMsg(m.wsMsgChan), + ) + + case wsStatusMsg: + m.wsStatus = string(msg) + return m, listenForWSMsg(m.wsMsgChan) + + case packetMsg: + p := Packet(msg) + // 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 + } + 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. + return m, listenForWSMsg(m.wsMsgChan) +} + +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") + } + + refreshStr := "" + if !m.lastRefresh.IsZero() { + refreshStr = m.lastRefresh.Format("15:04:05") + } + b.WriteString(fmt.Sprintf("Observers: %d │ Last refresh: %s\n\n", + len(m.observers), refreshStr)) + + // Header + 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("─", 68))) + b.WriteString("\n") + + for _, o := range m.observers { + name := safePrefix(o.ObserverID, 8) + if o.ObserverName != nil && *o.ObserverName != "" { + name = truncate(*o.ObserverName, 24) + } + + 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.NoiseFloor != nil { + if *o.NoiseFloor > -85 { + nfStyle = redStyle + } else if *o.NoiseFloor > -100 { + nfStyle = yellowStyle + } + } + + line := fmt.Sprintf("%-24s %8s %10s %8d %10s", + name, nfStyle.Render(nf), batt, o.PacketCount, lastSeen) + 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/%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"))) + 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 + } + // 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 i := 0; i < visible; i++ { + p := m.ringBuf[(m.ringHead+startIdx+i)%ringBufferMax] + 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) + } +}