diff --git a/cmd/tracer.go b/cmd/tracer.go new file mode 100644 index 0000000..59ab38b --- /dev/null +++ b/cmd/tracer.go @@ -0,0 +1,717 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/signal" + "sort" + "strings" + "sync" + "syscall" + "time" + + "github.com/getoptimum/mump2p-cli/internal/auth" + "github.com/getoptimum/mump2p-cli/internal/config" + ui "github.com/gizak/termui/v3" + "github.com/gizak/termui/v3/widgets" + "github.com/spf13/cobra" +) + +type Snapshot struct { + Algorithm string `json:"algorithm"` + ActiveNodes int `json:"active_nodes"` + UnhealthyNodes int `json:"unhealthy_nodes"` + PublishedMessages int64 `json:"published_messages"` + DeliveredMessages int64 `json:"delivered_messages"` + DuplicateMessages int64 `json:"duplicate_messages"` + AverageDelaySeconds float64 `json:"average_delay_seconds"` + P75 float64 `json:"p75"` + P95 float64 `json:"p95"` + TotalBytesMoved uint64 `json:"total_bytes_moved"` + BloatFactor float64 `json:"bloat_factor"` + IdealByteComplexity uint64 `json:"ideal_byte_complexity"` + LastUpdated time.Time `json:"last_updated"` + Messages map[string]MsgInfo `json:"messages"` + WindowSeconds int `json:"window_seconds"` +} + +type MsgInfo struct { + Topic string `json:"topic"` + Published time.Time `json:"published"` + Delivered time.Time `json:"delivered"` + DelaySec float64 `json:"delay_sec"` + PeersSeen map[string]struct{} `json:"peers_seen"` + Duplicates int `json:"duplicates"` + BytesMoved uint64 `json:"bytes_moved"` +} + +var ( + tracerServiceURL string + tracerWindow string + tracerTickMs int + tracerRows int + dashboardTopic string + dashboardSize int + dashboardCount int + dashboardInterval int +) + +var tracerCmd = &cobra.Command{ + Use: "tracer", + Short: "Interactive tracer dashboard", +} + +var ( + loadEndpoint2 string + loadTopic string + loadSize int + loadCount int + loadInterval int +) + +var tracerDashboardCmd = &cobra.Command{ + Use: "dashboard", + Short: "Open the tracer TUI dashboard", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL := resolveServiceURL(tracerServiceURL) + + jwtToken, err := resolveJWT() + if err != nil { + return err + } + + if tracerTickMs <= 0 { + tracerTickMs = 500 + } + tick := time.Duration(tracerTickMs) * time.Millisecond + if tracerWindow == "" { + tracerWindow = "10s" + } + if tracerRows <= 0 { + tracerRows = 12 + } + + jwtToken, clientID, err := resolveJWTAndClientID() + if err != nil { + return err + } + + return runTracerDashboard(baseURL, jwtToken, clientID, tracerWindow, tick, tracerRows, dashboardTopic, dashboardSize, dashboardCount, dashboardInterval) + }, +} + +var tracerResetCmd = &cobra.Command{ + Use: "reset", + Short: "Reset tracer statistics on the proxy", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL := resolveServiceURL(tracerServiceURL) + + jwtToken, err := resolveJWT() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return resetStats(ctx, baseURL, jwtToken) + }, +} + +var tracerLoadCmd = &cobra.Command{ + Use: "load", + Short: "Generate random traffic to the proxy for tracer", + RunE: func(cmd *cobra.Command, args []string) error { + baseURL := resolveServiceURL(tracerServiceURL) + + jwtToken, clientID, err := resolveJWTAndClientID() + if err != nil { + return err + } + + if loadTopic == "" { + loadTopic = "demo" + } + if loadSize <= 0 { + loadSize = 850_000 + } + if loadCount <= 0 { + loadCount = 50 + } + if loadInterval < 0 { + loadInterval = 500 + } + _ = proxySubscribe(baseURL, jwtToken, loadTopic, clientID, 1) + if loadEndpoint2 != "" && loadEndpoint2 != baseURL { + _ = proxySubscribe(loadEndpoint2, jwtToken, loadTopic, clientID, 1) + } + for i := 0; i < loadCount; i++ { + _ = proxyPublishRandom(baseURL, jwtToken, clientID, loadTopic, uint64(loadSize)) + if loadEndpoint2 != "" { + _ = proxyPublishRandom(loadEndpoint2, jwtToken, clientID, loadTopic, uint64(loadSize)) + } + if loadInterval > 0 { + time.Sleep(time.Duration(loadInterval) * time.Millisecond) + } + } + fmt.Println("load generation completed") + return nil + }, +} + +func init() { + rootCmd.AddCommand(tracerCmd) + tracerCmd.AddCommand(tracerDashboardCmd) + tracerCmd.AddCommand(tracerResetCmd) + tracerCmd.AddCommand(tracerLoadCmd) + + tracerCmd.PersistentFlags().StringVar(&tracerServiceURL, "service-url", "", "Override the default service URL") + + tracerDashboardCmd.Flags().StringVar(&tracerWindow, "window", "10s", "Sliding window size (e.g. 10s, 1m)") + tracerDashboardCmd.Flags().IntVar(&tracerTickMs, "tick-ms", 500, "UI refresh tick in milliseconds") + tracerDashboardCmd.Flags().IntVar(&tracerRows, "rows", 12, "Max recent message rows to show") + tracerDashboardCmd.Flags().StringVar(&dashboardTopic, "topic", "demo", "Topic to publish messages to (auto-publish enabled)") + tracerDashboardCmd.Flags().IntVar(&dashboardSize, "size", 102400, "Random message size in bytes for auto-publish") + tracerDashboardCmd.Flags().IntVar(&dashboardCount, "count", 60, "Number of messages to auto-publish") + tracerDashboardCmd.Flags().IntVar(&dashboardInterval, "interval-ms", 500, "Interval between auto-published messages in milliseconds") + + tracerLoadCmd.Flags().StringVar(&loadEndpoint2, "endpoint2", "", "Optional second proxy endpoint for comparison") + tracerLoadCmd.Flags().StringVar(&loadTopic, "topic", "demo", "Topic to publish to") + tracerLoadCmd.Flags().IntVar(&loadSize, "size", 850000, "Random message size in bytes") + tracerLoadCmd.Flags().IntVar(&loadCount, "count", 50, "Number of messages to send") + tracerLoadCmd.Flags().IntVar(&loadInterval, "interval-ms", 500, "Interval between messages in milliseconds") +} + +type history struct { + values []float64 + limit int + mu sync.Mutex +} + +func newHistory(limit int) *history { return &history{limit: limit} } +func (h *history) push(v float64) { + h.mu.Lock() + defer h.mu.Unlock() + h.values = append(h.values, v) + if len(h.values) > h.limit { + h.values = h.values[len(h.values)-h.limit:] + } +} +func (h *history) snapshot() []float64 { + h.mu.Lock() + defer h.mu.Unlock() + out := make([]float64, len(h.values)) + copy(out, h.values) + return out +} +func (h *history) reset() { + h.mu.Lock() + defer h.mu.Unlock() + h.values = nil +} + +func humanBytes(b uint64) string { + const unit = 1024 + if b < unit { + return fmt.Sprintf("%dB", b) + } + div, exp := uint64(unit), 0 + for n := b / unit; n >= unit; n /= unit { + div *= unit + exp++ + } + return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp]) +} +func pct(a, b int) int { + if a+b == 0 { + return 0 + } + return int(float64(a) / float64(a+b) * 100) +} +func safeDiv(a, b float64) float64 { + if b == 0 { + return 0 + } + return a / b +} + +func streamSnapshots(ctx context.Context, base, jwt, window string, out chan<- Snapshot, statusCh chan<- string, pollInterval time.Duration) { + defer close(out) + url := fmt.Sprintf("%s/api/v1/tracer/stream?window=%s", strings.TrimRight(base, "/"), window) + client := &http.Client{Timeout: 10 * time.Second} + backoff := 2 * time.Second + backoffMax := 30 * time.Second + + statusCh <- fmt.Sprintf("[Connecting] %s", url) + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + // Make first request immediately, then poll on ticker + for first := true; ; first = false { + if ctx.Err() != nil { + return + } + + // Wait for ticker before subsequent requests, but not before the first + if !first { + select { + case <-ticker.C: + case <-ctx.Done(): + return + } + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + statusCh <- fmt.Sprintf("[Config error] %v", err) + return + } + req.Header.Set("Accept", "application/json") + if !IsAuthDisabled() && jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + + resp, err := client.Do(req) + if err != nil { + statusCh <- fmt.Sprintf("[Error] %v -> retrying in %s", err, backoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + if backoff < backoffMax { + backoff *= 2 + if backoff > backoffMax { + backoff = backoffMax + } + } + continue + } + + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + statusCh <- fmt.Sprintf("[HTTP %d] -> retrying in %s", resp.StatusCode, backoff) + select { + case <-time.After(backoff): + case <-ctx.Done(): + return + } + if backoff < backoffMax { + backoff *= 2 + if backoff > backoffMax { + backoff = backoffMax + } + } + continue + } + + backoff = 2 * time.Second + statusCh <- "[Connected] Polling… (press 'q' to quit)" + + body, err := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if err != nil { + statusCh <- fmt.Sprintf("[Read error] %v; retrying…", err) + select { + case <-ticker.C: + case <-ctx.Done(): + return + } + continue + } + + var snap Snapshot + if err := json.Unmarshal(body, &snap); err != nil { + statusCh <- fmt.Sprintf("[Parse error] %v; retrying…", err) + select { + case <-ticker.C: + case <-ctx.Done(): + return + } + continue + } + + select { + case out <- snap: + case <-ctx.Done(): + return + } + } +} + +func runTracerDashboard(baseURL, jwt, clientID, window string, tick time.Duration, maxRows int, topic string, size, count, interval int) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer cancel() + + if err := ui.Init(); err != nil { + return fmt.Errorf("failed to init tracer UI: %w", err) + } + defer ui.Close() + + header := widgets.NewParagraph() + header.Title = " Optimum Tracer Dashboard " + header.Text = "initializing…" + header.BorderStyle = ui.NewStyle(ui.ColorCyan) + + nodesGauge := widgets.NewGauge() + nodesGauge.Title = " Active vs Unhealthy " + nodesGauge.Percent = 0 + nodesGauge.BarColor = ui.ColorGreen + nodesGauge.Label = "—" + nodesGauge.BorderStyle = ui.NewStyle(ui.ColorWhite) + + msgBars := widgets.NewBarChart() + msgBars.Title = " Messages " + msgBars.Labels = []string{"Published", "Delivered", "Dupes"} + msgBars.Data = []float64{0, 0, 0} + msgBars.BarWidth = 9 + msgBars.BarGap = 3 + msgBars.BorderStyle = ui.NewStyle(ui.ColorWhite) + msgBars.NumStyles = []ui.Style{ + ui.NewStyle(ui.ColorBlack), + ui.NewStyle(ui.ColorBlack), + ui.NewStyle(ui.ColorBlack), + } + + table := widgets.NewTable() + table.Title = " Recent Messages (slowest first) " + table.TextStyle = ui.NewStyle(ui.ColorWhite) + table.RowSeparator = false + table.Rows = [][]string{{"Topic", "Delay(ms)", "Peers", "Bytes", "Delivered/Published"}} + table.BorderStyle = ui.NewStyle(ui.ColorWhite) + + status := widgets.NewParagraph() + status.Title = " Status " + status.Text = "—" + status.BorderStyle = ui.NewStyle(ui.ColorCyan) + + help := widgets.NewParagraph() + help.Text = "q / Ctrl+C = quit • r = reset stats • Resize = adaptive layout" + help.Border = false + help.TextStyle = ui.NewStyle(ui.ColorCyan) + + resize := func() { + w, h := ui.TerminalDimensions() + header.SetRect(0, 0, w, 3) + status.SetRect(0, h-3, w, h) + nodesGauge.SetRect(0, 3, w/2, 8) + msgBars.SetRect(w/2, 3, w, 8) + table.SetRect(0, 8, w, h-3) + } + resize() + + snapCh := make(chan Snapshot, 4) + statusCh := make(chan string, 8) + + go streamSnapshots(ctx, strings.TrimRight(baseURL, "/"), jwt, window, snapCh, statusCh, tick) + + if count > 0 { + go func() { + statusCh <- fmt.Sprintf("[Auto-publish] Starting: %d messages to topic '%s'", count, topic) + _ = proxySubscribe(baseURL, jwt, topic, clientID, 1) + for i := 0; i < count; i++ { + select { + case <-ctx.Done(): + return + default: + if err := proxyPublishRandom(baseURL, jwt, clientID, topic, uint64(size)); err != nil { + statusCh <- fmt.Sprintf("[Auto-publish error] %v", err) + } + if i < count-1 { + time.Sleep(time.Duration(interval) * time.Millisecond) + } + } + } + statusCh <- fmt.Sprintf("[Auto-publish] Completed: %d messages sent", count) + }() + } + + var ( + lastReset time.Time + mu sync.Mutex + ) + + updateUI := func(s Snapshot) { + mu.Lock() + defer mu.Unlock() + + header.Text = fmt.Sprintf( + " Source: %s • Window: %s • Algo: %s • Updated: %s", + baseURL, + window, + s.Algorithm, + s.LastUpdated.Format("15:04:05"), // 24-hour format HH:MM:SS + ) + + nodesGauge.Title = fmt.Sprintf(" Active vs Unhealthy (%d / %d unhealthy) ", s.ActiveNodes, s.UnhealthyNodes) + nodesGauge.Percent = pct(s.ActiveNodes, s.UnhealthyNodes) + nodesGauge.Label = fmt.Sprintf("%d%% healthy", nodesGauge.Percent) + switch { + case nodesGauge.Percent >= 80: + nodesGauge.BarColor = ui.ColorGreen + case nodesGauge.Percent >= 50: + nodesGauge.BarColor = ui.ColorYellow + default: + nodesGauge.BarColor = ui.ColorRed + } + + msgBars.Data = []float64{ + float64(s.PublishedMessages), + float64(s.DeliveredMessages), + float64(s.DuplicateMessages), + } + + deliveredPerPeer := safeDiv(float64(s.DeliveredMessages), float64(s.PublishedMessages)) + + // Calculate latency metrics + avgMs := s.AverageDelaySeconds * 1000 + p75Ms := s.P75 * 1000 + p95Ms := s.P95 * 1000 + + // Update table title with latency metrics + table.Title = fmt.Sprintf( + " Recent Messages • P95: %.2fms • P75: %.2fms • Avg: %.2fms ", + p95Ms, p75Ms, avgMs, + ) + + type pair struct { + Topic string + M MsgInfo + } + var msgs []pair + for id, m := range s.Messages { + // Include all messages, even if DelaySec is 0 (unsettled) + msgs = append(msgs, pair{Topic: m.Topic, M: m}) + _ = id // message ID for debugging if needed + } + // Sort by delay (descending), with 0 delays at the end + sort.Slice(msgs, func(i, j int) bool { + if msgs[i].M.DelaySec == 0 && msgs[j].M.DelaySec == 0 { + return false + } + if msgs[i].M.DelaySec == 0 { + return false + } + if msgs[j].M.DelaySec == 0 { + return true + } + return msgs[i].M.DelaySec > msgs[j].M.DelaySec + }) + + tableRows := [][]string{{"Topic", "Delay(ms)", "Peers", "Bytes", "Delivered/Published"}} + if len(msgs) == 0 { + tableRows = append(tableRows, []string{ + "No messages in window", + "—", + "—", + "—", + "—", + }) + } else { + for i, p := range msgs { + if i >= maxRows { + break + } + tableRows = append(tableRows, []string{ + ellipsize(p.Topic, 40), + fmt.Sprintf("%.2f", p.M.DelaySec*1000), + fmt.Sprintf("%d", len(p.M.PeersSeen)), + humanBytes(p.M.BytesMoved), + fmt.Sprintf("%.3f", deliveredPerPeer), + }) + } + } + table.Rows = tableRows + } + + ticker := time.NewTicker(tick) + defer ticker.Stop() + + uiEvents := ui.PollEvents() + for { + select { + case <-ctx.Done(): + return nil + case ev := <-uiEvents: + switch ev.ID { + case "": + resize() + ui.Clear() + ui.Render(header, status, nodesGauge, msgBars, table) + case "q", "": + return nil + case "r": + if time.Since(lastReset) < 2*time.Second { + status.Text = "Reset pressed too quickly; wait a moment…" + ui.Render(header, status, nodesGauge, msgBars, table) + break + } + lastReset = time.Now() + go func() { + statusCh <- "[Reset] Sending reset to server…" + if err := resetStats(ctx, baseURL, jwt); err != nil { + statusCh <- fmt.Sprintf("[Reset error] %v", err) + return + } + statusCh <- "[Reset] Done." + }() + } + case s, ok := <-snapCh: + if !ok { + status.Text = "Stream ended. Waiting for reconnect… (press 'q' to quit)" + ui.Render(header, status, nodesGauge, msgBars, table) + continue + } + updateUI(s) + ui.Render(header, status, nodesGauge, msgBars, table) + case st := <-statusCh: + status.Text = st + case <-ticker.C: + ui.Render(header, status, nodesGauge, msgBars, table) + } + } +} + +func ellipsize(s string, max int) string { + if len(s) <= max { + return s + } + if max <= 1 { + return "…" + } + return s[:max-1] + "…" +} + +func resetStats(ctx context.Context, base, jwt string) error { + url := fmt.Sprintf("%s/api/v1/tracer/reset", strings.TrimRight(base, "/")) + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + if jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("reset failed: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + return nil +} + +func resolveServiceURL(override string) string { + base := config.LoadConfig().ServiceUrl + if override != "" { + base = override + } + return base +} + +func loadTokenAndClaims(storagePath string) (tokenStr string, claims *auth.TokenClaims, err error) { + authClient := auth.NewClient() + storage := auth.NewStorageWithPath(storagePath) + + token, err := authClient.GetValidToken(storage) + if err != nil { + return "", nil, fmt.Errorf("authentication required: %v", err) + } + + parser := auth.NewTokenParser() + claims, err = parser.ParseToken(token.Token) + if err != nil { + return "", nil, fmt.Errorf("error parsing token: %v", err) + } + if !claims.IsActive { + return "", nil, fmt.Errorf("your account is inactive, please contact support") + } + + return token.Token, claims, nil +} + +func resolveJWT() (string, error) { + if IsAuthDisabled() { + return "", nil + } + + tokenStr, _, err := loadTokenAndClaims(GetAuthPath()) + return tokenStr, err +} + +func resolveJWTAndClientID() (string, string, error) { + if IsAuthDisabled() { + clientID := GetClientID() + if clientID == "" { + return "", "", fmt.Errorf("--client-id is required when using --disable-auth") + } + return "", clientID, nil + } + + tokenStr, claims, err := loadTokenAndClaims(GetAuthPath()) + if err != nil { + return "", "", err + } + return tokenStr, claims.ClientID, nil +} + +func proxyPublishRandom(base, jwt, clientID, topic string, length uint64) error { + url := strings.TrimRight(base, "/") + "/api/v1/publish" + body := map[string]any{ + "client_id": clientID, + "topic": topic, + "message_length": length, + } + reqBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if !IsAuthDisabled() && jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + if resp.StatusCode/100 != 2 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + return fmt.Errorf("publish error: HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(b))) + } + return nil +} + +func proxySubscribe(base, jwt, topic, clientID string, threshold int) error { + url := strings.TrimRight(base, "/") + "/api/v1/subscribe" + body := map[string]any{ + "client_id": clientID, + "topic": topic, + "threshold": threshold, + } + reqBytes, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("encoding request body: %w", err) + } + req, err := http.NewRequest("POST", url, bytes.NewReader(reqBytes)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + if !IsAuthDisabled() && jwt != "" { + req.Header.Set("Authorization", "Bearer "+jwt) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() //nolint:errcheck + return nil +} diff --git a/go.mod b/go.mod index 9129f2c..86f548b 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/getoptimum/mump2p-cli go 1.24.6 require ( + github.com/gizak/termui/v3 v3.1.0 github.com/golang-jwt/jwt/v4 v4.5.2 github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.9.1 @@ -21,10 +22,13 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.2 // indirect + github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect + github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/spf13/pflag v1.0.6 // indirect golang.org/x/net v0.38.0 // indirect - golang.org/x/sys v0.36.0 // indirect + golang.org/x/sys v0.38.0 // indirect golang.org/x/text v0.23.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 35e7a1d..7e5f619 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= +github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -25,6 +27,12 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mattn/go-runewidth v0.0.2 h1:UnlwIPBGaTZfPQ6T1IGzPI0EkYAQmT9fAEJ/poFC63o= +github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM= +github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d h1:x3S6kxmy49zXVVyhcnrFqxvNVCBPb2KZ9hV2RBdS840= +github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -52,8 +60,8 @@ go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250324211829-b45e905df463 h1:e0AIkUUhxyBKh6ssZNrAMeqhA7RKUj42346d1y02i2g=