From adffbe975d6850b28da9582cfde28bf1d0cca530 Mon Sep 17 00:00:00 2001 From: rosstimothy <39066650+rosstimothy@users.noreply.github.com> Date: Tue, 28 Jan 2025 11:43:24 -0500 Subject: [PATCH] Switch tctl top to use charmbracelet/bubbletea (#51389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Most of the functionality was untouched, there were only few deviations to the original styling and alignment of components. The most notable changes are as follows: - The backend and cache tables no longer have a range column, range requests are now indicated by a preceding ®. - The watcher graphs are rendered with a different library and no longer have a confusing x axis - The colors do not match the original version - The cluster stats page includes the role count - Errors retrieving metrics no longer exits the process. The footer is updated to display errors and requires manual intervention to exit. If errors are intermittent future successful reports will restore functionality. --- go.mod | 2 - go.sum | 7 - tool/tctl/common/cmds.go | 3 +- tool/tctl/common/top/box.go | 66 +++ tool/tctl/common/top/command.go | 70 +++ tool/tctl/common/top/model.go | 524 ++++++++++++++++++ .../common/{top_command.go => top/report.go} | 486 +++------------- tool/tctl/common/top/table.go | 179 ++++++ 8 files changed, 924 insertions(+), 413 deletions(-) create mode 100644 tool/tctl/common/top/box.go create mode 100644 tool/tctl/common/top/command.go create mode 100644 tool/tctl/common/top/model.go rename tool/tctl/common/{top_command.go => top/report.go} (58%) create mode 100644 tool/tctl/common/top/table.go diff --git a/go.mod b/go.mod index c57fb89fe1fb8..7bd7144e546f7 100644 --- a/go.mod +++ b/go.mod @@ -98,7 +98,6 @@ require ( github.com/fsouza/fake-gcs-server v1.49.3 github.com/fxamacker/cbor/v2 v2.7.0 github.com/ghodss/yaml v1.0.0 - github.com/gizak/termui/v3 v3.1.0 github.com/go-git/go-git/v5 v5.13.1 github.com/go-jose/go-jose/v3 v3.0.3 github.com/go-ldap/ldap/v3 v3.4.8 @@ -459,7 +458,6 @@ require ( 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 - github.com/nsf/termbox-go v1.1.1 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/onsi/ginkgo/v2 v2.19.0 // indirect diff --git a/go.sum b/go.sum index 624e05f35a9e1..50b11edc31ce7 100644 --- a/go.sum +++ b/go.sum @@ -1213,8 +1213,6 @@ github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uq github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= -github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc= -github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY= github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA= github.com/go-asn1-ber/asn1-ber v1.5.5/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= @@ -1811,7 +1809,6 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE 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.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= @@ -1843,7 +1840,6 @@ github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa1 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= @@ -1894,9 +1890,6 @@ github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= -github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ= -github.com/nsf/termbox-go v1.1.1 h1:nksUPLCb73Q++DwbYUBEglYBRPZyoXJdrj5L+TkjyZY= -github.com/nsf/termbox-go v1.1.1/go.mod h1:T0cTdVuOwf7pHQNtfhnEbzHbcNyCEcVU4YPpouCbVxo= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= diff --git a/tool/tctl/common/cmds.go b/tool/tctl/common/cmds.go index 2cd7b7a579802..a2292e72b5696 100644 --- a/tool/tctl/common/cmds.go +++ b/tool/tctl/common/cmds.go @@ -22,6 +22,7 @@ import ( "github.com/gravitational/teleport/tool/tctl/common/accessmonitoring" "github.com/gravitational/teleport/tool/tctl/common/loginrule" "github.com/gravitational/teleport/tool/tctl/common/plugin" + "github.com/gravitational/teleport/tool/tctl/common/top" "github.com/gravitational/teleport/tool/tctl/sso/configure" "github.com/gravitational/teleport/tool/tctl/sso/tester" ) @@ -35,7 +36,7 @@ func Commands() []CLICommand { &TokensCommand{}, &AuthCommand{}, &StatusCommand{}, - &TopCommand{}, + &top.Command{}, &AccessRequestCommand{}, &AppsCommand{}, &DBCommand{}, diff --git a/tool/tctl/common/top/box.go b/tool/tctl/common/top/box.go new file mode 100644 index 0000000000000..c4a803fe3395b --- /dev/null +++ b/tool/tctl/common/top/box.go @@ -0,0 +1,66 @@ +// 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 ( + "strings" + + "github.com/charmbracelet/lipgloss" +) + +// boxedView wraps the provided content in a rounded border, +// with the title embedded in the top. For example, if the +// content was \t\t\tHello and the title was Some Heading the +// returned content would be: +// +// ╭Some Heading────────╮ +// │ │ +// │ Hello │ +// ╰────────────────────╯ +func boxedView(title string, content string, width int) string { + rounderBorder := lipgloss.RoundedBorder() + + const borderCorners = 2 + width = width - borderCorners + availableSpace := width - lipgloss.Width(title) + + var filler string + if availableSpace > 0 { + filler = strings.Repeat(rounderBorder.Top, availableSpace) + } + + titleContent := lipgloss.NewStyle(). + Foreground(selectedColor). + Render(title) + + renderedTitle := rounderBorder.TopLeft + titleContent + filler + rounderBorder.TopRight + + // empty out the top border since it + // is already manually applied to the title. + rounderBorder.TopLeft = "" + rounderBorder.Top = "" + rounderBorder.TopRight = "" + + contentStyle := lipgloss.NewStyle(). + BorderStyle(rounderBorder). + PaddingLeft(1). + PaddingRight(1). + Faint(true). + Width(width) + + return renderedTitle + contentStyle.Render(content) +} diff --git a/tool/tctl/common/top/command.go b/tool/tctl/common/top/command.go new file mode 100644 index 0000000000000..b917ff5fb9fcb --- /dev/null +++ b/tool/tctl/common/top/command.go @@ -0,0 +1,70 @@ +// 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 ( + "context" + "time" + + "github.com/alecthomas/kingpin/v2" + tea "github.com/charmbracelet/bubbletea" + "github.com/gravitational/roundtrip" + "github.com/gravitational/trace" + + "github.com/gravitational/teleport/lib/service/servicecfg" + commonclient "github.com/gravitational/teleport/tool/tctl/common/client" + tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" +) + +// Command is a debug command that consumes the +// Teleport /metrics endpoint and displays diagnostic +// information an easy to consume way. +type Command struct { + config *servicecfg.Config + top *kingpin.CmdClause + diagURL string + refreshPeriod time.Duration +} + +// Initialize sets up the "tctl top" command. +func (c *Command) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) { + c.config = config + c.top = app.Command("top", "Report diagnostic information.") + c.top.Arg("diag-addr", "Diagnostic HTTP URL").Default("http://127.0.0.1:3000").StringVar(&c.diagURL) + c.top.Arg("refresh", "Refresh period").Default("5s").DurationVar(&c.refreshPeriod) +} + +// TryRun attempts to run subcommands. +func (c *Command) TryRun(ctx context.Context, cmd string, _ commonclient.InitFunc) (match bool, err error) { + if cmd != c.top.FullCommand() { + return false, nil + } + + diagClient, err := roundtrip.NewClient(c.diagURL, "") + if err != nil { + return true, trace.Wrap(err) + } + + p := tea.NewProgram( + newTopModel(c.refreshPeriod, diagClient), + tea.WithAltScreen(), + tea.WithContext(ctx), + ) + + _, err = p.Run() + return true, trace.Wrap(err) +} diff --git a/tool/tctl/common/top/model.go b/tool/tctl/common/top/model.go new file mode 100644 index 0000000000000..be7debcd6105e --- /dev/null +++ b/tool/tctl/common/top/model.go @@ -0,0 +1,524 @@ +// 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" + "context" + "fmt" + "strings" + "time" + + "github.com/charmbracelet/bubbles/help" + "github.com/charmbracelet/bubbles/key" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/dustin/go-humanize" + "github.com/gravitational/roundtrip" + "github.com/gravitational/trace" + "github.com/guptarohit/asciigraph" + + "github.com/gravitational/teleport/api/constants" +) + +// topModel is a [tea.Model] implementation which +// displays various tabs and content displayed by +// the tctl top command. +type topModel struct { + width int + height int + selected int + help help.Model + refreshInterval time.Duration + clt *roundtrip.Client + report *Report + reportError error +} + +func newTopModel(refreshInterval time.Duration, clt *roundtrip.Client) *topModel { + return &topModel{ + help: help.New(), + clt: clt, + refreshInterval: refreshInterval, + } +} + +// 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) + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + report, err := fetchAndGenerateReport(ctx, m.clt, m.report, m.refreshInterval) + if err != nil { + return err + } + + return report + } +} + +// Init is a noop but required to implement [tea.Model]. +func (m *topModel) Init() tea.Cmd { + return m.refresh() +} + +// 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) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + h, v := lipgloss.NewStyle().GetFrameSize() + m.height = msg.Height - v + m.width = msg.Width - h + 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 "right": + m.selected = min(m.selected+1, len(tabs)-1) + case "left": + m.selected = max(m.selected-1, 0) + } + case *Report: + m.report = msg + m.reportError = nil + return m, m.refresh() + case error: + m.reportError = msg + return m, m.refresh() + } + return m, nil +} + +// View formats the metrics and draws them to +// the screen. +func (m *topModel) View() string { + availableHeight := m.height + header := headerView(m.selected, m.width) + availableHeight -= lipgloss.Height(header) + + footer := m.footerView() + availableHeight -= lipgloss.Height(footer) + + content := lipgloss.NewStyle(). + Height(availableHeight). + Width(m.width). + Render(m.contentView()) + + return lipgloss.JoinVertical(lipgloss.Left, + header, + content, + footer, + ) +} + +// headerView generates the tab bar displayed at +// the top of the screen. The selectedTab will be +// rendered a different color to indicate as such. +func headerView(selectedTab int, width int) string { + tabs := tabView(selectedTab) + + availableSpace := width - lipgloss.Width(tabs) + + var filler string + if availableSpace > 0 { + filler = strings.Repeat(" ", availableSpace) + } + + return tabs + lipgloss.NewStyle().Render(filler) + "\n" + strings.Repeat("‾", width) +} + +// footerView generates the help text displayed at the +// bottom of the screen. +func (m *topModel) footerView() string { + underscore := lipgloss.NewStyle().Underline(true).Render(" ") + underline := strings.Repeat(underscore, m.width) + + var leftContent string + + if m.reportError != nil { + if trace.IsConnectionProblem(m.reportError) { + leftContent = fmt.Sprintf("Could not connect to metrics service: %v", m.clt.Endpoint()) + } else { + leftContent = fmt.Sprintf("Failed to generate report: %v", m.reportError) + } + } + if leftContent == "" && m.report != nil { + leftContent = fmt.Sprintf("Report generated at %s for host %s", + m.report.Timestamp.Format(constants.HumanDateFormatSeconds), + m.report.Hostname, + ) + } + left := lipgloss.NewStyle(). + Inline(true). + Width(len(leftContent)). + MaxWidth(100). + Render(leftContent) + + right := lipgloss.NewStyle(). + Inline(true). + Width(35). + Align(lipgloss.Center). + Render(m.help.View(helpKeys)) + + center := lipgloss.NewStyle(). + Inline(true). + Width(m.width - len(leftContent) - 35). + Align(lipgloss.Center). + Render("") + + return underline + "\n" + + statusBarStyle.Render(left) + + statusBarStyle.Render(center) + + statusBarStyle.Render(right) +} + +// contentView generates the appropriate content +// based on which tab is selected. +func (m *topModel) contentView() string { + if m.report == nil { + return "" + } + + switch m.selected { + case 0: + return renderCommon(m.report, m.width) + case 1: + return renderBackend(m.report, m.height, m.width) + case 2: + return renderCache(m.report, m.height, m.width) + case 3: + return renderWatcher(m.report, m.height, m.width) + default: + return "" + } +} + +// renderCommon generates the view for the cluster stats tab. +func renderCommon(report *Report, width int) string { + columnWidth := width / 2 + + clusterTable := tableView( + columnWidth, + column{ + width: width / 3, + content: []string{ + "Interactive Sessions", + "Cert Gen Active Requests", + "Cert Gen Requests/sec", + "Cert Gen Throttled Requests/sec", + "Auth Watcher Queue Size", + "Roles", + "Active Migrations", + }, + }, + column{ + width: columnWidth / 3, + content: []string{ + humanize.FormatFloat("", report.Cluster.InteractiveSessions), + humanize.FormatFloat("", report.Cluster.GenerateRequests), + humanize.FormatFloat("", report.Cluster.GenerateRequestsCount.GetFreq()), + humanize.FormatFloat("", report.Cluster.GenerateRequestsThrottledCount.GetFreq()), + humanize.FormatFloat("", report.Cache.QueueSize), + humanize.FormatFloat("", report.Cluster.Roles), + cmp.Or(strings.Join(report.Cluster.ActiveMigrations, ", "), "None"), + }, + }, + ) + clusterContent := boxedView("Cluster Stats", clusterTable, columnWidth) + + processTable := tableView( + columnWidth, + column{ + width: columnWidth * 4 / 10, + content: []string{ + "Start Time", + "Resident Memory", + "CPU Seconds Total", + "Open FDs", + "Max FDs", + }, + }, + column{ + width: columnWidth * 6 / 10, + content: []string{ + report.Process.StartTime.Format(constants.HumanDateFormatSeconds), + humanize.Bytes(uint64(report.Process.ResidentMemoryBytes)), + humanize.FormatFloat("", report.Process.CPUSecondsTotal), + humanize.FormatFloat("", report.Process.OpenFDs), + humanize.FormatFloat("", report.Process.MaxFDs), + }, + }, + ) + processContent := boxedView("Process Stats", processTable, columnWidth) + + runtimeTable := tableView( + columnWidth, + column{ + width: columnWidth / 2, + content: []string{ + "Allocated Memory", + "Goroutines", + "Threads", + "Heap Objects", + "Heap Allocated Memory", + "Info", + }, + }, + column{ + width: columnWidth / 2, + content: []string{ + humanize.Bytes(uint64(report.Go.AllocBytes)), + humanize.FormatFloat("", report.Go.Goroutines), + humanize.FormatFloat("", report.Go.Threads), + humanize.FormatFloat("", report.Go.HeapObjects), + humanize.Bytes(uint64(report.Go.HeapAllocBytes)), + report.Go.Info, + }, + }, + ) + runtimeContent := boxedView("Go Runtime Stats", runtimeTable, columnWidth) + + certLatencyContent := boxedView("Generate Server Certificates Percentiles", "No data", columnWidth) + + style := lipgloss.NewStyle(). + Width(columnWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + return lipgloss.JoinHorizontal(lipgloss.Left, + style.Render( + lipgloss.JoinVertical(lipgloss.Left, + clusterContent, + processContent, + runtimeContent, + ), + ), + style.Render(certLatencyContent), + ) +} + +// renderBackend generates the view for the backend stats tab. +func renderBackend(report *Report, height, width int) string { + latencyWidth := width / 3 + requestsWidth := width * 2 / 3 + topRequestsContent := boxedView("Top Backend Requests", requestsTableView(height, requestsWidth, report.Backend), requestsWidth) + readLatentcyContent := boxedView("Backend Read Percentiles", percentileTableView(latencyWidth, report.Backend.Read), latencyWidth) + batchReadLatentyContent := boxedView("Backend Batch Read Percentiles", percentileTableView(latencyWidth, report.Backend.BatchRead), latencyWidth) + writeLatencyContent := boxedView("Backend Write Percentiles", percentileTableView(latencyWidth, report.Backend.Write), latencyWidth) + + latencyStyle := lipgloss.NewStyle(). + Width(latencyWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + requestsStyle := lipgloss.NewStyle(). + Width(requestsWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + return lipgloss.JoinHorizontal(lipgloss.Left, + requestsStyle.Render(topRequestsContent), + latencyStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + readLatentcyContent, + batchReadLatentyContent, + writeLatencyContent, + ), + ), + ) +} + +// renderCache generates the view for the cache stats tab. +func renderCache(report *Report, height, width int) string { + latencyWidth := width / 3 + requestsWidth := width * 2 / 3 + + topRequestsContent := boxedView("Top Cache Requests", requestsTableView(height, requestsWidth, report.Cache), requestsWidth) + readLatentcyContent := boxedView("Cache Read Percentiles", percentileTableView(latencyWidth, report.Cache.Read), latencyWidth) + batchReadLatentyContent := boxedView("Cache Batch Read Percentiles", percentileTableView(latencyWidth, report.Cache.BatchRead), latencyWidth) + writeLatencyContent := boxedView("Cache Write Percentiles", percentileTableView(latencyWidth, report.Cache.Write), latencyWidth) + + latencyStyle := lipgloss.NewStyle(). + Width(latencyWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + requestsStyle := lipgloss.NewStyle(). + Width(requestsWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + return lipgloss.JoinHorizontal(lipgloss.Left, + requestsStyle.Render(topRequestsContent), + latencyStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + readLatentcyContent, + batchReadLatentyContent, + writeLatencyContent, + ), + ), + ) +} + +// renderWatcher generates the view for the watcher stats tab. +func renderWatcher(report *Report, height, width int) string { + graphWidth := width * 40 / 100 + graphHeight := height / 3 + eventsWidth := width * 60 / 100 + + topEventsContent := boxedView("Top Events Emitted", eventsTableView(height, eventsWidth, report.Watcher), eventsWidth) + + dataCount := (graphWidth / 2) + eventData := report.Watcher.EventsPerSecond.Data(dataCount) + if len(eventData) < 1 { + eventData = []float64{0, 0} + } + countPlot := asciigraph.Plot( + eventData, + asciigraph.Height(graphHeight), + asciigraph.Width(graphWidth-15), + ) + eventCountContent := boxedView("Events/Sec", countPlot, graphWidth) + + sizeData := report.Watcher.BytesPerSecond.Data(dataCount) + if len(sizeData) < 1 { + sizeData = []float64{0, 0} + } + sizePlot := asciigraph.Plot( + sizeData, + asciigraph.Height(graphHeight), + asciigraph.Width(graphWidth-15), + ) + eventSizeContent := boxedView("Bytes/Sec", sizePlot, graphWidth) + + graphStyle := lipgloss.NewStyle(). + Width(graphWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + eventsStyle := lipgloss.NewStyle(). + Width(eventsWidth). + Padding(0). + Margin(0). + Align(lipgloss.Left) + + return lipgloss.JoinHorizontal(lipgloss.Left, + eventsStyle.Render(topEventsContent), + graphStyle.Render( + lipgloss.JoinVertical(lipgloss.Left, + eventCountContent, + eventSizeContent, + ), + ), + ) +} + +// tabView renders the tabbed content in the header. +func tabView(selectedTab int) string { + output := lipgloss.NewStyle(). + Underline(true). + Render("") + + for i, tab := range tabs { + lastItem := i == len(tabs)-1 + selected := i == selectedTab + + var color lipgloss.Color + if selected { + color = selectedColor + } + + output += lipgloss.NewStyle(). + Foreground(color). + Faint(!selected). + Render(tab) + + if !lastItem { + output += separator + } + } + + 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(). + Faint(true). + Render(" • ") + + selectedColor = lipgloss.Color("4") + + tabs = []string{"Common", "Backend", "Cache", "Watcher"} +) diff --git a/tool/tctl/common/top_command.go b/tool/tctl/common/top/report.go similarity index 58% rename from tool/tctl/common/top_command.go rename to tool/tctl/common/top/report.go index b19ca6a069de2..95ff68382804f 100644 --- a/tool/tctl/common/top_command.go +++ b/tool/tctl/common/top/report.go @@ -1,37 +1,33 @@ -/* - * Teleport - * Copyright (C) 2023 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 common +// 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" "context" "fmt" + "iter" "math" "net/url" "os" - "sort" + "slices" "strings" "time" - "github.com/alecthomas/kingpin/v2" - "github.com/dustin/go-humanize" - ui "github.com/gizak/termui/v3" - "github.com/gizak/termui/v3/widgets" "github.com/gravitational/roundtrip" "github.com/gravitational/trace" "github.com/prometheus/client_golang/prometheus" @@ -39,349 +35,10 @@ import ( "github.com/prometheus/common/expfmt" "github.com/gravitational/teleport" - "github.com/gravitational/teleport/api/constants" "github.com/gravitational/teleport/api/types" - "github.com/gravitational/teleport/lib/service/servicecfg" "github.com/gravitational/teleport/lib/utils" - commonclient "github.com/gravitational/teleport/tool/tctl/common/client" - tctlcfg "github.com/gravitational/teleport/tool/tctl/common/config" ) -// TopCommand implements `tctl top` group of commands. -type TopCommand struct { - config *servicecfg.Config - - // CLI clauses (subcommands) - top *kingpin.CmdClause - diagURL *string - refreshPeriod *time.Duration -} - -// Initialize allows TopCommand to plug itself into the CLI parser. -func (c *TopCommand) Initialize(app *kingpin.Application, _ *tctlcfg.GlobalCLIFlags, config *servicecfg.Config) { - c.config = config - c.top = app.Command("top", "Report diagnostic information.") - c.diagURL = c.top.Arg("diag-addr", "Diagnostic HTTP URL").Default("http://127.0.0.1:3000").String() - c.refreshPeriod = c.top.Arg("refresh", "Refresh period").Default("5s").Duration() -} - -// TryRun takes the CLI command as an argument (like "nodes ls") and executes it. -func (c *TopCommand) TryRun(ctx context.Context, cmd string, _ commonclient.InitFunc) (match bool, err error) { - switch cmd { - case c.top.FullCommand(): - diagClient, err := roundtrip.NewClient(*c.diagURL, "") - if err != nil { - return true, trace.Wrap(err) - } - err = c.Top(ctx, diagClient) - if trace.IsConnectionProblem(err) { - return true, trace.ConnectionProblem(err, - "[CLIENT] Could not connect to metrics service at %v. Is teleport running with --diag-addr=%v?", *c.diagURL, *c.diagURL) - } - return true, trace.Wrap(err) - default: - return false, nil - } -} - -// Top is called to execute "status" CLI command. -func (c *TopCommand) Top(ctx context.Context, client *roundtrip.Client) error { - if err := ui.Init(); err != nil { - return trace.Wrap(err) - } - defer ui.Close() - - uiEvents := ui.PollEvents() - ticker := time.NewTicker(*c.refreshPeriod) - defer ticker.Stop() - - // fetch and render first time - var prev *Report - re, err := c.fetchAndGenerateReport(ctx, client, nil) - if err != nil { - return trace.Wrap(err) - } - lastTab := "" - if err := c.render(ctx, *re, lastTab); err != nil { - return trace.Wrap(err) - } - for { - select { - case e := <-uiEvents: - switch e.ID { // event string/identifier - case "q", "": // press 'q' or 'C-c' to quit - return nil - } - if e.ID == "1" || e.ID == "2" || e.ID == "3" || e.ID == "4" { - lastTab = e.ID - } - // render previously fetched data on the resize event - if re != nil { - if err := c.render(ctx, *re, lastTab); err != nil { - return trace.Wrap(err) - } - } - case <-ticker.C: - // fetch data and re-render on ticker - prev = re - re, err = c.fetchAndGenerateReport(ctx, client, prev) - if err != nil { - return trace.Wrap(err) - } - if err := c.render(ctx, *re, lastTab); err != nil { - return trace.Wrap(err) - } - } - } -} - -func (c *TopCommand) render(ctx context.Context, re Report, eventID string) error { - h := widgets.NewParagraph() - h.Text = fmt.Sprintf("Report Generated at %v for host %v. Press or Ctrl-C to quit.", - re.Timestamp.Format(constants.HumanDateFormatSeconds), re.Hostname) - h.Border = false - h.TextStyle = ui.NewStyle(ui.ColorMagenta) - - termWidth, termHeight := ui.TerminalDimensions() - - backendRequestsTable := func(title string, b BackendStats) *widgets.Table { - t := widgets.NewTable() - t.Title = title - t.TitleStyle = ui.NewStyle(ui.ColorCyan) - t.ColumnWidths = []int{10, 10, 10, 50000} - t.RowSeparator = false - t.Rows = [][]string{ - {"Count", "Req/Sec", "Range", "Key"}, - } - for _, req := range b.SortedTopRequests() { - t.Rows = append(t.Rows, - []string{ - humanize.FormatFloat("", float64(req.Count)), - humanize.FormatFloat("", req.GetFreq()), - fmt.Sprintf("%v", req.Key.IsRange()), - req.Key.Key, - }) - } - return t - } - - eventsTable := func(w *WatcherStats) *widgets.Table { - t := widgets.NewTable() - t.Title = "Top Events Emitted" - t.TitleStyle = ui.NewStyle(ui.ColorCyan) - t.ColumnWidths = []int{10, 10, 10, 50000} - t.RowSeparator = false - t.Rows = [][]string{ - {"Count", "Req/Sec", "Avg Size", "Resource"}, - } - for _, event := range w.SortedTopEvents() { - t.Rows = append(t.Rows, - []string{ - humanize.FormatFloat("", float64(event.Count)), - humanize.FormatFloat("", event.GetFreq()), - humanize.FormatFloat("", event.AverageSize()), - event.Resource, - }) - } - return t - } - - eventsGraph := func(title string, buf *utils.CircularBuffer) *widgets.Plot { - lc := widgets.NewPlot() - lc.Title = title - lc.TitleStyle = ui.NewStyle(ui.ColorCyan) - lc.Data = make([][]float64, 1) - // only get the most recent events to fill the graph - lc.Data[0] = buf.Data((termWidth / 2) - 10) - lc.AxesColor = ui.ColorWhite - lc.LineColors[0] = ui.ColorGreen - lc.Marker = widgets.MarkerDot - - return lc - } - - t1 := widgets.NewTable() - t1.Title = "Cluster Stats" - t1.TitleStyle = ui.NewStyle(ui.ColorCyan) - t1.ColumnWidths = []int{30, 50000} - t1.RowSeparator = false - t1.Rows = [][]string{ - {"Interactive Sessions", humanize.FormatFloat("", re.Cluster.InteractiveSessions)}, - {"Cert Gen Active Requests", humanize.FormatFloat("", re.Cluster.GenerateRequests)}, - {"Cert Gen Requests/sec", humanize.FormatFloat("", re.Cluster.GenerateRequestsCount.GetFreq())}, - {"Cert Gen Throttled Requests/sec", humanize.FormatFloat("", re.Cluster.GenerateRequestsThrottledCount.GetFreq())}, - {"Auth Watcher Queue Size", humanize.FormatFloat("", re.Cache.QueueSize)}, - {"Active Migrations", strings.Join(re.Cluster.ActiveMigrations, ", ")}, - } - for _, rc := range re.Cluster.RemoteClusters { - t1.Rows = append(t1.Rows, []string{ - fmt.Sprintf("Cluster %v", rc.Name), rc.IsConnected(), - }) - } - - t2 := widgets.NewTable() - t2.Title = "Process Stats" - t2.TitleStyle = ui.NewStyle(ui.ColorCyan) - t2.ColumnWidths = []int{30, 50000} - t2.RowSeparator = false - t2.Rows = [][]string{ - {"Start Time", re.Process.StartTime.Format(constants.HumanDateFormatSeconds)}, - {"Resident Memory Bytes", humanize.Bytes(uint64(re.Process.ResidentMemoryBytes))}, - {"Open File Descriptors", humanize.FormatFloat("", re.Process.OpenFDs)}, - {"CPU Seconds Total", humanize.FormatFloat("", re.Process.CPUSecondsTotal)}, - {"Max File Descriptors", humanize.FormatFloat("", re.Process.MaxFDs)}, - } - - t3 := widgets.NewTable() - t3.Title = "Go Runtime Stats" - t3.TitleStyle = ui.NewStyle(ui.ColorCyan) - t3.ColumnWidths = []int{30, 50000} - t3.RowSeparator = false - t3.Rows = [][]string{ - {"Allocated Memory", humanize.Bytes(uint64(re.Go.AllocBytes))}, - {"Goroutines", humanize.FormatFloat("", re.Go.Goroutines)}, - {"Threads", humanize.FormatFloat("", re.Go.Threads)}, - {"Heap Objects", humanize.FormatFloat("", re.Go.HeapObjects)}, - {"Heap Allocated Memory", humanize.Bytes(uint64(re.Go.HeapAllocBytes))}, - {"Info", re.Go.Info}, - } - - percentileTable := func(title string, hist Histogram) *widgets.Table { - t := widgets.NewTable() - t.Title = title - t.TitleStyle = ui.NewStyle(ui.ColorCyan) - - if hist.Count == 0 { - t.Rows = [][]string{ - {"No data"}, - } - return t - } - - t.ColumnWidths = []int{30, 50000} - t.RowSeparator = false - t.Rows = [][]string{ - {"Percentile", "Latency"}, - } - for _, p := range hist.AsPercentiles() { - t.Rows = append(t.Rows, []string{ - humanize.FormatFloat("#,###", p.Percentile) + "%", - fmt.Sprintf("%v", p.Value), - }) - } - return t - } - - grid := ui.NewGrid() - grid.SetRect(0, 0, termWidth, termHeight) - - tabpane := widgets.NewTabPane("[1] Common", "[2] Backend Stats", "[3] Cache Stats", "[4] Event Stats") - tabpane.ActiveTabStyle = ui.NewStyle(ui.ColorCyan, ui.ColorClear, ui.ModifierBold|ui.ModifierUnderline) - tabpane.InactiveTabStyle = ui.NewStyle(ui.ColorCyan) - tabpane.Border = false - - switch eventID { - case "", "1": - tabpane.ActiveTabIndex = 0 - grid.Set( - ui.NewRow(0.05, - ui.NewCol(1.0, tabpane), - ), - ui.NewRow(0.925, - ui.NewCol(0.5, - ui.NewRow(0.3, t1), - ui.NewRow(0.3, t2), - ui.NewRow(0.3, t3), - ), - ui.NewCol(0.5, - ui.NewRow(0.3, percentileTable("Generate Server Certificates Percentiles", re.Cluster.GenerateRequestsHistogram)), - ), - ), - ui.NewRow(0.025, - ui.NewCol(1.0, h), - ), - ) - case "2": - tabpane.ActiveTabIndex = 1 - grid.Set( - ui.NewRow(0.05, - ui.NewCol(1.0, tabpane), - ), - ui.NewRow(0.925, - ui.NewCol(0.5, - ui.NewRow(1.0, backendRequestsTable("Top Backend Requests", re.Backend)), - ), - ui.NewCol(0.5, - ui.NewRow(0.3, percentileTable("Backend Read Percentiles", re.Backend.Read)), - ui.NewRow(0.3, percentileTable("Backend Batch Read Percentiles", re.Backend.BatchRead)), - ui.NewRow(0.3, percentileTable("Backend Write Percentiles", re.Backend.Write)), - ), - ), - ui.NewRow(0.025, - ui.NewCol(1.0, h), - ), - ) - case "3": - tabpane.ActiveTabIndex = 2 - grid.Set( - ui.NewRow(0.05, - ui.NewCol(1.0, tabpane), - ), - ui.NewRow(0.925, - ui.NewCol(0.5, - ui.NewRow(1.0, backendRequestsTable("Top Cache Requests", re.Cache)), - ), - ui.NewCol(0.5, - ui.NewRow(0.3, percentileTable("Cache Read Percentiles", re.Cache.Read)), - ui.NewRow(0.3, percentileTable("Cache Batch Read Percentiles", re.Cache.BatchRead)), - ui.NewRow(0.3, percentileTable("Cache Write Percentiles", re.Cache.Write)), - ), - ), - ui.NewRow(0.025, - ui.NewCol(1.0, h), - ), - ) - case "4": - tabpane.ActiveTabIndex = 3 - grid.Set( - ui.NewRow(0.05, - ui.NewCol(1.0, tabpane), - ), - ui.NewRow(0.925, - ui.NewCol(0.5, - ui.NewRow(1.0, eventsTable(re.Watcher)), - ), - ui.NewCol(0.5, - ui.NewRow(0.5, eventsGraph("Events/Sec", re.Watcher.EventsPerSecond)), - ui.NewRow(0.5, eventsGraph("Bytes/Sec", re.Watcher.BytesPerSecond)), - ), - ), - ui.NewRow(0.025, - ui.NewCol(1.0, h), - ), - ) - } - ui.Render(grid) - return nil -} - -func (c *TopCommand) fetchAndGenerateReport(ctx context.Context, client *roundtrip.Client, prev *Report) (*Report, error) { - metrics, err := c.getPrometheusMetrics(ctx, client) - if err != nil { - return nil, trace.Wrap(err) - } - return generateReport(metrics, prev, *c.refreshPeriod) -} - -func (c *TopCommand) getPrometheusMetrics(ctx context.Context, client *roundtrip.Client) (map[string]*dto.MetricFamily, error) { - re, err := client.Get(ctx, client.Endpoint("metrics"), url.Values{}) - if err != nil { - return nil, trace.Wrap(trace.ConvertSystemError(err)) - } - var parser expfmt.TextParser - return parser.TextToMetricFamilies(re.Reader()) -} - // Report is a report rendered over the data type Report struct { // Version is a report version @@ -395,9 +52,9 @@ type Report struct { // Go contains go runtime stats Go GoStats // Backend is a backend stats - Backend BackendStats + Backend *BackendStats // Cache is cache stats - Cache BackendStats + Cache *BackendStats // Cluster is cluster stats Cluster ClusterStats // Watcher is watcher stats @@ -425,16 +82,17 @@ func (b *WatcherStats) SortedTopEvents() []Event { out = append(out, events) } - sort.Slice(out, func(i, j int) bool { - if out[i].GetFreq() != out[j].GetFreq() { - return out[i].GetFreq() > out[j].GetFreq() + // Comparisons are inverted to ensure ordering is highest to lowest. + slices.SortFunc(out, func(a, b Event) int { + if a.GetFreq() != b.GetFreq() { + return cmp.Compare(a.GetFreq(), b.GetFreq()) * -1 } - if out[i].Count != out[j].Count { - return out[i].Count > out[j].Count + if a.Count != b.Count { + return cmp.Compare(a.Count, b.Count) * -1 } - return out[i].Resource < out[j].Resource + return cmp.Compare(a.Resource, b.Resource) * -1 }) return out } @@ -510,16 +168,17 @@ func (b *BackendStats) SortedTopRequests() []Request { out = append(out, req) } - sort.Slice(out, func(i, j int) bool { - if out[i].GetFreq() != out[j].GetFreq() { - return out[i].GetFreq() > out[j].GetFreq() + // Comparisons are inverted to ensure ordering is highest to lowest. + slices.SortFunc(out, func(a, b Request) int { + if a.GetFreq() != b.GetFreq() { + return cmp.Compare(a.GetFreq(), b.GetFreq()) * -1 } - if out[i].Count != out[j].Count { - return out[i].Count > out[j].Count + if a.Count != b.Count { + return cmp.Compare(a.Count, b.Count) * -1 } - return out[i].Key.Key < out[j].Key.Key + return cmp.Compare(a.Key.Key, b.Key.Key) * -1 }) return out } @@ -541,6 +200,8 @@ type ClusterStats struct { GenerateRequestsHistogram Histogram // ActiveMigrations is a set of active migrations ActiveMigrations []string + // Roles is the number of roles that exist in the cluster. + Roles float64 } // RemoteCluster is a remote cluster (or local cluster) @@ -630,30 +291,34 @@ type Percentile struct { Value time.Duration } -// AsPercentiles interprets histogram as a bucket of percentiles -// and returns calculated percentiles -func (h Histogram) AsPercentiles() []Percentile { - if h.Count == 0 { - return nil - } - var percentiles []Percentile - for _, bucket := range h.Buckets { - if bucket.Count == 0 { - continue +// Percentiles returns an iterator of the percentiles +// of the buckets within the historgram. +func (h Histogram) Percentiles() iter.Seq[Percentile] { + return func(yield func(Percentile) bool) { + if h.Count == 0 { + return } - if bucket.Count == h.Count || math.IsInf(bucket.UpperBound, 0) { - percentiles = append(percentiles, Percentile{ - Percentile: 100, + + for _, bucket := range h.Buckets { + if bucket.Count == 0 { + continue + } + if bucket.Count == h.Count || math.IsInf(bucket.UpperBound, 0) { + yield(Percentile{ + Percentile: 100, + Value: time.Duration(bucket.UpperBound * float64(time.Second)), + }) + return + } + + if !yield(Percentile{ + Percentile: 100 * (float64(bucket.Count) / float64(h.Count)), Value: time.Duration(bucket.UpperBound * float64(time.Second)), - }) - return percentiles + }) { + return + } } - percentiles = append(percentiles, Percentile{ - Percentile: 100 * (float64(bucket.Count) / float64(h.Count)), - Value: time.Duration(bucket.UpperBound * float64(time.Second)), - }) } - return percentiles } // Bucket is a histogram bucket @@ -664,6 +329,20 @@ type Bucket struct { UpperBound float64 } +func fetchAndGenerateReport(ctx context.Context, client *roundtrip.Client, prev *Report, period time.Duration) (*Report, error) { + re, err := client.Get(ctx, client.Endpoint("metrics"), url.Values{}) + if err != nil { + return nil, trace.Wrap(trace.ConvertSystemError(err)) + } + + var parser expfmt.TextParser + metrics, err := parser.TextToMetricFamilies(re.Reader()) + 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() @@ -671,10 +350,10 @@ func generateReport(metrics map[string]*dto.MetricFamily, prev *Report, period t Version: types.V1, Timestamp: time.Now().UTC(), Hostname: hostname, - Backend: BackendStats{ + Backend: &BackendStats{ TopRequests: make(map[RequestKey]Request), }, - Cache: BackendStats{ + Cache: &BackendStats{ TopRequests: make(map[RequestKey]Request), }, } @@ -698,15 +377,15 @@ func generateReport(metrics map[string]*dto.MetricFamily, prev *Report, period t var stats *BackendStats if prev != nil { - stats = &prev.Backend + stats = prev.Backend } - collectBackendStats(teleport.ComponentBackend, &re.Backend, stats) + collectBackendStats(teleport.ComponentBackend, re.Backend, stats) if prev != nil { - stats = &prev.Cache + stats = prev.Cache } else { stats = nil } - collectBackendStats(teleport.ComponentCache, &re.Cache, stats) + collectBackendStats(teleport.ComponentCache, re.Cache, stats) re.Cache.QueueSize = getComponentGaugeValue(teleport.Component(teleport.ComponentAuth, teleport.ComponentCache), metrics[teleport.MetricBackendWatcherQueues]) @@ -742,6 +421,7 @@ func generateReport(metrics map[string]*dto.MetricFamily, prev *Report, period t GenerateRequestsThrottledCount: Counter{Count: getCounterValue(metrics[teleport.MetricGenerateRequestsThrottled])}, GenerateRequestsHistogram: getHistogram(metrics[teleport.MetricGenerateRequestsHistogram], atIndex(0)), ActiveMigrations: getActiveMigrations(metrics[prometheus.BuildFQName(teleport.MetricNamespace, "", teleport.MetricMigrations)]), + Roles: getGaugeValue(metrics[prometheus.BuildFQName(teleport.MetricNamespace, "", "roles_total")]), } if prev != nil { diff --git a/tool/tctl/common/top/table.go b/tool/tctl/common/top/table.go new file mode 100644 index 0000000000000..94c682f7bc15f --- /dev/null +++ b/tool/tctl/common/top/table.go @@ -0,0 +1,179 @@ +// 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 ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/dustin/go-humanize" +) + +type column struct { + width int + content []string +} + +// tableView renders two columns in a table like view +// that has no headings. Content lengths of the columns +// is required to match. +func tableView(width int, first, second column) string { + if len(first.content) != len(second.content) { + panic("column content must have equal heights") + } + + style := lipgloss.NewStyle(). + Width(width) + + leftColumn := lipgloss.NewStyle(). + Width(first.width). + Align(lipgloss.Left) + + rightColumn := lipgloss.NewStyle(). + Width(second.width). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + var rows []string + for i := 0; i < len(first.content); i++ { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, + leftColumn.Render(first.content[i]), + rightColumn.Render(second.content[i]), + )) + } + + return style.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} + +// percentileTableView renders a dynamic table like view +// displaying the percentiles of the provided histogram. +func percentileTableView(width int, hist Histogram) string { + firstColumn := column{ + width: width / 2, + content: []string{ + "Percentile", + }, + } + + secondColumn := column{ + width: width / 2, + content: []string{ + "Latency", + }, + } + + for p := range hist.Percentiles() { + firstColumn.content = append(firstColumn.content, humanize.FormatFloat("#,###", p.Percentile)+"%") + secondColumn.content = append(secondColumn.content, fmt.Sprintf("%v", p.Value)) + } + + return tableView(width, firstColumn, secondColumn) +} + +// requestsTableView renders a table like view +// displaying information about backend request stats. +func requestsTableView(height, width int, stats *BackendStats) string { + style := lipgloss.NewStyle(). + Width(width). + MaxHeight(height - 10) + + countColumn := lipgloss.NewStyle(). + Width(10). + Border(lipgloss.RoundedBorder(), false, false, false, false). + Align(lipgloss.Left) + + frequencyColumn := lipgloss.NewStyle(). + Width(8). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + keyColumn := lipgloss.NewStyle(). + Width(width-18). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + rows := []string{ + lipgloss.JoinHorizontal(lipgloss.Left, + countColumn.Render("Count"), + frequencyColumn.Render("Req/Sec"), + frequencyColumn.Render("Key"), + ), + } + + for _, req := range stats.SortedTopRequests() { + var key string + if req.Key.Range { + key = "®" + req.Key.Key + } else { + key = " " + req.Key.Key + } + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, + countColumn.Render(humanize.FormatFloat("", float64(req.Count))), + frequencyColumn.Render(humanize.FormatFloat("", req.GetFreq())), + keyColumn.Render(key), + )) + } + + return style.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +} + +// eventsTableView renders a table like view +// displaying information about watcher event stats. +func eventsTableView(height, width int, stats *WatcherStats) string { + style := lipgloss.NewStyle(). + Width(width). + MaxHeight(height - 10) + + countColumn := lipgloss.NewStyle(). + Width(10). + Border(lipgloss.RoundedBorder(), false, false, false, false). + Align(lipgloss.Left) + + frequencyColumn := lipgloss.NewStyle(). + Width(8). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + sizeColumn := lipgloss.NewStyle(). + Width(8). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + resourceColumn := lipgloss.NewStyle(). + Width(width-18). + Border(lipgloss.RoundedBorder(), false, false, false, true). + Align(lipgloss.Left) + + rows := []string{ + lipgloss.JoinHorizontal(lipgloss.Left, + countColumn.Render("Count"), + frequencyColumn.Render("Req/Sec"), + frequencyColumn.Render("Avg Size"), + frequencyColumn.Render("Resource"), + ), + } + + for _, event := range stats.SortedTopEvents() { + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Left, + countColumn.Render(humanize.FormatFloat("", float64(event.Count))), + frequencyColumn.Render(humanize.FormatFloat("", event.GetFreq())), + sizeColumn.Render(humanize.FormatFloat("", event.AverageSize())), + resourceColumn.Render(event.Resource), + )) + } + + return style.Render(lipgloss.JoinVertical(lipgloss.Left, rows...)) +}