diff --git a/go.mod b/go.mod
index 366ec23afe045..f0bea4cda8fc7 100644
--- a/go.mod
+++ b/go.mod
@@ -80,10 +80,10 @@ require (
github.com/beevik/etree v1.4.1
github.com/buildkite/bintest/v3 v3.3.0
github.com/charlievieth/strcase v0.0.5
- github.com/charmbracelet/bubbles v0.20.0
- github.com/charmbracelet/bubbletea v1.1.0
+ github.com/charmbracelet/bubbles v0.21.0
+ github.com/charmbracelet/bubbletea v1.3.4
github.com/charmbracelet/huh v0.6.0
- github.com/charmbracelet/lipgloss v0.13.0
+ github.com/charmbracelet/lipgloss v1.1.0
github.com/coreos/go-oidc v2.2.1+incompatible // replaced
github.com/coreos/go-oidc/v3 v3.12.0
github.com/coreos/go-semver v0.3.1
@@ -312,9 +312,11 @@ require (
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chai2010/gettext-go v1.0.2 // indirect
- github.com/charmbracelet/x/ansi v0.2.3 // 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/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
- github.com/charmbracelet/x/term v0.2.0 // 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
@@ -470,7 +472,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/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
+ github.com/muesli/termenv v0.16.0 // indirect
github.com/muhlemmer/gu v0.3.1 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
@@ -505,6 +507,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/sassoftware/relic v7.2.1+incompatible // indirect
github.com/secure-systems-lab/go-securesystemslib v0.9.0 // indirect
github.com/segmentio/asm v1.2.0 // indirect
@@ -542,6 +545,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.4.0 // indirect
diff --git a/go.sum b/go.sum
index ebea39f10e3f3..98344c7908570 100644
--- a/go.sum
+++ b/go.sum
@@ -964,6 +964,8 @@ github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1 h1:50sS0RWhGpW/
github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.9.1/go.mod h1:ErZOtbzuHabipRTDTor0inoRlYwbsV1ovwSxjGs/uJo=
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.4.1 h1:PmQJDDYahBGNKDcpdX8uPy1xRCwoCGVUiW669MEirVI=
github.com/beevik/etree v1.4.1/go.mod h1:gPNJNaBGVZ9AwsidazFZyygnd+0pAU38N4D+WemwKNs=
@@ -1025,20 +1027,26 @@ 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.20.0 h1:jSZu6qD8cRQ6k9OMfR1WlM+ruM8fkPWkHvQWD9LIutE=
-github.com/charmbracelet/bubbles v0.20.0/go.mod h1:39slydyswPy+uVOHZ5x/GjwVAFkCsV8IIVy+4MhzwwU=
-github.com/charmbracelet/bubbletea v1.1.0 h1:FjAl9eAL3HBCHenhz/ZPjkKdScmaS5SK69JAK2YJK9c=
-github.com/charmbracelet/bubbletea v1.1.0/go.mod h1:9Ogk0HrdbHolIKHdjfFpyXJmiCzGwy+FesYkZr7hYU4=
+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/huh v0.6.0 h1:mZM8VvZGuE0hoDXq6XLxRtgfWyTI3b2jZNKh0xWmax8=
github.com/charmbracelet/huh v0.6.0/go.mod h1:GGNKeWCeNzKpEOh/OJD8WBwTQjV3prFAtQPpLv+AVwU=
-github.com/charmbracelet/lipgloss v0.13.0 h1:4X3PPeoWEDCMvzDvGmTajSyYPcZM4+y8sCA/SsA3cjw=
-github.com/charmbracelet/lipgloss v0.13.0/go.mod h1:nw4zy0SBX/F/eAO1cWdcvy6qnkDUxr8Lw7dvFrAIbbY=
-github.com/charmbracelet/x/ansi v0.2.3 h1:VfFN0NUpcjBRd4DnKfRaIRo53KRgey/nhOoEqosGDEY=
-github.com/charmbracelet/x/ansi v0.2.3/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw=
+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/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
-github.com/charmbracelet/x/term v0.2.0 h1:cNB9Ot9q8I711MyZ7myUR5HFWL/lc3OpU8jZ4hwm0x0=
-github.com/charmbracelet/x/term v0.2.0/go.mod h1:GVxgxAbjUrmpvIINHIQnJJKpMlHiZ4cktEQCN6GWyF0=
+github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
+github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=
github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=
github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4=
@@ -1913,8 +1921,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/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
-github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a/go.mod h1:hxSnBBYLK21Vtq/PHd0S2FYCxBXzBua8ov5s1RobyRQ=
+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/muhlemmer/gu v0.3.1 h1:7EAqmFrW7n3hETvuAdmFmn4hS8W+z3LgKtrnow+YzNM=
github.com/muhlemmer/gu v0.3.1/go.mod h1:YHtHR+gxM+bKEIIs7Hmi9sPT3ZDUvTN/i88wQpZkrdM=
github.com/muhlemmer/httpforwarded v0.1.0 h1:x4DLrzXdliq8mprgUMR0olDvHGkou5BJsK/vWUetyzY=
@@ -2097,6 +2105,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/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A=
github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk=
github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4=
@@ -2270,6 +2280,8 @@ github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
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/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 f9af7a604de32..8756574fdca7a 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"
"iter"
"math"
@@ -27,7 +26,6 @@ import (
"strings"
"time"
- "github.com/gravitational/trace"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
@@ -374,14 +372,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()