From 2eb49049fc2855de931dd13099f79fcc3072228b Mon Sep 17 00:00:00 2001 From: Luke Okraszewski Date: Thu, 7 Aug 2025 01:30:57 -0700 Subject: [PATCH] [tctl/top] Add raw metrics view (#57260) This commit adds a new tab to the `tctl top` utility which renders prometheus metrics as a list which supports live updates and filtering. It makes use of the bubbles/list charm which supports fuzzy searching via github.com/sahilm/fuzzy. Refactores the main model to split the refresh tick into seperate tea commands to allow tapping the raw metrics concurrently to generating the report. Keybinds are now stored as part of the model and used directly for handling KeyMsg events. Note that `list.Model` does not support variable height list item entries and for pagination purposes this value needs to be fixed, for this reason histogram metrics will only render sum and count. changelog: tctl top can now display raw prometheus metrics * apply review suggestions --- go.mod | 18 ++- go.sum | 42 ++++--- integrations/event-handler/go.mod | 2 +- integrations/event-handler/go.sum | 4 +- integrations/terraform/go.mod | 2 +- integrations/terraform/go.sum | 4 +- tool/tctl/common/top/box.go | 7 +- tool/tctl/common/top/keys.go | 104 +++++++++++++++++ tool/tctl/common/top/metrics.go | 115 ++++++++++++++++++ tool/tctl/common/top/model.go | 188 +++++++++++++++++++----------- tool/tctl/common/top/report.go | 10 -- 11 files changed, 391 insertions(+), 105 deletions(-) create mode 100644 tool/tctl/common/top/keys.go create mode 100644 tool/tctl/common/top/metrics.go diff --git a/go.mod b/go.mod index a6cea2ad17048..c7c85ab618a2e 100644 --- a/go.mod +++ b/go.mod @@ -70,9 +70,9 @@ require ( github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240323062759-1fd604ae58de github.com/beevik/etree v1.3.0 github.com/buildkite/bintest/v3 v3.2.0 - github.com/charmbracelet/bubbles v0.18.0 - github.com/charmbracelet/bubbletea v0.26.1 - github.com/charmbracelet/lipgloss v0.10.0 + github.com/charmbracelet/bubbles v0.21.0 + github.com/charmbracelet/bubbletea v1.3.4 + github.com/charmbracelet/lipgloss v1.1.0 github.com/coreos/go-oidc v2.2.1+incompatible // replaced github.com/coreos/go-semver v0.3.1 github.com/coreos/go-systemd/v22 v22.5.0 @@ -268,6 +268,7 @@ require ( github.com/alicebob/gopher-json v0.0.0-20230218143504-906a9b012302 // indirect github.com/apache/arrow/go/v15 v15.0.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.2 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 // indirect @@ -292,6 +293,10 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chai2010/gettext-go v1.0.2 // indirect github.com/charlievieth/strcase v0.0.5 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/x/ansi v0.8.0 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect github.com/cloudflare/cfssl v1.6.4 // indirect github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3 // indirect github.com/containerd/containerd v1.7.27 // indirect @@ -423,7 +428,7 @@ require ( github.com/mattn/go-colorable v0.1.13 // 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.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mdlayher/socket v0.5.1 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -440,8 +445,7 @@ require ( github.com/mtibben/percent v0.2.1 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect - github.com/muesli/reflow v0.3.0 // indirect - github.com/muesli/termenv v0.15.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect @@ -473,6 +477,7 @@ require ( github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/sahilm/fuzzy v0.1.1 // indirect github.com/sasha-s/go-deadlock v0.3.1 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.8.0 // indirect @@ -511,6 +516,7 @@ require ( github.com/xeipuuv/gojsonschema v1.2.0 // indirect github.com/xhit/go-str2duration/v2 v2.1.0 // indirect github.com/xlab/treeprint v1.2.0 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d // indirect github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zeebo/errs v1.3.0 // indirect diff --git a/go.sum b/go.sum index 99031bee1ace9..bc006a9eac76f 100644 --- a/go.sum +++ b/go.sum @@ -842,6 +842,8 @@ github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPd github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go v1.44.256/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.44.263/go.mod h1:aVsgQcEevwlmQ7qHE9I3h+dtQgpqhFB+i8Phjh7fkwI= github.com/aws/aws-sdk-go v1.49.12/go.mod h1:LF8svs817+Nz+DmiMQKTO3ubZ/6IaTpq3TjupRn3Eqk= @@ -945,6 +947,8 @@ github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240323062759- github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20240323062759-1fd604ae58de/go.mod h1:pJQomIo4A5X9k8E30Q4A2BAJckIwQhC1TAFN/WXCkdk= 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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/beevik/etree v1.1.0/go.mod h1:r8Aw8JqVegEf0w2fDnATrX9VpkMcyFeM0FhwO62wh+A= github.com/beevik/etree v1.3.0 h1:hQTc+pylzIKDb23yYprodCWWTt+ojFfUZyzU09a/hmU= github.com/beevik/etree v1.3.0/go.mod h1:aiPf89g/1k3AShMVAzriilpcE4R/Vuor90y83zVZWFc= @@ -1003,12 +1007,22 @@ github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNS github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= github.com/charlievieth/strcase v0.0.5 h1:gV4iXVyD6eI5KdfOV+/vIVCKXZwtCWOmDMcu7Uy00Rs= github.com/charlievieth/strcase v0.0.5/go.mod h1:FIOYY1aDBMSIOFqmVomHBpoK+bteGlESRsgsdWjrhx8= -github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= -github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= -github.com/charmbracelet/bubbletea v0.26.1 h1:xujcQeF73rh4jwu3+zhfQsvV18x+7zIjlw7/CYbzGJ0= -github.com/charmbracelet/bubbletea v0.26.1/go.mod h1:FzKr7sKoO8iFVcdIBM9J0sJOcQv5nDQaYwsee3kpbgo= -github.com/charmbracelet/lipgloss v0.10.0 h1:KWeXFSexGcfahHX+54URiZGkBFazf70JNMtwg/AFW3s= -github.com/charmbracelet/lipgloss v0.10.0/go.mod h1:Wig9DSfvANsxqkRsqj6x87irdy123SR4dOXlKa91ciE= +github.com/charmbracelet/bubbles v0.21.0 h1:9TdC97SdRVg/1aaXNVWfFH3nnLAwOXr8Fn6u6mfQdFs= +github.com/charmbracelet/bubbles v0.21.0/go.mod h1:HF+v6QUR4HkEpz62dx7ym2xc71/KBHg+zKwJtMw+qtg= +github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI= +github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= +github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -1856,9 +1870,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D 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.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= -github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.14/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= @@ -1935,10 +1948,8 @@ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D 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/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s= -github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8= -github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= -github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= @@ -2080,7 +2091,6 @@ github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 h1:N/ElC8H3+5X github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= github.com/remyoudompheng/bigfft v0.0.0-20190728182440-6a916e37a237/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= -github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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= @@ -2120,6 +2130,8 @@ github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6ke github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/sasha-s/go-deadlock v0.3.1 h1:sqv7fDNShgjcaxkO0JNcOAlr8B9+cV5Ey/OB71efZx0= github.com/sasha-s/go-deadlock v0.3.1/go.mod h1:F73l+cr82YSh10GxyRI6qZiCgK64VaZjwesgfQ1/iLM= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= @@ -2289,6 +2301,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtX github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +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/yashtewari/glob-intersection v0.2.0 h1:8iuHdN88yYuCzCdjt0gDe+6bAhUwBeEWqThExu54RFg= diff --git a/integrations/event-handler/go.mod b/integrations/event-handler/go.mod index 17964606a662d..84639a57bfc56 100644 --- a/integrations/event-handler/go.mod +++ b/integrations/event-handler/go.mod @@ -218,7 +218,7 @@ require ( github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect diff --git a/integrations/event-handler/go.sum b/integrations/event-handler/go.sum index e2d46ea4f0cd6..ac5ec2f0930f3 100644 --- a/integrations/event-handler/go.sum +++ b/integrations/event-handler/go.sum @@ -664,8 +664,8 @@ github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Ky github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 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-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= diff --git a/integrations/terraform/go.mod b/integrations/terraform/go.mod index e77108ad763a3..1093c149d6c03 100644 --- a/integrations/terraform/go.mod +++ b/integrations/terraform/go.mod @@ -259,7 +259,7 @@ require ( github.com/mattermost/xml-roundtrip-validator v0.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect - github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect diff --git a/integrations/terraform/go.sum b/integrations/terraform/go.sum index ab7d7cfde700c..efc37828f30df 100644 --- a/integrations/terraform/go.sum +++ b/integrations/terraform/go.sum @@ -978,8 +978,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ 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-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= -github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= -github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= diff --git a/tool/tctl/common/top/box.go b/tool/tctl/common/top/box.go index c4a803fe3395b..1526d556e8085 100644 --- a/tool/tctl/common/top/box.go +++ b/tool/tctl/common/top/box.go @@ -32,6 +32,10 @@ import ( // │ Hello │ // ╰────────────────────╯ func boxedView(title string, content string, width int) string { + return boxedViewWithStyle(title, content, width, lipgloss.NewStyle().Faint(true)) +} + +func boxedViewWithStyle(title string, content string, width int, style lipgloss.Style) string { rounderBorder := lipgloss.RoundedBorder() const borderCorners = 2 @@ -55,11 +59,10 @@ func boxedView(title string, content string, width int) string { rounderBorder.Top = "" rounderBorder.TopRight = "" - contentStyle := lipgloss.NewStyle(). + contentStyle := style. BorderStyle(rounderBorder). PaddingLeft(1). PaddingRight(1). - Faint(true). Width(width) return renderedTitle + contentStyle.Render(content) diff --git a/tool/tctl/common/top/keys.go b/tool/tctl/common/top/keys.go new file mode 100644 index 0000000000000..8c27571a7ca92 --- /dev/null +++ b/tool/tctl/common/top/keys.go @@ -0,0 +1,104 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package top + +import "github.com/charmbracelet/bubbles/key" + +type keyMap struct { + Quit key.Binding + + // Movement + Right key.Binding + Left key.Binding + Up key.Binding + Down key.Binding + Filter key.Binding + + // Tabs + Common key.Binding + Backend key.Binding + Cache key.Binding + Watcher key.Binding + Audit key.Binding + Raw key.Binding +} + +// newDefaultKeymap returns default keybinds used by top +func newDefaultKeymap() *keyMap { + return &keyMap{ + Quit: key.NewBinding( + key.WithKeys("q", "ctrl+c"), + key.WithHelp("q", "quit"), + ), + Left: key.NewBinding( + key.WithKeys("left", "esc", "shift+tab", "h"), + key.WithHelp("←", "previous"), + ), + Right: key.NewBinding( + key.WithKeys("right", "tab", "l"), + key.WithHelp("→", "next"), + ), + Up: key.NewBinding( + key.WithKeys("up", "k"), + key.WithHelp("↑", "up"), + ), + Down: key.NewBinding( + key.WithKeys("down", "j"), + key.WithHelp("↓", "down"), + ), + Common: key.NewBinding( + key.WithKeys("1"), + key.WithHelp("1", "common"), + ), + Backend: key.NewBinding( + key.WithKeys("2"), + key.WithHelp("2", "backend"), + ), + Cache: key.NewBinding( + key.WithKeys("3"), + key.WithHelp("3", "cache"), + ), + Watcher: key.NewBinding( + key.WithKeys("4"), + key.WithHelp("4", "watcher"), + ), + Audit: key.NewBinding( + key.WithKeys("5"), + key.WithHelp("5", "audit"), + ), + Raw: key.NewBinding( + key.WithKeys("6"), + key.WithHelp("6", "raw"), + ), + + Filter: key.NewBinding( + key.WithKeys("/"), + key.WithHelp("/", "filter"), + ), + } +} + +func (k keyMap) ShortHelp() []key.Binding { + return []key.Binding{k.Left, k.Right, k.Quit} +} + +func (k keyMap) FullHelp() [][]key.Binding { + return [][]key.Binding{ + {k.Left, k.Right}, + {k.Quit}, + } +} diff --git a/tool/tctl/common/top/metrics.go b/tool/tctl/common/top/metrics.go new file mode 100644 index 0000000000000..e727d7233d5f9 --- /dev/null +++ b/tool/tctl/common/top/metrics.go @@ -0,0 +1,115 @@ +// Teleport +// Copyright (C) 2025 Gravitational, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package top + +import ( + "cmp" + "fmt" + "slices" + + "github.com/charmbracelet/bubbles/list" + "github.com/dustin/go-humanize" + dto "github.com/prometheus/client_model/go" +) + +// metricItem implements [list.DefaultItem] interface to allow rendering via [list.DefaultDelegate] +type metricItem struct { + name string + value string +} + +func (m metricItem) Title() string { return m.name } +func (m metricItem) Description() string { return m.value } +func (m metricItem) FilterValue() string { return m.name } + +// getMetricLabelString formats prometheus labels into a readable string. +// +// Example for multiple labels: +// +// "{label_key1=\"value1\",labelkey2=\"value2\"}" +// +// Empty slice will return: +// +// "" +func getMetricLabelString(labels []*dto.LabelPair) string { + var out string + + if len(labels) == 0 { + return "" + } + + for i, label := range labels { + if i > 0 { + out += "," + } + out += label.GetName() + "=\"" + label.GetValue() + "\"" + } + + return "{" + out + "}" +} + +// metricItemFromPromMetric returns a single [list.Item] from a prometheus metric family and a single metric. +func metricItemFromPromMetric(mf *dto.MetricFamily, m *dto.Metric) list.Item { + value := "n/a" + + switch mf.GetType() { + case dto.MetricType_COUNTER: + value = humanize.FormatFloat("", m.GetCounter().GetValue()) + case dto.MetricType_GAUGE: + value = humanize.FormatFloat("", m.GetGauge().GetValue()) + case dto.MetricType_SUMMARY: + value = fmt.Sprintf("count: %d sum: %s", + m.GetSummary().GetSampleCount(), + humanize.FormatFloat("", m.GetSummary().GetSampleSum()), + ) + case dto.MetricType_HISTOGRAM: + // List view does not allow enough space to make historgram buckets meaningful, only show sum and count. + value = fmt.Sprintf("count: %d sum: %s", + m.GetHistogram().GetSampleCount(), + humanize.FormatFloat("", m.GetHistogram().GetSampleSum()), + ) + } + + return metricItem{ + name: mf.GetName() + getMetricLabelString(m.GetLabel()), + value: value, + } +} + +// convertMetricsToItems converts a [metricsMsg] into a sorted slice of [list.Item] to be used in a list view. +func convertMetricsToItems(msg metricsMsg) []list.Item { + + var itemCount int + for _, mf := range msg { + itemCount += len(mf.GetMetric()) + } + + items := make([]list.Item, 0, itemCount) + + for _, mf := range msg { + for _, m := range mf.GetMetric() { + items = append(items, metricItemFromPromMetric(mf, m)) + } + } + + // Sort the item list to keep the display order consistent. + slices.SortFunc(items, func(i, j list.Item) int { + return cmp.Compare(i.FilterValue(), j.FilterValue()) + }) + + return items +} diff --git a/tool/tctl/common/top/model.go b/tool/tctl/common/top/model.go index f6a443e94e5a0..06837478a05fc 100644 --- a/tool/tctl/common/top/model.go +++ b/tool/tctl/common/top/model.go @@ -27,11 +27,13 @@ import ( "github.com/charmbracelet/bubbles/help" "github.com/charmbracelet/bubbles/key" + "github.com/charmbracelet/bubbles/list" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/dustin/go-humanize" "github.com/gravitational/trace" "github.com/guptarohit/asciigraph" + dto "github.com/prometheus/client_model/go" "github.com/gravitational/teleport/api/constants" ) @@ -44,84 +46,168 @@ type topModel struct { height int selected int help help.Model + keys *keyMap refreshInterval time.Duration clt MetricsClient + metricsList list.Model report *Report reportError error addr string } func newTopModel(refreshInterval time.Duration, clt MetricsClient, addr string) *topModel { + // A delegate is used to implement custom styling for list items. + delegate := list.NewDefaultDelegate() + delegate.Styles.NormalDesc = lipgloss.NewStyle().Faint(true) + delegate.Styles.NormalTitle = lipgloss.NewStyle().Faint(true) + delegate.Styles.SelectedTitle = lipgloss.NewStyle().Faint(false).Foreground(selectedColor) + delegate.Styles.SelectedDesc = lipgloss.NewStyle().Faint(false) + + metricsList := list.New(nil, delegate, 0, 0) + metricsList.SetShowTitle(false) + metricsList.SetShowFilter(true) + metricsList.SetShowStatusBar(true) + metricsList.SetShowHelp(false) + return &topModel{ help: help.New(), clt: clt, refreshInterval: refreshInterval, addr: addr, + keys: newDefaultKeymap(), + metricsList: metricsList, } } -// refresh pulls metrics from Teleport and builds -// a [Report] according to the configured refresh -// interval. -func (m *topModel) refresh() tea.Cmd { - return func() tea.Msg { - if m.report != nil { - <-time.After(m.refreshInterval) - } +// tickMsg is dispached when the refresh period expires. +type tickMsg time.Time +// metricsMsg contains new prometheus metrics. +type metricsMsg map[string]*dto.MetricFamily + +// tick provides a ticker at a specified interval. +func (m *topModel) tick() tea.Cmd { + return tea.Tick(m.refreshInterval, func(t time.Time) tea.Msg { + return tickMsg(t) + }) +} + +// fetchMetricsCmd fetches metrics from target and returns [metricsMsg]. +func (m *topModel) fetchMetricsCmd() tea.Cmd { + return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - report, err := fetchAndGenerateReport(ctx, m.clt, m.report, m.refreshInterval) + metrics, err := m.clt.GetMetrics(ctx) if err != nil { return err } + return metricsMsg(metrics) + } +} + +// generateReportCmd returns a command to generate a [Report] from given [metricsMsg] +func (m *topModel) generateReportCmd(metrics metricsMsg) tea.Cmd { + return func() tea.Msg { + report, err := generateReport(metrics, m.report, m.refreshInterval) + if err != nil { + return err + } return report } } -// Init is a noop but required to implement [tea.Model]. +// Init kickstarts the ticker to begin polling. func (m *topModel) Init() tea.Cmd { - return m.refresh() + return func() tea.Msg { + return tickMsg(time.Now()) + } +} + +// isMetricFilterFocused checks if the user is currently on the metric pane and filtering is in progress. +func (m *topModel) isMetricFilterFocused() bool { + return m.selected == 5 && m.metricsList.FilterState() == list.Filtering } // Update processes messages in order to updated the // view based on user input and new metrics data. func (m *topModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: h, v := lipgloss.NewStyle().GetFrameSize() m.height = msg.Height - v m.width = msg.Width - h + m.metricsList.SetSize(m.width, m.height-6 /* account for UI height */) case tea.KeyMsg: - switch msg.String() { - case "q", "ctrl+c": - return m, tea.Quit - case "1": - m.selected = 0 - case "2": - m.selected = 1 - case "3": - m.selected = 2 - case "4": - m.selected = 3 - case "5": - m.selected = 4 - case "right": - m.selected = min(m.selected+1, len(tabs)-1) - case "left": - m.selected = max(m.selected-1, 0) + if m.isMetricFilterFocused() { + // Redirect all keybinds to the list until the user is done. + var cmd tea.Cmd + m.metricsList, cmd = m.metricsList.Update(msg) + cmds = append(cmds, cmd) + } else { + switch { + case key.Matches(msg, m.keys.Quit): + return m, tea.Quit + case key.Matches(msg, m.keys.Common): + m.selected = 0 + case key.Matches(msg, m.keys.Backend): + m.selected = 1 + case key.Matches(msg, m.keys.Cache): + m.selected = 2 + case key.Matches(msg, m.keys.Watcher): + m.selected = 3 + case key.Matches(msg, m.keys.Audit): + m.selected = 4 + case key.Matches(msg, m.keys.Raw): + m.selected = 5 + case key.Matches(msg, m.keys.Right): + m.selected = (m.selected + 1) % len(tabs) + case key.Matches(msg, m.keys.Left): + m.selected = (m.selected - 1 + len(tabs)) % len(tabs) + case key.Matches(msg, m.keys.Filter), + key.Matches(msg, m.keys.Up), + key.Matches(msg, m.keys.Down): + // Only a subset of keybinds are forwarded to the list view. + var cmd tea.Cmd + m.metricsList, cmd = m.metricsList.Update(msg) + cmds = append(cmds, cmd) + } + } + case tickMsg: + cmds = append(cmds, m.tick(), m.fetchMetricsCmd()) + case metricsMsg: + cmds = append(cmds, m.generateReportCmd(msg)) + + filterValue := m.metricsList.FilterInput.Value() + selected := m.metricsList.Index() + + var cmd tea.Cmd + cmd = m.metricsList.SetItems(convertMetricsToItems(msg)) + cmds = append(cmds, cmd) + + // There is a glitch in the list.Model view when a filter has been applied + // the pagination status is broken after replacing all items. Workaround this by + // manually resetting the filter and updating the selection. + if m.metricsList.FilterState() == list.FilterApplied { + m.metricsList.SetFilterText(filterValue) + m.metricsList.Select(selected) } + case *Report: m.report = msg m.reportError = nil - return m, m.refresh() case error: m.reportError = msg - return m, m.refresh() + default: + // Forward internal messages to the metrics list. + var cmd tea.Cmd + m.metricsList, cmd = m.metricsList.Update(msg) + cmds = append(cmds, cmd) } - return m, nil + + return m, tea.Batch(cmds...) } // View formats the metrics and draws them to @@ -194,7 +280,7 @@ func (m *topModel) footerView() string { Inline(true). Width(35). Align(lipgloss.Center). - Render(m.help.View(helpKeys)) + Render(m.help.View(m.keys)) center := lipgloss.NewStyle(). Inline(true). @@ -226,6 +312,8 @@ func (m *topModel) contentView() string { return renderWatcher(m.report, m.height, m.width) case 4: return renderAudit(m.report, m.height, m.width) + case 5: + return boxedViewWithStyle("Prometheus Metrics", m.metricsList.View(), m.width, lipgloss.NewStyle()) default: return "" } @@ -558,41 +646,7 @@ func tabView(selectedTab int) string { return output } -// keyMap is used to display the help text at -// the bottom of the screen. -type keyMap struct { - quit key.Binding - right key.Binding - left key.Binding -} - -func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.left, k.right, k.quit} -} - -func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{ - {k.left, k.right}, - {k.quit}, - } -} - var ( - helpKeys = keyMap{ - quit: key.NewBinding( - key.WithKeys("q", "esc", "ctrl+c"), - key.WithHelp("q", "quit"), - ), - left: key.NewBinding( - key.WithKeys("left", "esc"), - key.WithHelp("left", "previous"), - ), - right: key.NewBinding( - key.WithKeys("right"), - key.WithHelp("right", "next"), - ), - } - statusBarStyle = lipgloss.NewStyle() separator = lipgloss.NewStyle(). @@ -604,5 +658,5 @@ var ( failedEventStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(fmt.Sprintf("%d", asciigraph.Red))) trimmedEventStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(fmt.Sprintf("%d", asciigraph.Goldenrod))) - tabs = []string{"Common", "Backend", "Cache", "Watcher", "Audit"} + tabs = []string{"Common", "Backend", "Cache", "Watcher", "Audit", "Raw Metrics"} ) diff --git a/tool/tctl/common/top/report.go b/tool/tctl/common/top/report.go index bec8f5ce4d56e..204d90af93c05 100644 --- a/tool/tctl/common/top/report.go +++ b/tool/tctl/common/top/report.go @@ -18,7 +18,6 @@ package top import ( "cmp" - "context" "fmt" "math" "os" @@ -26,7 +25,6 @@ import ( "strings" "time" - "github.com/gravitational/trace" "github.com/prometheus/client_golang/prometheus" dto "github.com/prometheus/client_model/go" @@ -371,14 +369,6 @@ type Bucket struct { UpperBound float64 } -func fetchAndGenerateReport(ctx context.Context, client MetricsClient, prev *Report, period time.Duration) (*Report, error) { - metrics, err := client.GetMetrics(ctx) - if err != nil { - return nil, trace.Wrap(err) - } - return generateReport(metrics, prev, period) -} - func generateReport(metrics map[string]*dto.MetricFamily, prev *Report, period time.Duration) (*Report, error) { // format top backend requests hostname, _ := os.Hostname()