issues-tui: wide, short terminal dashboard for current issues #1
No files matched your search
@@ -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
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
// 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")
|
||||
}
|
||||
|
||||
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.
|
||||
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.
|
||||
pagedPatterns := map[string]bool{}
|
||||
for _, sp := range s.pager.Filter(firing) {
|
||||
for _, p := range sp.Patterns {
|
||||
pagedPatterns[patternKey(sp.Service, p.Signature)] = 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
|
||||
}
|
||||
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] {
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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
|
||||
// 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
|
||||
|
||||
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:
|
||||
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
|
||||
}
|
||||
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(), ScanError: m.scanErr,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
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
|
||||
ScanError error
|
||||
}
|
||||
|
||||
// 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 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))
|
||||
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 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"))))
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in new issue
Block a user