mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-04-06 10:35:41 +00:00
Compare commits
4 Commits
master
...
feat/tui-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c9ff9b2b | ||
|
|
12b8c176f1 | ||
|
|
3e39776178 | ||
|
|
8851d996f2 |
1
cmd/tui/.gitignore
vendored
Normal file
1
cmd/tui/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
corescope-tui
|
||||
30
cmd/tui/go.mod
Normal file
30
cmd/tui/go.mod
Normal file
@@ -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
|
||||
)
|
||||
47
cmd/tui/go.sum
Normal file
47
cmd/tui/go.sum
Normal file
@@ -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=
|
||||
696
cmd/tui/main.go
Normal file
696
cmd/tui/main.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user