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...)) +}