diff --git a/go.mod b/go.mod
index 35573fdd64568..a627056e3d8a5 100644
--- a/go.mod
+++ b/go.mod
@@ -503,6 +503,7 @@ require (
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 // indirect
github.com/sagikazarmark/locafero v0.7.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
diff --git a/go.sum b/go.sum
index 3de49bca3f4c0..c39c6d0ed88e0 100644
--- a/go.sum
+++ b/go.sum
@@ -2056,6 +2056,8 @@ github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46 h1:GHRpF1pTW19a
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sagikazarmark/locafero v0.7.0 h1:5MqpDsTGNDhY8sGp0Aowyf0qKsPrhewaLSsFaodPcyo=
github.com/sagikazarmark/locafero v0.7.0/go.mod h1:2za3Cg5rMaTMoG/2Ulr9AwtFaIppKXTRYnozin4aB5k=
+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=
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()