From a96e95212aa7c45f52cc8862f93082615b54c83d Mon Sep 17 00:00:00 2001 From: developer Date: Thu, 24 Sep 2026 09:00:51 +0000 Subject: [PATCH 1/2] issues-tui: wide, short terminal dashboard for current issues Standalone cmd that runs the same pipeline as the webhook notifications (alerts.LoadErrorEvents -> alerts.DetectAlerts -> cooldown suppressor) on a schedule and renders what is firing as a row of severity-colored cards - built for an ultra-wide, low display. A pattern shows PAGED when the scan would have notified and ongoing while suppressed; disappearing spikes are reported as recovered. -once prints one snapshot for cron/pipe use. Colors follow the otel-ui design-system tokens (dark and light). --- cmd/issues-tui/main.go | 249 +++++++++++++++++++++++ cmd/issues-tui/main_test.go | 253 +++++++++++++++++++++++ cmd/issues-tui/tui.go | 95 +++++++++ cmd/issues-tui/view.go | 394 ++++++++++++++++++++++++++++++++++++ go.mod | 19 +- go.sum | 116 ++++------- 6 files changed, 1048 insertions(+), 78 deletions(-) create mode 100644 cmd/issues-tui/main.go create mode 100644 cmd/issues-tui/main_test.go create mode 100644 cmd/issues-tui/tui.go create mode 100644 cmd/issues-tui/view.go diff --git a/cmd/issues-tui/main.go b/cmd/issues-tui/main.go new file mode 100644 index 0000000..bc12590 --- /dev/null +++ b/cmd/issues-tui/main.go @@ -0,0 +1,249 @@ +// issues-tui is a standalone terminal dashboard for current issues. It runs +// the exact detection pipeline behind the webhook notifications +// (alerts.LoadErrorEvents -> alerts.DetectAlerts -> the cooldown suppressor) +// on a schedule against ClickHouse, and renders what is firing right now as +// wide, short cards - built for an ultra-wide, low display. +// +// A pattern shows as PAGED when this tick would have sent a notification +// (new or escalated past its cooldown) and as ongoing while it is still +// firing but already paged once. When a service's spike disappears it is +// reported as recovered for that scan. +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os" + "strings" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + tea "github.com/charmbracelet/bubbletea" + + "git.tornberg.me/go-otel/internal/alerts" + "git.tornberg.me/go-otel/internal/features" +) + +func main() { + // Detection defaults come from the same environment the server's + // alerting runner reads, so the dashboard and the notifications cannot + // drift apart; every flag overrides. + acfg := alerts.ConfigFromEnv() + addr := flag.String("addr", envOr("OTEL_CLICKHOUSE_ADDR", "127.0.0.1:9000"), "ClickHouse host:port") + database := flag.String("database", envOr("OTEL_CLICKHOUSE_DB", "otel"), "ClickHouse database") + user := flag.String("user", envOr("OTEL_CLICKHOUSE_USER", "default"), "ClickHouse user") + password := flag.String("password", os.Getenv("OTEL_CLICKHOUSE_PASSWORD"), "ClickHouse password") + interval := flag.Duration("interval", acfg.Interval, "how often to rescan") + window := flag.Duration("window", acfg.Window, "observation window") + baselineWindows := flag.Int("baseline-windows", acfg.BaselineWindows, "preceding windows used as baseline") + cooldown := flag.Duration("cooldown", acfg.Cooldown, "a paged pattern stays ongoing this long unless it doubles") + minExcess := flag.Int("min-excess", acfg.MinExcess, "minimum paging excess to report a spike") + multiple := flag.Float64("multiple", acfg.Multiple, "pattern must be novel or >= multiple * its baseline") + lag := flag.Duration("lag", acfg.Lag, "ingest settle time: windows end this long before now") + baseURL := flag.String("base-url", acfg.BaseURL, "UI base URL for issue links, e.g. https://otel.k6n.net") + title := flag.String("title", "otel issues", "dashboard header title") + once := flag.Bool("once", false, "scan once, print the dashboard to stdout and exit") + flag.Parse() + + sc, err := newClickHouseScanner(*addr, *database, *user, *password, features.SpikeConfig{ + Window: *window, BaselineWindows: *baselineWindows, Multiple: *multiple, MinExcess: *minExcess, + }, *cooldown, *lag) + if err != nil { + log.Fatal(err) + } + defer sc.close() + + if *once { + snap := sc.Scan(context.Background(), time.Now()) + if snap.Err != nil { + fmt.Fprintln(os.Stderr, "issues-tui:", snap.Err) + os.Exit(1) + } + fmt.Println(renderSnapshot(snap, snapshotOptions{Width: 200, Height: 12, Title: *title, BaseURL: *baseURL})) + return + } + + p := tea.NewProgram(newModel(sc, modelConfig{ + Interval: *interval, Title: *title, BaseURL: *baseURL, + }), tea.WithAltScreen()) + if _, err := p.Run(); err != nil { + log.Fatal(err) + } +} + +func envOr(k, f string) string { + if v := os.Getenv(k); v != "" { + return v + } + return f +} + +// scanner re-evaluates the notification pipeline on demand. All state +// (cooldown suppressor, since an issue first fired) lives here, so Scan can +// be called from the TUI, from -once mode or from tests with the same +// behavior. +type scanner struct { + load alerts.Loader + dataSince func(ctx context.Context) time.Time + close func() + cfg features.SpikeConfig + lag time.Duration + pager *features.Suppressor + + // active maps an issue key to the window end when it was first seen + // firing; it mirrors what a person watching notifications would remember. + active map[string]time.Time + + since time.Time + sinceAt time.Time +} + +func newClickHouseScanner(addr, database, user, password string, cfg features.SpikeConfig, cooldown, lag time.Duration) (*scanner, error) { + conn, err := clickhouse.Open(&clickhouse.Options{Addr: []string{addr}, Auth: clickhouse.Auth{Database: database, Username: user, Password: password}}) + if err != nil { + return nil, err + } + return &scanner{ + load: func(ctx context.Context, start, end time.Time) ([]features.Event, error) { + return alerts.LoadErrorEvents(ctx, conn, database, start, end) + }, + dataSince: func(ctx context.Context) time.Time { return alerts.DataSince(ctx, conn, database) }, + close: func() { _ = conn.Close() }, + cfg: cfg, + lag: lag, + pager: features.NewSuppressor(cooldown), + active: map[string]time.Time{}, + }, nil +} + +// issueView is one card on the dashboard: a firing spike reduced to an Alert +// (the same model the notifications carry) plus display state. +type issueView struct { + alert alerts.Alert + key string + paged bool // this scan would have notified (new or escalated) + firstSeen time.Time // window end when this issue first appeared +} + +// recoveredIssue is an issue that fired on the previous scan but not now. +type recoveredIssue struct { + Service string + FirstSeen time.Time + LastSeen time.Time +} + +// snapshot is one scan's result: everything firing right now. +type snapshot struct { + ScannedAt time.Time + WindowEnd time.Time + WindowStart time.Time + Events int + Issues []issueView + Recovered []recoveredIssue + Err error +} + +// spikeKey identifies an issue across scans: a service plus the set of +// patterns currently firing for it. +func spikeKey(s features.Spike) string { + sigs := make([]string, 0, len(s.Patterns)) + for _, p := range s.Patterns { + sigs = append(sigs, p.Signature) + } + return s.Service + "\x00" + strings.Join(sigs, "\x00") +} + +// Scan runs one evaluation, mirroring the runner's Tick: load the window +// ending lag ago plus its baseline, detect exactly what the server pages on, +// and use a cooldown suppressor to tell a fresh page from an ongoing one. +func (s *scanner) Scan(ctx context.Context, now time.Time) snapshot { + cctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + end := now.Add(-s.lag).UTC() + start := end.Add(-time.Duration(s.cfg.BaselineWindows+1) * s.cfg.Window) + snap := snapshot{ScannedAt: now.UTC(), WindowEnd: end, WindowStart: start} + + if s.dataSince != nil && (s.since.IsZero() || now.Sub(s.sinceAt) > time.Hour) { + // Mirrors the runner: refresh where data begins at most hourly, so + // the warmup guard does not suppress alerts after a quiet period. + if ds := s.dataSince(cctx); !ds.IsZero() { + s.since, s.sinceAt, s.cfg.DataSince = ds, now, ds + } + } + + events, err := s.load(cctx, start, end) + if err != nil { + snap.Err = err + return snap + } + snap.Events = len(events) + + firing := alerts.DetectAlerts(events, end, s.cfg) + // The suppressor mutates only its own state and the copies Filter makes, + // so firing keeps every pattern for display while paged marks what would + // have been sent to Telegram / the webhook. + pagedKeys := map[string]bool{} + for _, sp := range s.pager.Filter(firing) { + pagedKeys[spikeKey(sp)] = true + } + + nowActive := map[string]bool{} + for _, sp := range firing { + key := spikeKey(sp) + nowActive[key] = true + first, ok := s.active[key] + if !ok { + first = end + s.active[key] = first + } + snap.Issues = append(snap.Issues, issueView{alert: alerts.NewAlert(sp, end), key: key, paged: pagedKeys[key], firstSeen: first}) + } + for key, first := range s.active { + if !nowActive[key] { + delete(s.active, key) + snap.Recovered = append(snap.Recovered, recoveredIssue{Service: serviceOfKey(key), FirstSeen: first, LastSeen: end}) + } + } + sortIssues(snap.Issues) + return snap +} + +func serviceOfKey(key string) string { + service, _, _ := strings.Cut(key, "\x00") + return service +} + +// sortIssues puts pages first, then the most urgent and largest spikes - the +// order a person should read the display in. +func sortIssues(issues []issueView) { + // insertion sort keeps it stable without pulling in sort.Slice churn for + // the tiny slices a dashboard shows. + for i := 1; i < len(issues); i++ { + for j := i; j > 0 && issueLess(issues[j], issues[j-1]); j-- { + issues[j], issues[j-1] = issues[j-1], issues[j] + } + } +} + +func issueLess(a, b issueView) bool { + if a.paged != b.paged { + return a.paged + } + ra, rb := prioRank(a.alert.Priority()), prioRank(b.alert.Priority()) + if ra != rb { + return ra < rb + } + return a.alert.Spike.Excess > b.alert.Spike.Excess +} + +func prioRank(p alerts.Priority) int { + switch p { + case alerts.PriorityHigh: + return 0 + case alerts.PriorityMedium: + return 1 + } + return 2 +} diff --git a/cmd/issues-tui/main_test.go b/cmd/issues-tui/main_test.go new file mode 100644 index 0000000..c470560 --- /dev/null +++ b/cmd/issues-tui/main_test.go @@ -0,0 +1,253 @@ +package main + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/charmbracelet/lipgloss" + + "git.tornberg.me/go-otel/internal/alerts" + "git.tornberg.me/go-otel/internal/features" +) + +// fakeClock builds a scanner with a canned loader and no ClickHouse. +func fakeScanner(events []features.Event, dataSince time.Time) *scanner { + return &scanner{ + load: func(context.Context, time.Time, time.Time) ([]features.Event, error) { return events, nil }, + dataSince: func(context.Context) time.Time { + return dataSince + }, + close: func() {}, + cfg: features.SpikeConfig{Window: time.Minute, BaselineWindows: 2, Multiple: 3, MinExcess: 5}, + pager: features.NewSuppressor(30 * time.Minute), + active: map[string]time.Time{}, + } +} + +func errs(n int, at time.Time) []features.Event { + events := make([]features.Event, 0, n) + for i := 0; i < n; i++ { + events = append(events, features.Event{ + Timestamp: at, + Service: "checkout", + Kind: "span", + IsError: true, + StatusCode: 500, + Route: "/api/payments", + SpanName: "POST /api/payments", + Message: "payment gateway timeout", + PatternSignature: "checkout|500|/api/payments", + IssueFingerprint: "issue-checkout-500", + }) + } + return events +} + +// TestScannerPagesOnceThenOngoing mirrors the notification lifecycle: the +// first window past the threshold pages, later windows are ongoing until the +// count doubles (escalation) or the spike disappears. +func TestScannerPagesOnceThenOngoing(t *testing.T) { + base := time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC) + sc := fakeScanner(nil, base.Add(-time.Hour)) + + // The loader places the events inside whatever window is being + // evaluated, so every scan sees a live spike in its current window. + count := 12 + sc.load = func(_ context.Context, _, end time.Time) ([]features.Event, error) { + return errs(count, end.Add(-30*time.Second)), nil + } + + first := sc.Scan(context.Background(), base) + if len(first.Issues) != 1 || !first.Issues[0].paged { + t.Fatalf("first scan should page one issue, got %+v", first.Issues) + } + if first.Issues[0].alert.Priority() != alerts.PriorityHigh { + t.Fatalf("500 on a span should be high priority, got %s", first.Issues[0].alert.Priority()) + } + if got := first.Issues[0].firstSeen; !got.Equal(first.WindowEnd) { + t.Fatalf("firstSeen should be the first window end, got %v", got) + } + + // A little more traffic, still not double: ongoing, not paged again. + count = 14 + second := sc.Scan(context.Background(), base.Add(time.Minute)) + if len(second.Issues) != 1 || second.Issues[0].paged { + t.Fatalf("second scan should be ongoing, got %+v", second.Issues) + } + if got := second.Issues[0].firstSeen; !got.Equal(first.WindowEnd) { + t.Fatalf("ongoing issue should keep its first-seen time, got %v want %v", got, first.WindowEnd) + } + + // Escalation: at least double the count pages again. + count = 30 + third := sc.Scan(context.Background(), base.Add(2*time.Minute)) + if len(third.Issues) != 1 || !third.Issues[0].paged { + t.Fatalf("doubled count should page again, got %+v", third.Issues) + } + + // Recovery: the spike disappears and is reported once. + sc.load = func(_ context.Context, _, _ time.Time) ([]features.Event, error) { return nil, nil } + fourth := sc.Scan(context.Background(), base.Add(3*time.Minute)) + if len(fourth.Issues) != 0 { + t.Fatalf("no events should mean no issues, got %+v", fourth.Issues) + } + if len(fourth.Recovered) != 1 || fourth.Recovered[0].Service != "checkout" { + t.Fatalf("expected checkout in recovered, got %+v", fourth.Recovered) + } + again := sc.Scan(context.Background(), base.Add(4*time.Minute)) + if len(again.Recovered) != 0 { + t.Fatalf("recovery should be reported once, got %+v", again.Recovered) + } +} + +func TestScannerAllClearAndError(t *testing.T) { + sc := fakeScanner(nil, time.Time{}) + empty := sc.Scan(context.Background(), time.Now()) + if len(empty.Issues) != 0 || empty.Err != nil { + t.Fatalf("empty loader should yield a clean snapshot, got %+v", empty) + } + + sc.load = func(context.Context, time.Time, time.Time) ([]features.Event, error) { + return nil, context.DeadlineExceeded + } + failed := sc.Scan(context.Background(), time.Now()) + if failed.Err == nil { + t.Fatal("loader error should surface in the snapshot") + } +} + +func TestGridLayoutFillsWideShortDisplays(t *testing.T) { + // 300 columns, one card row of height: six issues, one row of wide cards. + perRow, rows, shown, cardW := gridLayout(6, 300, 8) + if perRow != 6 || rows != 1 || shown != 6 { + t.Fatalf("want all 6 on one row, got perRow=%d rows=%d shown=%d", perRow, rows, shown) + } + if cardW < cardMinWidth || cardW > cardMaxWidth { + t.Fatalf("card width %d out of range", cardW) + } + + // More issues than fit: cap at one row and let the footer count the rest. + perRow, rows, shown, _ = gridLayout(20, 300, 8) + if rows != 1 || shown != perRow { + t.Fatalf("want a single row capped at width, got rows=%d shown=%d perRow=%d", rows, shown, perRow) + } + + // A taller display wraps to a second row. + _, rows, shown, _ = gridLayout(20, 300, 15) + if rows != 2 { + t.Fatalf("15 rows of height should fit two card rows, got %d", rows) + } + if shown != perRow*2 { + t.Fatalf("shown %d should fill two rows of %d", shown, perRow) + } + + // Narrow display: at least one card still renders. + perRow, rows, shown, cardW = gridLayout(3, 36, 8) + if perRow < 1 || rows != 1 || shown != 1 || cardW < cardMinWidth { + t.Fatalf("narrow display should show one card, got perRow=%d rows=%d shown=%d cardW=%d", perRow, rows, shown, cardW) + } +} + +func cardAt(t *testing.T, n int) (snapshot, snapshotOptions) { + t.Helper() + now := time.Date(2025, 6, 1, 12, 45, 0, 0, time.UTC) + sp := features.Spike{ + Service: "checkout", WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, + ErrorCount: 38, BaselineMean: 0.3, Excess: 37.7, + Patterns: []features.PatternDelta{{ + Signature: "checkout|500|/api/payments", Count: 38, Excess: 37.7, Novel: true, Clients: 12, + Services: []string{"checkout", "payments-db"}, + Example: features.Event{ + Service: "checkout", Kind: "span", StatusCode: 500, Route: "/api/payments", + SpanName: "POST /api/payments", Message: "payment gateway timeout", + IssueFingerprint: "abcdef1234567890", + }, + }}, + } + snap := snapshot{ScannedAt: now, WindowStart: sp.WindowStart, WindowEnd: now, Events: 8213, + Issues: make([]issueView, n)} + for i := range snap.Issues { + snap.Issues[i] = issueView{alert: alerts.NewAlert(sp, now), key: sp.Service, paged: i == 0, firstSeen: now.Add(-12 * time.Minute)} + } + return snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", BaseURL: "https://otel.k6n.net", + NextScanAt: now.Add(30 * time.Second), Now: now} +} + +func TestRenderSnapshotShowsCards(t *testing.T) { + snap, o := cardAt(t, 8) + out := renderSnapshot(snap, o) + if !strings.Contains(out, "PAGED") || !strings.Contains(out, "checkout") { + t.Fatalf("dashboard should show the paged checkout card:\n%s", out) + } + if !strings.Contains(out, "HTTP 500") { + t.Fatalf("card should describe the failure:\n%s", out) + } + if lines := strings.Split(out, "\n"); len(lines) > 12 { + t.Fatalf("dashboard must respect the height budget, got %d lines", len(lines)) + } + + // The footer counts what the single card row could not show: five cards + // fit in 200 columns, three are hidden. + if !strings.Contains(out, "+3 more") { + t.Fatalf("footer should count hidden cards:\n%s", out) + } +} + +func TestRenderSnapshotAllClear(t *testing.T) { + now := time.Date(2025, 6, 1, 12, 45, 0, 0, time.UTC) + snap := snapshot{ScannedAt: now, WindowStart: now.Add(-5 * time.Minute), WindowEnd: now, Events: 8213} + out := renderSnapshot(snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", Now: now}) + if !strings.Contains(out, "ALL CLEAR") { + t.Fatalf("empty scan should show all clear:\n%s", out) + } +} + +func TestRenderSnapshotScanError(t *testing.T) { + snap := snapshot{Err: context.DeadlineExceeded} + out := renderSnapshot(snap, snapshotOptions{Width: 200, Height: 12, Title: "otel issues", Now: time.Now()}) + if !strings.Contains(out, "SCAN FAILED") { + t.Fatalf("failed scan should be visible:\n%s", out) + } +} + +func TestSortIssuesPagedFirst(t *testing.T) { + now := time.Now() + mk := func(service string, paged bool, prio alerts.Priority, excess float64) issueView { + pat := features.PatternDelta{Example: features.Event{Service: service, StatusCode: 500}} + if prio == alerts.PriorityLow { + pat.Example.StatusCode = 404 + } + return issueView{paged: paged, alert: alerts.NewAlert(features.Spike{ + Service: service, Excess: excess, Patterns: []features.PatternDelta{pat}, + }, now)} + } + issues := []issueView{ + mk("quiet", false, alerts.PriorityHigh, 40), + mk("loud", true, alerts.PriorityLow, 10), + mk("burning", false, alerts.PriorityHigh, 99), + } + sortIssues(issues) + if issues[0].alert.Spike.Service != "loud" { + t.Fatalf("paged issue should come first, got %s", issues[0].alert.Spike.Service) + } + if issues[1].alert.Spike.Service != "burning" || issues[2].alert.Spike.Service != "quiet" { + t.Fatalf("then by priority and excess, got %s, %s", issues[1].alert.Spike.Service, issues[2].alert.Spike.Service) + } +} + +func TestBarAndMinimal(t *testing.T) { + out := bar(40, "left", "right") + if lipgloss.Width(out) != 40 { + t.Fatalf("bar should pad to width, got %q (%d)", out, lipgloss.Width(out)) + } + if out2 := bar(8, "left", "right"); lipgloss.Width(out2) > 8 { + t.Fatalf("bar should truncate when both sides do not fit, got %q", out2) + } + + snap := snapshot{Err: context.DeadlineExceeded} + if got := renderMinimal(snap, snapshotOptions{}); !strings.Contains(got, "SCAN FAILED") { + t.Fatalf("minimal render should surface scan errors, got %q", got) + } +} diff --git a/cmd/issues-tui/tui.go b/cmd/issues-tui/tui.go new file mode 100644 index 0000000..18997ef --- /dev/null +++ b/cmd/issues-tui/tui.go @@ -0,0 +1,95 @@ +package main + +import ( + "context" + "time" + + tea "github.com/charmbracelet/bubbletea" +) + +// modelConfig is the static configuration the dashboard needs beyond the +// scanner. +type modelConfig struct { + Interval time.Duration + Title string + BaseURL string +} + +// tickMsg drives the clock and the scan countdown. +type tickMsg time.Time + +// snapshotMsg carries a finished scan back into the model. +type snapshotMsg snapshot + +type model struct { + sc *scanner + cfg modelConfig + snap snapshot + + scanning bool + nextScanAt time.Time + + width, height int + ready bool +} + +func newModel(sc *scanner, cfg modelConfig) model { + return model{sc: sc, cfg: cfg, nextScanAt: time.Now()} +} + +func (m model) Init() tea.Cmd { + return tea.Batch(tickClock(), m.scanCmd()) +} + +func tickClock() tea.Cmd { + return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) +} + +func (m *model) scanCmd() tea.Cmd { + sc := m.sc + return func() tea.Msg { return snapshotMsg(sc.Scan(context.Background(), time.Now())) } +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height, m.ready = msg.Width, msg.Height, true + return m, nil + + case tea.KeyMsg: + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + case "r": + if !m.scanning { + m.scanning = true + return m, m.scanCmd() + } + return m, nil + } + return m, nil + + case tickMsg: + now := time.Time(msg) + cmd := tickClock() + if !m.scanning && !now.Before(m.nextScanAt) { + m.scanning = true + return m, tea.Batch(cmd, m.scanCmd()) + } + return m, cmd + + case snapshotMsg: + m.snap = snapshot(msg) + m.scanning = false + m.nextScanAt = time.Now().Add(m.cfg.Interval) + return m, nil + } + return m, nil +} + +func (m model) View() string { + return renderSnapshot(m.snap, snapshotOptions{ + Width: m.width, Height: m.height, Title: m.cfg.Title, BaseURL: m.cfg.BaseURL, + Scanning: m.scanning, NextScanAt: m.nextScanAt, Now: time.Now(), + }) +} diff --git a/cmd/issues-tui/view.go b/cmd/issues-tui/view.go new file mode 100644 index 0000000..07fff0a --- /dev/null +++ b/cmd/issues-tui/view.go @@ -0,0 +1,394 @@ +package main + +import ( + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + + "git.tornberg.me/go-otel/internal/alerts" +) + +// Layout budget. The display this dashboard targets is much wider than it is +// tall, so cards flow left-to-right in one row first; a second row only +// appears when the terminal is tall enough. +const ( + cardContentLines = 4 // priority/what/stats/meta inside the border + cardHeight = cardContentLines + 2 // border rows included + cardGap = 1 // columns between cards + cardMinWidth = 34 + cardMaxWidth = 64 + minUsableWidth = 40 + minUsableHeight = 3 + footerHint = "q quit · r scan now" +) + +// snapshotOptions carries everything the renderer needs beyond the scan. +type snapshotOptions struct { + Width, Height int + Title string + BaseURL string + Scanning bool + NextScanAt time.Time + Now time.Time +} + +// Colors mirror the otel-ui design-system tokens (AGENTS.md) so the terminal +// dashboard and the web UI read as one system: neutral chrome, color only as +// a severity signal. +var ( + colChrome = lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#0B0E12"} + colSurface = lipgloss.AdaptiveColor{Light: "#FFFFFF", Dark: "#14181E"} + colLine = lipgloss.AdaptiveColor{Light: "#E3E0D8", Dark: "#242B34"} + colText = lipgloss.AdaptiveColor{Light: "#161A1F", Dark: "#E8ECF2"} + colText2 = lipgloss.AdaptiveColor{Light: "#4F5964", Dark: "#98A3B2"} + colText3 = lipgloss.AdaptiveColor{Light: "#6B7480", Dark: "#8B96A6"} + colCritical = lipgloss.AdaptiveColor{Light: "#C4402C", Dark: "#FF6F5E"} + colWarn = lipgloss.AdaptiveColor{Light: "#8A5D0E", Dark: "#F0B24A"} + colOK = lipgloss.AdaptiveColor{Light: "#1F7A56", Dark: "#4FCF9A"} + colAccent = lipgloss.AdaptiveColor{Light: "#3B4FC4", Dark: "#7B8CFF"} +) + +func priorityColor(p alerts.Priority) lipgloss.TerminalColor { + switch p { + case alerts.PriorityHigh: + return colCritical + case alerts.PriorityMedium: + return colWarn + } + return colText3 +} + +// renderSnapshot draws the whole dashboard: one header line, the card grid, +// one footer line. It is shared by the live TUI and -once mode. +func renderSnapshot(snap snapshot, o snapshotOptions) string { + w, h := o.Width, o.Height + if w <= 0 { + w = 80 + } + if h <= 0 { + h = 8 + } + if w < minUsableWidth || h < minUsableHeight { + return renderMinimal(snap, o) + } + + header := renderHeader(snap, o) + footer := renderFooter(snap, o) + body := renderBody(snap, o, w, h-2) + return strings.Join([]string{header, body, footer}, "\n") +} + +func renderBody(snap snapshot, o snapshotOptions, w, avail int) string { + switch { + case snap.Err != nil: + return statusPanel(w, min(avail, 5), colCritical, "SCAN FAILED", snap.Err.Error(), + "values are from "+fmtTime(snap.ScannedAt)+"; the display keeps the last good scan") + case len(snap.Issues) == 0: + sub := fmt.Sprintf("no issues firing · window %s–%s UTC · scanned %s", + snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04"), commas(snap.Events)) + if snap.ScannedAt.IsZero() { + sub = "waiting for the first scan…" + } + return statusPanel(w, min(avail, 5), colOK, "ALL CLEAR", sub, "") + default: + return renderGrid(snap, o, w, avail) + } +} + +// renderGrid lays issues into rows of cards that fill the width; only as many +// rows as the height allows are drawn, the rest are counted in the footer. +func renderGrid(snap snapshot, o snapshotOptions, w, avail int) string { + perRow, _, shown, cardW := gridLayout(len(snap.Issues), w, avail) + var rendered []string + for start := 0; start < shown; start += perRow { + end := min(start+perRow, shown) + var row []string + for _, v := range snap.Issues[start:end] { + row = append(row, issueCard(v, cardW, o)) + } + rendered = append(rendered, lipgloss.JoinHorizontal(lipgloss.Top, row...)) + } + grid := strings.Join(rendered, "\n") + if lipgloss.Width(grid) < w { + grid = lipgloss.PlaceHorizontal(w, lipgloss.Center, grid) + } + return grid +} + +// gridLayout fits n cards into the width and at most `avail` height. It +// returns cards per row, rows drawn, how many cards that shows, and the card +// width; whatever does not fit is reported by the caller as hidden. +func gridLayout(n, width, avail int) (perRow, rows, shown, cardW int) { + if n <= 0 { + return 0, 0, 0, 0 + } + maxRows := max(1, (avail+cardGap)/(cardHeight+cardGap)) + perRow = max(1, (width+cardGap)/(cardMinWidth+cardGap)) + perRow = min(perRow, n) + rows = min(maxRows, (n+perRow-1)/perRow) + shown = min(n, rows*perRow) + cardW = min(cardMaxWidth, (width-cardGap*(perRow-1))/perRow) + return perRow, rows, shown, cardW +} + +// issueCard renders one issue as a bordered card of exactly cardHeight lines. +// Line 1 identifies it (priority, service, age), line 2 is what is failing, +// line 3 the numbers behind it, line 4 the display state and issue link. +func issueCard(v issueView, w int, o snapshotOptions) string { + a := v.alert + fg := priorityColor(a.Priority()) + inner := w - 4 // border plus padding on both sides + + // Line 1: HIGH · checkout · for 12m + age := "" + if !v.firstSeen.IsZero() && !o.Now.IsZero() { + age = " · for " + shortDur(o.Now.Sub(v.firstSeen).Round(time.Minute)) + } + head := ansi.Truncate( + style(fg, true).Render(strings.ToUpper(string(a.Priority())))+ + style(colText, true).Render(" · "+a.Spike.Service)+ + style(colText3, false).Render(age), inner, "…") + + // Line 2: what is failing - the top pattern's description, then any + // additional pattern count. + what := fmt.Sprintf("%d error(s), no pattern detail", a.Spike.ErrorCount) + if len(a.Patterns) > 0 { + what = a.Patterns[0].What + if extra := len(a.Patterns) - 1; extra > 0 { + what += fmt.Sprintf(" (+%d more pattern%s)", extra, plural(extra)) + } + } + whatLine := style(colText, false).Render(ansi.Truncate(what, inner, "…")) + + // Line 3: the detector's numbers: count vs baseline, excess, and the + // top pattern's tags. + segs := []string{style(colText2, false).Render( + fmt.Sprintf("%d err vs %.1f/w · excess +%.0f", a.Spike.ErrorCount, a.Spike.BaselineMean, a.Spike.Excess))} + var plain []string + if len(a.Patterns) > 0 { + top := a.Patterns[0] + if top.New { + segs = append(segs, style(colAccent, true).Render("NEW")) + } + if top.Clients > 0 { + plain = append(plain, fmt.Sprintf("%d client%s", top.Clients, plural(top.Clients))) + } + if len(top.Services) > 1 { + plain = append(plain, "with: "+strings.Join(top.Services, ",")) + } + } + if len(plain) > 0 { + segs = append(segs, style(colText3, false).Render(strings.Join(plain, " · "))) + } + statsLine := ansi.Truncate(strings.Join(segs, " "), inner, "…") + + // Line 4: display state (paged now vs already paged) and the issue link. + state := style(colText3, false).Render("ongoing") + if v.paged { + state = style(fg, true).Render("PAGED") + } + var meta []string + if !a.Spike.WindowEnd.IsZero() { + meta = append(meta, style(colText3, false).Render(fmtTime(a.Spike.WindowEnd))) + } + meta = append(meta, state) + if o.BaseURL != "" && len(a.Patterns) > 0 && a.Patterns[0].IssueID != "" { + meta = append(meta, style(colAccent, false).Render("⧉ "+shortID(a.Patterns[0].IssueID))) + } + metaLine := ansi.Truncate(strings.Join(meta, " "), inner, "…") + + return lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(fg). + Background(colSurface). + Padding(0, 1). + Width(w - 2). + Height(cardContentLines). + Render(strings.Join([]string{head, whatLine, statsLine, metaLine}, "\n")) +} + +// statusPanel is the single wide panel used for ALL CLEAR and scan failures. +func statusPanel(w, h int, border lipgloss.TerminalColor, title, line1, line2 string) string { + inner := max(w-6, 10) + lines := []string{ + style(border, true).Render(ansi.Truncate(title, inner, "…")), + style(colText, false).Render(ansi.Truncate(line1, inner, "…")), + } + if line2 != "" { + lines = append(lines, style(colText3, false).Render(ansi.Truncate(line2, inner, "…"))) + } + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, + lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(border). + Background(colSurface).Padding(0, 2).Width(inner). + Render(strings.Join(lines, "\n"))) +} + +// renderHeader draws the one-line status bar: title, scan window, counts, +// clock and next-scan countdown, right-aligned. +func renderHeader(snap snapshot, o snapshotOptions) string { + w := o.Width - 2 + title := style(colAccent, true).Render(strings.ToUpper(o.Title)) + + left := []string{title} + if !snap.WindowEnd.IsZero() { + left = append(left, style(colText3, false).Render( + fmt.Sprintf("%s–%s UTC", snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04")))) + } + if !snap.ScannedAt.IsZero() { + left = append(left, style(colText3, false).Render(commas(snap.Events)+" events")) + var paged int + for _, v := range snap.Issues { + if v.paged { + paged++ + } + } + counts := fmt.Sprintf("%d firing", len(snap.Issues)) + if paged > 0 { + left = append(left, style(colCritical, true).Render(fmt.Sprintf("%d PAGED · %s", paged, counts))) + } else { + left = append(left, style(colText2, false).Render(counts)) + } + } + + var right []string + if o.Scanning { + right = append(right, style(colAccent, false).Render("scanning…")) + } else if !o.NextScanAt.IsZero() && !o.Now.IsZero() { + d := max(o.NextScanAt.Sub(o.Now).Round(time.Second), 0) + right = append(right, style(colText3, false).Render("next "+shortDur(d))) + } + if !snap.ScannedAt.IsZero() { + right = append(right, style(colText3, false).Render("scanned "+fmtTime(snap.ScannedAt))) + } + if !o.Now.IsZero() { + right = append(right, style(colText, false).Render(o.Now.Format("15:04:05"))) + } + return bar(w, strings.Join(left, " "), strings.Join(right, " ")) +} + +// renderFooter draws the one-line help/status bar: keys, hidden-card count +// and recovered issues. +func renderFooter(snap snapshot, o snapshotOptions) string { + var left []string + left = append(left, style(colText3, false).Render(footerHint)) + if hidden := len(snap.Issues) - shownCount(snap, o); hidden > 0 { + left = append(left, style(colWarn, false).Render(fmt.Sprintf("+%d more not shown (widen or raise the display)", hidden))) + } + var right []string + if len(snap.Recovered) > 0 { + names := make([]string, 0, len(snap.Recovered)) + for _, r := range snap.Recovered { + names = append(names, r.Service) + } + right = append(right, style(colOK, false).Render("recovered: "+strings.Join(names, ", "))) + } + if o.BaseURL != "" { + right = append(right, style(colText3, false).Render(strings.TrimRight(o.BaseURL, "/")+"/issues")) + } + return bar(o.Width-2, strings.Join(left, " "), strings.Join(right, " ")) +} + +func shownCount(snap snapshot, o snapshotOptions) int { + if o.Height < minUsableHeight { + return len(snap.Issues) + } + _, _, shown, _ := gridLayout(len(snap.Issues), o.Width, o.Height-2) + return shown +} + +// bar places left and right text on one full-width line, truncating when the +// terminal is too narrow for both. +func bar(w int, left, right string) string { + lw, rw := lipgloss.Width(left), lipgloss.Width(right) + if lw+rw+1 > w { + return ansi.Truncate(left+" "+right, w, "…") + } + return left + strings.Repeat(" ", max(w-lw-rw, 0)) + right +} + +// renderMinimal is the fallback for tiny terminals and -once without a size: +// one line per issue, no boxes. +func renderMinimal(snap snapshot, o snapshotOptions) string { + if snap.Err != nil { + return "SCAN FAILED: " + snap.Err.Error() + } + if len(snap.Issues) == 0 { + return strings.ToUpper(o.Title) + ": all clear" + } + lines := make([]string, 0, len(snap.Issues)+1) + lines = append(lines, strings.ToUpper(o.Title)+":") + for _, v := range snap.Issues { + state := "ongoing" + if v.paged { + state = "PAGED" + } + what := "" + if len(v.alert.Patterns) > 0 { + what = v.alert.Patterns[0].What + } + lines = append(lines, fmt.Sprintf("%s %s %s: %s", + state, strings.ToUpper(string(v.alert.Priority())), v.alert.Spike.Service, what)) + } + return strings.Join(lines, "\n") +} + +// --- small helpers --------------------------------------------------------- + +func style(c lipgloss.TerminalColor, bold bool) lipgloss.Style { + s := lipgloss.NewStyle().Foreground(c) + if bold { + s = s.Bold(true) + } + return s +} + +func fmtTime(t time.Time) string { + if t.IsZero() { + return "never" + } + return t.Format("15:04:05") +} + +func commas(n int) string { + s := fmt.Sprintf("%d", n) + if n < 1000 { + return s + } + var parts []string + for len(s) > 3 { + parts = append([]string{s[len(s)-3:]}, parts...) + s = s[:len(s)-3] + } + return s + "," + strings.Join(parts, ",") +} + +func plural(n int) string { + if n == 1 { + return "" + } + return "s" +} + +// shortDur renders durations compactly: 90s -> "1m30s", 2h05m -> "2h5m". +func shortDur(d time.Duration) string { + s := d.Round(time.Second).String() + if strings.HasSuffix(s, "m0s") { + s = strings.TrimSuffix(s, "0s") + } + if strings.HasSuffix(s, "h0m") { + s = strings.TrimSuffix(s, "0m") + } + return s +} + +// shortID abbreviates an issue fingerprint for the card's link chip. +func shortID(id string) string { + if n := utf8.RuneCountInString(id); n > 10 { + return id[:10] + } + return id +} diff --git a/go.mod b/go.mod index 117d596..bf1a6b7 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,13 @@ go 1.27.0 require ( github.com/ClickHouse/clickhouse-go/v2 v2.48.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.10.1 github.com/go-sourcemap/sourcemap v2.1.4+incompatible github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 github.com/natefinch/lumberjack v2.0.0+incompatible + github.com/oschwald/geoip2-golang/v2 v2.4.0 go.opentelemetry.io/collector/consumer v1.55.0 go.opentelemetry.io/collector/pdata v1.55.0 go.opentelemetry.io/proto/otlp v1.10.0 @@ -22,9 +26,14 @@ require ( github.com/BurntSushi/toml v1.6.0 // indirect github.com/ClickHouse/ch-go v0.74.0 // indirect github.com/andybalholm/brotli v1.2.2 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/errors v0.7.1 // indirect @@ -41,20 +50,28 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.19.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mailru/easyjson v0.7.7 // 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/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // 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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect - github.com/oschwald/geoip2-golang/v2 v2.4.0 // indirect github.com/oschwald/maxminddb-golang/v2 v2.6.0 // indirect github.com/paulmach/orb v0.13.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/segmentio/asm v1.2.1 // indirect github.com/shopspring/decimal v1.4.0 // indirect github.com/spf13/pflag v1.0.6 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/collector/featuregate v1.55.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect diff --git a/go.sum b/go.sum index cd3eb7b..787d04c 100644 --- a/go.sum +++ b/go.sum @@ -1,58 +1,45 @@ -cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= -cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= -github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/ClickHouse/ch-go v0.74.0 h1:uYs2m4wIt0ZHSM1E72rg0maCfzhR2V3xWb/vZEgpeWE= github.com/ClickHouse/ch-go v0.74.0/go.mod h1:sZ/r+8ttZMjyrP9PuFbgoVbth1ywIu2LIQNA2vgko6M= github.com/ClickHouse/clickhouse-go/v2 v2.48.0 h1:auzd4VkapQYhQF8F2Gog7s3x78Bi1JZmByxGbrw3C+4= github.com/ClickHouse/clickhouse-go/v2 v2.48.0/go.mod h1:lBjUCPRG6RpRQdMbkXq+JV8rY0/O5lw+Z7jShgReFjM= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= -github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= -github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= -github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= -github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= -github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= -github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= -github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= -github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +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.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= -github.com/dmarkham/enumer v1.6.3/go.mod h1:DyjXaqCglj4GhELF73oWiparNkYkXvmOBLza/o4kO74= -github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= -github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= -github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= -github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= -github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= -github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= -github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +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/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= -github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= @@ -67,10 +54,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= -github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -82,12 +67,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-version v1.9.0 h1:CeOIz6k+LoN3qX9Z0tyQrPtiB1DFYRPfCIBtaXPSCnA= github.com/hashicorp/go-version v1.9.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= -github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -103,69 +86,60 @@ 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/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= -github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +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/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mkevac/debugcharts v0.0.0-20191222103121-ae1c48aa8615/go.mod h1:Ad7oeElCZqA1Ufj0U9/liOF4BtVepxRcTvr2ey7zTvM= -github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= -github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= -github.com/moby/moby/client v0.5.1/go.mod h1:odLstlZ6uSnfvAgVxMpvgmb8SUdd+siH2T0GBuxVAlM= -github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= -github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= -github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= -github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= -github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= -github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +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/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +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/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= -github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= github.com/natefinch/lumberjack v2.0.0+incompatible h1:4QJd3OLAMgj7ph+yZTuX13Ld4UpgHp07nNdFX7mqFfM= github.com/natefinch/lumberjack v2.0.0+incompatible/go.mod h1:Wi9p2TTF5DG5oU+6YfsmYQpsTIOm0B1VNzQg9Mw6nPk= github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM= github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo= github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4= github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog= -github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= -github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/oschwald/geoip2-golang/v2 v2.4.0 h1:JdVymxpwFf7o+3o53Sw2gCYBX8maA5DWxcgzNb14yJU= github.com/oschwald/geoip2-golang/v2 v2.4.0/go.mod h1:VJW7lAC5Dw4WH42mjhUFkxf7+v3K1YOafLD8iBiszsc= github.com/oschwald/maxminddb-golang/v2 v2.6.0 h1:pRlHCdJmc+4uxMOSthmKDt5HOw3JTX8TJZlhyP5ew0w= github.com/oschwald/maxminddb-golang/v2 v2.6.0/go.mod h1:sjqpB3z2BZrMduDp9TAUTCkZDoT3nDhixUc4Dge2qRQ= -github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/paulmach/orb v0.13.0 h1:r7n7mQGGF+cj/CbcivEj9J3HGK+XR+yXnvzRdq9saIw= github.com/paulmach/orb v0.13.0/go.mod h1:6scRWINywA2Jf05dcjOfLfxrUIMECvTSG2MVbRLxu/k= -github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= -github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pierrec/lz4/v4 v4.1.27 h1:+PhzhWDrjRj89TH2sw43nE3+4+W8lSxIuQadEHZyjUk= github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= -github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +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/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= -github.com/shirou/gopsutil/v4 v4.26.5/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= -github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= @@ -175,21 +149,16 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/testcontainers/testcontainers-go v0.43.0/go.mod h1:+VxkT2NQnKOZPKi6praMuMKYHYyOGXr0XSBSlSMCzFo= -github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= -github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +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= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= -go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/collector/consumer v1.55.0 h1:7Per8P4J0nlBrFVSXb+nwZ+egiel1BRtggZngyykGsM= @@ -200,8 +169,6 @@ go.opentelemetry.io/collector/internal/testutil v0.149.0 h1:OWfUPO3NFKSaJtz/SBZp go.opentelemetry.io/collector/internal/testutil v0.149.0/go.mod h1:Jkjs6rkqs973LqgZ0Fe3zrokQRKULYXPIf4HuqStiEE= go.opentelemetry.io/collector/pdata v1.55.0 h1:WBgye8bo8koUyV9Vmp/r2Q3lgDezdsgfKDQAaM1oT2I= go.opentelemetry.io/collector/pdata v1.55.0/go.mod h1:6jPrbM4tuliCPACDznjFtxnnHisfKfzwrBVoeuESYuk= -go.opentelemetry.io/contrib/detectors/gcp v1.39.0/go.mod h1:t/OGqzHBa5v6RHZwrDBJ2OirWc+4q/w2fTbLZwAKjTk= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= @@ -224,20 +191,17 @@ go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= -go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= -go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +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/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -249,10 +213,11 @@ golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= @@ -267,8 +232,6 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -305,7 +268,6 @@ k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= -k8s.io/gengo/v2 v2.0.0-20250604051438-85fd79dbfd9f/go.mod h1:EJykeLsmFC60UQbYJezXkEsG2FLrt0GPNkU5iK5GWxU= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b h1:MloQ9/bdJyIu9lb1PzujOPolHyvO06MXG5TUIj2mNAA= -- 2.54.0 From f5015581a43dfdba8534f5165dbc76839f78f19c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mats=20T=C3=B6rnberg?= Date: Thu, 24 Sep 2026 11:11:29 +0200 Subject: [PATCH 2/2] fix issues TUI display reliability --- README.md | 27 +++++++++++++++++++++++++++ cmd/issues-tui/main.go | 19 ++++++++++++++++--- cmd/issues-tui/main_test.go | 23 +++++++++++++++++++++++ cmd/issues-tui/tui.go | 13 +++++++++++-- cmd/issues-tui/view.go | 7 +++++++ 5 files changed, 84 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 0cb3dd3..e58cd0f 100644 --- a/README.md +++ b/README.md @@ -371,6 +371,33 @@ to Docker or stored in the image. - `OTEL_CLICKHOUSE_DB` / `OTEL_CLICKHOUSE_USER` / `OTEL_CLICKHOUSE_PASSWORD` - optional ClickHouse connection settings (defaults: `otel` / `default` / empty). + +### Issues display + +The standalone `issues-tui` command is a terminal dashboard for currently +firing alert spikes. It reads normalized error events directly from +ClickHouse; it does not replace the web UI's persisted `/api/issues` view. + +Build and run it on the display computer (or use `go run`): + +```sh +go build -o issues-tui ./cmd/issues-tui +OTEL_CLICKHOUSE_ADDR=clickhouse.example.net:9000 \ +OTEL_CLICKHOUSE_DB=otel \ +OTEL_CLICKHOUSE_USER=default \ +OTEL_CLICKHOUSE_PASSWORD='…' \ +OTEL_ALERT_BASE_URL=https://otel.example.net \ +./issues-tui +``` + +The display computer must be able to reach ClickHouse's native TCP port. For a +Kubernetes-only ClickHouse service, use a private network path or tunnel; the +in-cluster service name is not reachable from an ordinary workstation. The +dashboard refreshes using the `OTEL_ALERT_*` interval, window, baseline, +cooldown and lag settings. Press `r` to scan immediately and `q` to quit. +Use `-once` for a one-shot, non-interactive output suitable for a process +supervisor health check. + - `OTEL_INGEST_KEY` - optional shared key required (via `?key=` or the `X-Otel-Ingest-Key` header) on `/v1/traces`, `/v1/metrics` and `/v1/logs` over HTTP. Unset by default (no key required). This is meant to diff --git a/cmd/issues-tui/main.go b/cmd/issues-tui/main.go index bc12590..dff243f 100644 --- a/cmd/issues-tui/main.go +++ b/cmd/issues-tui/main.go @@ -155,6 +155,10 @@ func spikeKey(s features.Spike) string { return s.Service + "\x00" + strings.Join(sigs, "\x00") } +func patternKey(service, signature string) string { + return service + "\x00" + signature +} + // Scan runs one evaluation, mirroring the runner's Tick: load the window // ending lag ago plus its baseline, detect exactly what the server pages on, // and use a cooldown suppressor to tell a fresh page from an ongoing one. @@ -184,9 +188,11 @@ func (s *scanner) Scan(ctx context.Context, now time.Time) snapshot { // The suppressor mutates only its own state and the copies Filter makes, // so firing keeps every pattern for display while paged marks what would // have been sent to Telegram / the webhook. - pagedKeys := map[string]bool{} + pagedPatterns := map[string]bool{} for _, sp := range s.pager.Filter(firing) { - pagedKeys[spikeKey(sp)] = true + for _, p := range sp.Patterns { + pagedPatterns[patternKey(sp.Service, p.Signature)] = true + } } nowActive := map[string]bool{} @@ -198,7 +204,14 @@ func (s *scanner) Scan(ctx context.Context, now time.Time) snapshot { first = end s.active[key] = first } - snap.Issues = append(snap.Issues, issueView{alert: alerts.NewAlert(sp, end), key: key, paged: pagedKeys[key], firstSeen: first}) + paged := false + for _, p := range sp.Patterns { + if pagedPatterns[patternKey(sp.Service, p.Signature)] { + paged = true + break + } + } + snap.Issues = append(snap.Issues, issueView{alert: alerts.NewAlert(sp, end), key: key, paged: paged, firstSeen: first}) } for key, first := range s.active { if !nowActive[key] { diff --git a/cmd/issues-tui/main_test.go b/cmd/issues-tui/main_test.go index c470560..e2547b9 100644 --- a/cmd/issues-tui/main_test.go +++ b/cmd/issues-tui/main_test.go @@ -251,3 +251,26 @@ func TestBarAndMinimal(t *testing.T) { t.Fatalf("minimal render should surface scan errors, got %q", got) } } + +func TestModelKeepsLastGoodSnapshotOnScanError(t *testing.T) { + m := newModel(nil, modelConfig{Interval: time.Minute}) + good := snapshot{ScannedAt: time.Now(), Issues: []issueView{{}}} + updated, _ := m.Update(snapshotMsg(good)) + m = updated.(model) + + failed := snapshot{Err: context.DeadlineExceeded} + updated, _ = m.Update(snapshotMsg(failed)) + m = updated.(model) + if len(m.snap.Issues) != 1 { + t.Fatalf("failed scan should not erase the last good snapshot: %+v", m.snap) + } + if m.scanErr == nil { + t.Fatal("failed scan should be visible separately from the last good snapshot") + } + + updated, _ = m.Update(snapshotMsg(snapshot{ScannedAt: time.Now()})) + m = updated.(model) + if m.scanErr != nil { + t.Fatal("successful scan should clear the previous scan error") + } +} diff --git a/cmd/issues-tui/tui.go b/cmd/issues-tui/tui.go index 18997ef..4029647 100644 --- a/cmd/issues-tui/tui.go +++ b/cmd/issues-tui/tui.go @@ -25,6 +25,9 @@ type model struct { sc *scanner cfg modelConfig snap snapshot + // scanErr is kept separately so a failed refresh does not erase the last + // successful data from the display. + scanErr error scanning bool nextScanAt time.Time @@ -79,7 +82,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case snapshotMsg: - m.snap = snapshot(msg) + snap := snapshot(msg) + if snap.Err != nil { + m.scanErr = snap.Err + } else { + m.snap = snap + m.scanErr = nil + } m.scanning = false m.nextScanAt = time.Now().Add(m.cfg.Interval) return m, nil @@ -90,6 +99,6 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m model) View() string { return renderSnapshot(m.snap, snapshotOptions{ Width: m.width, Height: m.height, Title: m.cfg.Title, BaseURL: m.cfg.BaseURL, - Scanning: m.scanning, NextScanAt: m.nextScanAt, Now: time.Now(), + Scanning: m.scanning, NextScanAt: m.nextScanAt, Now: time.Now(), ScanError: m.scanErr, }) } diff --git a/cmd/issues-tui/view.go b/cmd/issues-tui/view.go index 07fff0a..449f900 100644 --- a/cmd/issues-tui/view.go +++ b/cmd/issues-tui/view.go @@ -34,6 +34,7 @@ type snapshotOptions struct { Scanning bool NextScanAt time.Time Now time.Time + ScanError error } // Colors mirror the otel-ui design-system tokens (AGENTS.md) so the terminal @@ -87,6 +88,9 @@ func renderBody(snap snapshot, o snapshotOptions, w, avail int) string { case snap.Err != nil: return statusPanel(w, min(avail, 5), colCritical, "SCAN FAILED", snap.Err.Error(), "values are from "+fmtTime(snap.ScannedAt)+"; the display keeps the last good scan") + case o.ScanError != nil && len(snap.Issues) == 0: + return statusPanel(w, min(avail, 5), colCritical, "SCAN FAILED", o.ScanError.Error(), + "no successful scan yet; retrying automatically") case len(snap.Issues) == 0: sub := fmt.Sprintf("no issues firing · window %s–%s UTC · scanned %s", snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04"), commas(snap.Events)) @@ -234,6 +238,9 @@ func renderHeader(snap snapshot, o snapshotOptions) string { title := style(colAccent, true).Render(strings.ToUpper(o.Title)) left := []string{title} + if o.ScanError != nil { + left = append(left, style(colCritical, true).Render("SCAN FAILED")) + } if !snap.WindowEnd.IsZero() { left = append(left, style(colText3, false).Render( fmt.Sprintf("%s–%s UTC", snap.WindowStart.Format("15:04"), snap.WindowEnd.Format("15:04")))) -- 2.54.0