1
0
mirror of https://github.com/minio/mc.git synced 2025-07-31 18:24:21 +03:00

provide a overall summary along with call-wise (#4970)

```
Duration: 1m3s ▱▱▱
RPM    :  1168103.3
RX Rate:↑ 2.5 TiB/m
TX Rate:↓ 2.4 TiB/m
-------------
...
```
This commit is contained in:
Harshavardhana
2024-06-28 16:17:44 -07:00
committed by GitHub
parent 3548007d5b
commit 6811427b8b
3 changed files with 280 additions and 248 deletions

View File

@ -24,17 +24,13 @@ import (
"hash/fnv" "hash/fnv"
"net/http" "net/http"
"net/url" "net/url"
"os"
"path" "path"
"sort" "sort"
"strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/fatih/color" "github.com/fatih/color"
"github.com/minio/cli" "github.com/minio/cli"
@ -42,9 +38,6 @@ import (
"github.com/minio/madmin-go/v3" "github.com/minio/madmin-go/v3"
"github.com/minio/mc/pkg/probe" "github.com/minio/mc/pkg/probe"
"github.com/minio/pkg/v3/console" "github.com/minio/pkg/v3/console"
"github.com/muesli/reflow/truncate"
"github.com/olekukonko/tablewriter"
"golang.org/x/term"
) )
var adminTraceFlags = []cli.Flag{ var adminTraceFlags = []cli.Flag{
@ -94,12 +87,12 @@ var adminTraceFlags = []cli.Flag{
}, },
cli.BoolFlag{ cli.BoolFlag{
Name: "stats", Name: "stats",
Usage: "accumulate stats", Usage: "print statistical summary of all the traced calls",
}, },
cli.IntFlag{ cli.IntFlag{
Name: "stats-n", Name: "stats-n",
Usage: "maximum number of stat entries", Usage: "maximum number of stat entries",
Value: 15, Value: 30,
Hidden: true, Hidden: true,
}, },
cli.BoolFlag{ cli.BoolFlag{
@ -979,227 +972,3 @@ func (s *statTrace) add(t madmin.ServiceTraceInfo) {
} }
s.Calls[id] = got s.Calls[id] = got
} }
func initTraceStatsUI(maxEntries int, traces <-chan madmin.ServiceTraceInfo) *traceStatsUI {
s := spinner.New()
s.Spinner = spinner.Points
s.Spinner.FPS = time.Second / 2
s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
console.SetColor("metrics-duration", color.New(color.FgWhite))
console.SetColor("metrics-size", color.New(color.FgGreen))
console.SetColor("metrics-dur", color.New(color.FgGreen))
console.SetColor("metrics-dur-med", color.New(color.FgYellow))
console.SetColor("metrics-dur-high", color.New(color.FgRed))
console.SetColor("metrics-error", color.New(color.FgYellow))
console.SetColor("metrics-title", color.New(color.FgCyan))
console.SetColor("metrics-top-title", color.New(color.FgHiCyan))
console.SetColor("metrics-number", color.New(color.FgWhite))
console.SetColor("metrics-number-secondary", color.New(color.FgBlue))
console.SetColor("metrics-zero", color.New(color.FgWhite))
stats := &statTrace{Calls: make(map[string]statItem, 20), Started: time.Now()}
go func() {
for t := range traces {
stats.add(t)
}
}()
return &traceStatsUI{
started: time.Now(),
spinner: s,
maxEntries: maxEntries,
current: stats,
}
}
type traceStatsUI struct {
current *statTrace
started time.Time
spinner spinner.Model
quitting bool
maxEntries int
}
func (m *traceStatsUI) Init() tea.Cmd {
return m.spinner.Tick
}
func (m *traceStatsUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.quitting {
return m, tea.Quit
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
case spinner.TickMsg:
var cmd tea.Cmd
m.spinner, cmd = m.spinner.Update(msg)
return m, cmd
}
return m, nil
}
func (m *traceStatsUI) View() string {
var s strings.Builder
s.WriteString(fmt.Sprintf("%s %s\n",
console.Colorize("metrics-top-title", "Duration: "+time.Since(m.current.Started).Round(time.Second).String()), m.spinner.View()))
// Set table header - akin to k8s style
// https://github.com/olekukonko/tablewriter#example-10---set-nowhitespace-and-tablepadding-option
table := tablewriter.NewWriter(&s)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ") // pad with tabs
table.SetNoWhiteSpace(true)
var entries []statItem
m.current.mu.Lock()
totalCnt := 0
dur := time.Since(m.current.Started)
for _, v := range m.current.Calls {
totalCnt += v.Count
entries = append(entries, v)
}
m.current.mu.Unlock()
if len(entries) == 0 {
s.WriteString("(waiting for data)")
return s.String()
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Count == entries[j].Count {
return entries[i].Name < entries[j].Name
}
return entries[i].Count > entries[j].Count
})
if m.maxEntries > 0 && len(entries) > m.maxEntries {
entries = entries[:m.maxEntries]
}
hasTTFB := false
for _, e := range entries {
if e.TTFB > 0 {
hasTTFB = true
break
}
}
t := []string{
console.Colorize("metrics-top-title", "Call"),
console.Colorize("metrics-top-title", "Count"),
console.Colorize("metrics-top-title", "RPM"),
console.Colorize("metrics-top-title", "Avg Time"),
console.Colorize("metrics-top-title", "Min Time"),
console.Colorize("metrics-top-title", "Max Time"),
}
if hasTTFB {
t = append(t,
console.Colorize("metrics-top-title", "Avg TTFB"),
console.Colorize("metrics-top-title", "Max TTFB"),
)
}
t = append(t,
console.Colorize("metrics-top-title", "Avg Size"),
console.Colorize("metrics-top-title", "Rate"),
console.Colorize("metrics-top-title", "Errors"),
)
table.Append(t)
for _, v := range entries {
if v.Count <= 0 {
continue
}
errs := "0"
if v.Errors > 0 {
errs = console.Colorize("metrics-error", strconv.Itoa(v.Errors))
}
avg := v.Duration / time.Duration(v.Count)
avgTTFB := v.TTFB / time.Duration(v.Count)
avgColor := "metrics-dur"
if avg > 10*time.Second {
avgColor = "metrics-dur-high"
} else if avg > time.Second {
avgColor = "metrics-dur-med"
}
minColor := "metrics-dur"
if v.MinDur > 10*time.Second {
minColor = "metrics-dur-high"
} else if v.MinDur > time.Second {
minColor = "metrics-dur-med"
}
maxColor := "metrics-dur"
if v.MaxDur > 10*time.Second {
maxColor = "metrics-dur-high"
} else if v.MaxDur > time.Second {
maxColor = "metrics-dur-med"
}
sz := "-"
rate := "-"
if v.Size > 0 && v.Count > 0 {
sz = humanize.IBytes(uint64(v.Size) / uint64(v.Count))
rate = fmt.Sprintf("%s/m", humanize.IBytes(uint64(float64(v.Size)/dur.Minutes())))
}
if v.CallStatsCount > 0 {
var s, r []string
if v.CallStats.Rx > 0 {
s = append(s, fmt.Sprintf("↑ %s", humanize.IBytes(uint64(v.CallStats.Rx/v.CallStatsCount))))
r = append(r, fmt.Sprintf("↑ %s", humanize.IBytes(uint64(float64(v.CallStats.Rx)/dur.Minutes()))))
}
if v.CallStats.Tx > 0 {
s = append(s, fmt.Sprintf("↓ %s", humanize.IBytes(uint64(v.CallStats.Tx/v.CallStatsCount))))
r = append(r, fmt.Sprintf("↓ %s", humanize.IBytes(uint64(float64(v.CallStats.Tx)/dur.Minutes()))))
}
if len(s) > 0 {
sz = strings.Join(s, " ")
rate = strings.Join(r, " ") + "/m"
}
}
if sz != "-" {
sz = console.Colorize("metrics-size", sz)
rate = console.Colorize("metrics-size", rate)
}
t := []string{
console.Colorize("metrics-title", metricsTitle(v.Name)),
console.Colorize("metrics-number", fmt.Sprintf("%d ", v.Count)) +
console.Colorize("metrics-number-secondary", fmt.Sprintf("(%0.1f%%)", float64(v.Count)/float64(totalCnt)*100)),
console.Colorize("metrics-number", fmt.Sprintf("%0.1f", float64(v.Count)/dur.Minutes())),
console.Colorize(avgColor, fmt.Sprintf("%v", avg.Round(time.Microsecond))),
console.Colorize(minColor, v.MinDur),
console.Colorize(maxColor, v.MaxDur),
}
if hasTTFB {
t = append(t,
console.Colorize(avgColor, fmt.Sprintf("%v", avgTTFB.Round(time.Microsecond))),
console.Colorize(maxColor, v.MaxTTFB))
}
t = append(t, sz,
rate,
errs)
table.Append(t)
}
table.Render()
if globalTermWidth <= 10 {
return s.String()
}
w := globalTermWidth
if nw, _, e := term.GetSize(int(os.Stdout.Fd())); e == nil {
w = nw
}
split := strings.Split(s.String(), "\n")
for i, line := range split {
split[i] = truncate.StringWithTail(line, uint(w), "»")
}
return strings.Join(split, "\n")
}

View File

@ -103,27 +103,27 @@ func mainSupportTopAPI(ctx *cli.Context) error {
// Start listening on all trace activity. // Start listening on all trace activity.
traceCh := client.ServiceTrace(ctxt, opts) traceCh := client.ServiceTrace(ctxt, opts)
p := tea.NewProgram(initTraceUI()) filteredTraces := make(chan madmin.ServiceTraceInfo, 1)
ui := tea.NewProgram(initTraceStatsUI(30, filteredTraces))
var te error
go func() { go func() {
for apiCallInfo := range traceCh { for t := range traceCh {
if apiCallInfo.Err != nil { if t.Err != nil {
fatalIf(probe.NewError(apiCallInfo.Err), "Unable to fetch top API events") te = t.Err
ui.Kill()
return
} }
if mopts.matches(apiCallInfo) { if mopts.matches(t) {
p.Send(topAPIResult{ filteredTraces <- t
apiCallInfo: apiCallInfo,
})
} }
p.Send(topAPIResult{
apiCallInfo: madmin.ServiceTraceInfo{},
})
} }
}() }()
if _, e := ui.Run(); e != nil {
if _, e := p.Run(); e != nil {
cancel() cancel()
fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch top API events") if te != nil {
e = te
}
fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch http trace statistics")
} }
return nil return nil
} }

263
cmd/trace-stats-ui.go Normal file
View File

@ -0,0 +1,263 @@
package cmd
import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
humanize "github.com/dustin/go-humanize"
"github.com/fatih/color"
"github.com/minio/madmin-go/v3"
"github.com/minio/pkg/v3/console"
"github.com/muesli/reflow/truncate"
"github.com/olekukonko/tablewriter"
"golang.org/x/term"
)
type traceStatsUI struct {
current *statTrace
started time.Time
meter spinner.Model
quitting bool
maxEntries int
}
func (m *traceStatsUI) Init() tea.Cmd {
return m.meter.Tick
}
func (m *traceStatsUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.quitting {
return m, tea.Quit
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "esc", "ctrl+c":
m.quitting = true
return m, tea.Quit
default:
return m, nil
}
case spinner.TickMsg:
var cmd tea.Cmd
m.meter, cmd = m.meter.Update(msg)
return m, cmd
}
return m, nil
}
func (m *traceStatsUI) View() string {
var s strings.Builder
s.WriteString(fmt.Sprintf("%s %s\n",
console.Colorize("metrics-top-title", "Duration: "+time.Since(m.current.Started).Round(time.Second).String()), m.meter.View()))
// Set table header - akin to k8s style
// https://github.com/olekukonko/tablewriter#example-10---set-nowhitespace-and-tablepadding-option
table := tablewriter.NewWriter(&s)
table.SetAutoWrapText(false)
table.SetAutoFormatHeaders(true)
table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
table.SetAlignment(tablewriter.ALIGN_LEFT)
table.SetCenterSeparator("")
table.SetColumnSeparator("")
table.SetRowSeparator("")
table.SetHeaderLine(false)
table.SetBorder(false)
table.SetTablePadding(" ") // pad with tabs
table.SetNoWhiteSpace(true)
var entries []statItem
m.current.mu.Lock()
var (
totalCnt = 0
totalRX = 0
totalTX = 0
)
dur := time.Since(m.current.Started)
for _, v := range m.current.Calls {
totalCnt += v.Count
totalRX += v.CallStats.Rx
totalTX += v.CallStats.Tx
entries = append(entries, v)
}
m.current.mu.Unlock()
if len(entries) == 0 {
s.WriteString("(waiting for data)")
return s.String()
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Count == entries[j].Count {
return entries[i].Name < entries[j].Name
}
return entries[i].Count > entries[j].Count
})
if m.maxEntries > 0 && len(entries) > m.maxEntries {
entries = entries[:m.maxEntries]
}
hasTTFB := false
for _, e := range entries {
if e.TTFB > 0 {
hasTTFB = true
break
}
}
if totalRX > 0 {
s.WriteString(console.Colorize("metrics-top-title", fmt.Sprintf("RX Rate:↑ %s/m\n",
humanize.IBytes(uint64(float64(totalRX)/dur.Minutes())))))
}
if totalTX > 0 {
s.WriteString(console.Colorize("metrics-top-title", fmt.Sprintf("TX Rate:↓ %s/m\n",
humanize.IBytes(uint64(float64(totalTX)/dur.Minutes())))))
}
s.WriteString(console.Colorize("metrics-top-title", fmt.Sprintf("RPM : %0.1f\n", float64(totalCnt)/dur.Minutes())))
s.WriteString("-------------\n")
t := []string{
console.Colorize("metrics-top-title", "Call"),
console.Colorize("metrics-top-title", "Count"),
console.Colorize("metrics-top-title", "RPM"),
console.Colorize("metrics-top-title", "Avg Time"),
console.Colorize("metrics-top-title", "Min Time"),
console.Colorize("metrics-top-title", "Max Time"),
}
if hasTTFB {
t = append(t,
console.Colorize("metrics-top-title", "Avg TTFB"),
console.Colorize("metrics-top-title", "Max TTFB"),
)
}
t = append(t,
console.Colorize("metrics-top-title", "Avg Size"),
console.Colorize("metrics-top-title", "Rate"),
console.Colorize("metrics-top-title", "Errors"),
)
table.Append(t)
for _, v := range entries {
if v.Count <= 0 {
continue
}
errs := "0"
if v.Errors > 0 {
errs = console.Colorize("metrics-error", strconv.Itoa(v.Errors))
}
avg := v.Duration / time.Duration(v.Count)
avgTTFB := v.TTFB / time.Duration(v.Count)
avgColor := "metrics-dur"
if avg > 10*time.Second {
avgColor = "metrics-dur-high"
} else if avg > 2*time.Second {
avgColor = "metrics-dur-med"
}
minColor := "metrics-dur"
if v.MinDur > 10*time.Second {
minColor = "metrics-dur-high"
} else if v.MinDur > 2*time.Second {
minColor = "metrics-dur-med"
}
maxColor := "metrics-dur"
if v.MaxDur > 10*time.Second {
maxColor = "metrics-dur-high"
} else if v.MaxDur > time.Second {
maxColor = "metrics-dur-med"
}
sz := "-"
rate := "-"
if v.Size > 0 && v.Count > 0 {
sz = humanize.IBytes(uint64(v.Size) / uint64(v.Count))
rate = fmt.Sprintf("%s/m", humanize.IBytes(uint64(float64(v.Size)/dur.Minutes())))
}
if v.CallStatsCount > 0 {
var s, r []string
if v.CallStats.Rx > 0 {
s = append(s, fmt.Sprintf("↑ %s", humanize.IBytes(uint64(v.CallStats.Rx/v.CallStatsCount))))
r = append(r, fmt.Sprintf("↑ %s", humanize.IBytes(uint64(float64(v.CallStats.Rx)/dur.Minutes()))))
}
if v.CallStats.Tx > 0 {
s = append(s, fmt.Sprintf("↓ %s", humanize.IBytes(uint64(v.CallStats.Tx/v.CallStatsCount))))
r = append(r, fmt.Sprintf("↓ %s", humanize.IBytes(uint64(float64(v.CallStats.Tx)/dur.Minutes()))))
}
if len(s) > 0 {
sz = strings.Join(s, " ")
}
if len(r) > 0 {
rate = strings.Join(r, " ") + "/m"
}
}
if sz != "-" {
sz = console.Colorize("metrics-size", sz)
rate = console.Colorize("metrics-size", rate)
}
t := []string{
console.Colorize("metrics-title", metricsTitle(v.Name)),
console.Colorize("metrics-number", fmt.Sprintf("%d ", v.Count)) +
console.Colorize("metrics-number-secondary", fmt.Sprintf("(%0.1f%%)", float64(v.Count)/float64(totalCnt)*100)),
console.Colorize("metrics-number", fmt.Sprintf("%0.1f", float64(v.Count)/dur.Minutes())),
console.Colorize(avgColor, fmt.Sprintf("%v", avg.Round(time.Microsecond))),
console.Colorize(minColor, v.MinDur),
console.Colorize(maxColor, v.MaxDur),
}
if hasTTFB {
t = append(t,
console.Colorize(avgColor, fmt.Sprintf("%v", avgTTFB.Round(time.Microsecond))),
console.Colorize(maxColor, v.MaxTTFB))
}
t = append(t, sz, rate, errs)
table.Append(t)
}
table.Render()
if globalTermWidth <= 10 {
return s.String()
}
w := globalTermWidth
if nw, _, e := term.GetSize(int(os.Stdout.Fd())); e == nil {
w = nw
}
split := strings.Split(s.String(), "\n")
for i, line := range split {
split[i] = truncate.StringWithTail(line, uint(w), "»")
}
return strings.Join(split, "\n")
}
func initTraceStatsUI(maxEntries int, traces <-chan madmin.ServiceTraceInfo) *traceStatsUI {
meter := spinner.New()
meter.Spinner = spinner.Meter
meter.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
console.SetColor("metrics-duration", color.New(color.FgWhite))
console.SetColor("metrics-size", color.New(color.FgGreen))
console.SetColor("metrics-dur", color.New(color.FgGreen))
console.SetColor("metrics-dur-med", color.New(color.FgYellow))
console.SetColor("metrics-dur-high", color.New(color.FgRed))
console.SetColor("metrics-error", color.New(color.FgYellow))
console.SetColor("metrics-title", color.New(color.FgCyan))
console.SetColor("metrics-top-title", color.New(color.FgHiCyan))
console.SetColor("metrics-number", color.New(color.FgWhite))
console.SetColor("metrics-number-secondary", color.New(color.FgBlue))
console.SetColor("metrics-zero", color.New(color.FgWhite))
stats := &statTrace{Calls: make(map[string]statItem, 20), Started: time.Now()}
go func() {
for t := range traces {
stats.add(t)
}
}()
return &traceStatsUI{
started: time.Now(),
meter: meter,
maxEntries: maxEntries,
current: stats,
}
}