github.com/minio/mc@v0.0.0-20240503112107-b471de8d1882/cmd/admin-scanner-status.go (about)

     1  // Copyright (c) 2015-2022 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package cmd
    19  
    20  import (
    21  	"bufio"
    22  	"bytes"
    23  	"context"
    24  	"errors"
    25  	"fmt"
    26  	"io"
    27  	"os"
    28  	"sort"
    29  	"strings"
    30  	"time"
    31  
    32  	"github.com/charmbracelet/bubbles/spinner"
    33  	tea "github.com/charmbracelet/bubbletea"
    34  	"github.com/charmbracelet/lipgloss"
    35  	"github.com/fatih/color"
    36  	"github.com/minio/cli"
    37  	json "github.com/minio/colorjson"
    38  	"github.com/minio/madmin-go/v3"
    39  	"github.com/minio/mc/pkg/probe"
    40  	"github.com/minio/pkg/v2/console"
    41  	"github.com/olekukonko/tablewriter"
    42  )
    43  
    44  var adminScannerInfoFlags = []cli.Flag{
    45  	cli.StringFlag{
    46  		Name:  "nodes",
    47  		Usage: "show only on matching servers, comma separate multiple",
    48  	},
    49  	cli.IntFlag{
    50  		Name:  "n",
    51  		Usage: "number of requests to run before exiting. 0 for endless",
    52  		Value: 0,
    53  	},
    54  	cli.IntFlag{
    55  		Name:  "interval",
    56  		Usage: "interval between requests in seconds",
    57  		Value: 3,
    58  	},
    59  	cli.IntFlag{
    60  		Name:  "max-paths",
    61  		Usage: "maximum number of active paths to show. -1 for unlimited",
    62  		Value: -1,
    63  	},
    64  	cli.StringFlag{
    65  		Name:   "in",
    66  		Hidden: true,
    67  		Usage:  "read previously saved json from file and replay",
    68  	},
    69  }
    70  
    71  var adminScannerInfo = cli.Command{
    72  	Name:            "status",
    73  	Aliases:         []string{"info"},
    74  	HiddenAliases:   true,
    75  	Usage:           "summarize scanner events on MinIO server in real-time",
    76  	Action:          mainAdminScannerInfo,
    77  	OnUsageError:    onUsageError,
    78  	Before:          setGlobalsFromContext,
    79  	Flags:           append(adminScannerInfoFlags, globalFlags...),
    80  	HideHelpCommand: true,
    81  	CustomHelpTemplate: `NAME:
    82    {{.HelpName}} - {{.Usage}}
    83  
    84  USAGE:
    85    {{.HelpName}} [FLAGS] TARGET
    86  
    87  FLAGS:
    88    {{range .VisibleFlags}}{{.}}
    89    {{end}}
    90  EXAMPLES:
    91     1. Display current in-progress all scanner operations.
    92        {{.Prompt}} {{.HelpName}} myminio/
    93  `,
    94  }
    95  
    96  // checkAdminTopAPISyntax - validate all the passed arguments
    97  func checkAdminScannerInfoSyntax(ctx *cli.Context) {
    98  	if ctx.String("in") != "" {
    99  		return
   100  	}
   101  	if len(ctx.Args()) == 0 || len(ctx.Args()) > 1 {
   102  		showCommandHelpAndExit(ctx, 1) // last argument is exit code
   103  	}
   104  }
   105  
   106  func mainAdminScannerInfo(ctx *cli.Context) error {
   107  	checkAdminScannerInfoSyntax(ctx)
   108  
   109  	aliasedURL := ctx.Args().Get(0)
   110  
   111  	ui := tea.NewProgram(initScannerMetricsUI(ctx.Int("max-paths")))
   112  	ctxt, cancel := context.WithCancel(globalContext)
   113  	defer cancel()
   114  
   115  	// Replay from file
   116  	if inFile := ctx.String("in"); inFile != "" {
   117  		go func() {
   118  			if _, e := ui.Run(); e != nil {
   119  				cancel()
   120  				fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch scanner metrics")
   121  			}
   122  		}()
   123  		f, e := os.Open(inFile)
   124  		fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to open input")
   125  		sc := bufio.NewReader(f)
   126  		var lastTime time.Time
   127  		for {
   128  			b, e := sc.ReadBytes('\n')
   129  			if e == io.EOF {
   130  				break
   131  			}
   132  			var metrics madmin.RealtimeMetrics
   133  			e = json.Unmarshal(b, &metrics)
   134  			if e != nil || metrics.Aggregated.Scanner == nil {
   135  				continue
   136  			}
   137  			delay := metrics.Aggregated.Scanner.CollectedAt.Sub(lastTime)
   138  			if !lastTime.IsZero() && delay > 0 {
   139  				if delay > 3*time.Second {
   140  					delay = 3 * time.Second
   141  				}
   142  				time.Sleep(delay)
   143  			}
   144  			ui.Send(metrics)
   145  			lastTime = metrics.Aggregated.Scanner.CollectedAt
   146  		}
   147  		os.Exit(0)
   148  	}
   149  
   150  	// Create a new MinIO Admin Client
   151  	client, err := newAdminClient(aliasedURL)
   152  	fatalIf(err.Trace(aliasedURL), "Unable to initialize admin client.")
   153  
   154  	opts := madmin.MetricsOptions{
   155  		Type:     madmin.MetricsScanner,
   156  		N:        ctx.Int("n"),
   157  		Interval: time.Duration(ctx.Int("interval")) * time.Second,
   158  		Hosts:    strings.Split(ctx.String("nodes"), ","),
   159  		ByHost:   false,
   160  	}
   161  	if globalJSON {
   162  		e := client.Metrics(ctxt, opts, func(metrics madmin.RealtimeMetrics) {
   163  			printMsg(metricsMessage{RealtimeMetrics: metrics})
   164  		})
   165  
   166  		if e != nil && !errors.Is(e, context.Canceled) {
   167  			fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch scanner metrics")
   168  		}
   169  		return nil
   170  	}
   171  
   172  	go func() {
   173  		e := client.Metrics(ctxt, opts, func(metrics madmin.RealtimeMetrics) {
   174  			ui.Send(metrics)
   175  		})
   176  
   177  		if e != nil && !errors.Is(e, context.Canceled) {
   178  			fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch scanner metrics")
   179  		}
   180  	}()
   181  
   182  	if _, e := ui.Run(); e != nil {
   183  		cancel()
   184  		fatalIf(probe.NewError(e).Trace(aliasedURL), "Unable to fetch scanner metrics")
   185  	}
   186  
   187  	return nil
   188  }
   189  
   190  type metricsMessage struct {
   191  	madmin.RealtimeMetrics
   192  }
   193  
   194  func (s metricsMessage) JSON() string {
   195  	buf := &bytes.Buffer{}
   196  	enc := json.NewEncoder(buf)
   197  	enc.SetIndent("", " ")
   198  	enc.SetEscapeHTML(false)
   199  
   200  	fatalIf(probe.NewError(enc.Encode(s)), "Unable to marshal into JSON.")
   201  	return buf.String()
   202  }
   203  
   204  func (s metricsMessage) String() string {
   205  	return s.JSON()
   206  }
   207  
   208  func initScannerMetricsUI(maxPaths int) *scannerMetricsUI {
   209  	s := spinner.New()
   210  	s.Spinner = spinner.Points
   211  	s.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("205"))
   212  	console.SetColor("metrics-duration", color.New(color.FgHiWhite))
   213  	console.SetColor("metrics-path", color.New(color.FgGreen))
   214  	console.SetColor("metrics-error", color.New(color.FgHiRed))
   215  	console.SetColor("metrics-title", color.New(color.FgCyan))
   216  	console.SetColor("metrics-top-title", color.New(color.FgHiCyan))
   217  	console.SetColor("metrics-number", color.New(color.FgHiWhite))
   218  	console.SetColor("metrics-zero", color.New(color.FgHiWhite))
   219  	console.SetColor("metrics-date", color.New(color.FgHiWhite))
   220  	return &scannerMetricsUI{
   221  		spinner:  s,
   222  		maxPaths: maxPaths,
   223  	}
   224  }
   225  
   226  type scannerMetricsUI struct {
   227  	current  madmin.RealtimeMetrics
   228  	spinner  spinner.Model
   229  	quitting bool
   230  	maxPaths int
   231  }
   232  
   233  func (m *scannerMetricsUI) Init() tea.Cmd {
   234  	return m.spinner.Tick
   235  }
   236  
   237  func (m *scannerMetricsUI) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
   238  	if m.quitting {
   239  		return m, tea.Quit
   240  	}
   241  	switch msg := msg.(type) {
   242  	case tea.KeyMsg:
   243  		switch msg.String() {
   244  		case "q", "esc", "ctrl+c":
   245  			m.quitting = true
   246  			return m, tea.Quit
   247  		default:
   248  			return m, nil
   249  		}
   250  	case madmin.RealtimeMetrics:
   251  		m.current = msg
   252  		if msg.Final {
   253  			m.quitting = true
   254  			return m, tea.Quit
   255  		}
   256  		return m, nil
   257  	case spinner.TickMsg:
   258  		var cmd tea.Cmd
   259  		m.spinner, cmd = m.spinner.Update(msg)
   260  		return m, cmd
   261  	}
   262  
   263  	return m, nil
   264  }
   265  
   266  func (m *scannerMetricsUI) View() string {
   267  	var s strings.Builder
   268  
   269  	if !m.quitting {
   270  		s.WriteString(fmt.Sprintf("%s %s\n", console.Colorize("metrics-top-title", "Scanner Activity:"), m.spinner.View()))
   271  	}
   272  
   273  	// Set table header - akin to k8s style
   274  	// https://github.com/olekukonko/tablewriter#example-10---set-nowhitespace-and-tablepadding-option
   275  	table := tablewriter.NewWriter(&s)
   276  	table.SetAutoWrapText(false)
   277  	table.SetAutoFormatHeaders(true)
   278  	table.SetHeaderAlignment(tablewriter.ALIGN_LEFT)
   279  	table.SetAlignment(tablewriter.ALIGN_LEFT)
   280  	table.SetCenterSeparator("")
   281  	table.SetColumnSeparator("")
   282  	table.SetRowSeparator("")
   283  	table.SetHeaderLine(false)
   284  	table.SetBorder(false)
   285  	table.SetTablePadding("\t") // pad with tabs
   286  	table.SetNoWhiteSpace(true)
   287  
   288  	writtenRows := 0
   289  	addRow := func(s string) {
   290  		table.Append([]string{s})
   291  		writtenRows++
   292  	}
   293  	_ = addRow
   294  	addRowF := func(format string, vals ...interface{}) {
   295  		s := fmt.Sprintf(format, vals...)
   296  		table.Append([]string{s})
   297  		writtenRows++
   298  	}
   299  
   300  	sc := m.current.Aggregated.Scanner
   301  	if sc == nil {
   302  		s.WriteString("(waiting for data)")
   303  		return s.String()
   304  	}
   305  
   306  	title := metricsTitle
   307  	ui := metricsUint64
   308  	const wantCycles = 16
   309  	addRow("")
   310  	if len(sc.CyclesCompletedAt) < 2 {
   311  		addRow("Last full scan time:             Unknown (not enough data)")
   312  	} else {
   313  		addRow("Overall Statistics")
   314  		addRow("------------------")
   315  		sort.Slice(sc.CyclesCompletedAt, func(i, j int) bool {
   316  			return sc.CyclesCompletedAt[i].After(sc.CyclesCompletedAt[j])
   317  		})
   318  		if len(sc.CyclesCompletedAt) >= wantCycles {
   319  			sinceLast := sc.CyclesCompletedAt[0].Sub(sc.CyclesCompletedAt[wantCycles-1])
   320  			perMonth := float64(30*24*time.Hour) / float64(sinceLast)
   321  			cycleTime := console.Colorize("metrics-number", fmt.Sprintf("%dd%dh%dm", int(sinceLast.Hours()/24), int(sinceLast.Hours())%24, int(sinceLast.Minutes())%60))
   322  			perms := console.Colorize("metrics-number", fmt.Sprintf("%.02f", perMonth))
   323  			addRowF(title("Last full scan time:")+"   %s; Estimated %s/month", cycleTime, perms)
   324  		} else {
   325  			sinceLast := sc.CyclesCompletedAt[0].Sub(sc.CyclesCompletedAt[1]) * time.Duration(wantCycles)
   326  			perMonth := float64(30*24*time.Hour) / float64(sinceLast)
   327  			cycleTime := console.Colorize("metrics-number", fmt.Sprintf("%dd%dh%dm", int(sinceLast.Hours()/24), int(sinceLast.Hours())%24, int(sinceLast.Minutes())%60))
   328  			perms := console.Colorize("metrics-number", fmt.Sprintf("%.02f", perMonth))
   329  			addRowF(title("Est. full scan time:")+"   %s; Estimated %s/month", cycleTime, perms)
   330  		}
   331  	}
   332  	if sc.CurrentCycle > 0 {
   333  		addRowF(title("Current cycle:")+"         %s; Started: %v", ui(sc.CurrentCycle), console.Colorize("metrics-date", sc.CurrentStarted))
   334  		addRowF(title("Active drives:")+"         %s", ui(uint64(len(sc.ActivePaths))))
   335  	} else {
   336  		addRowF(title("Current cycle:") + "         (between cycles)")
   337  		addRowF(title("Active drives:")+"         %s", ui(uint64(len(sc.ActivePaths))))
   338  	}
   339  	getRate := func(x madmin.TimedAction) string {
   340  		if x.AccTime > 0 {
   341  			return fmt.Sprintf("; Rate: %v/day", ui(uint64(float64(24*time.Hour)/(float64(time.Minute)/float64(x.Count)))))
   342  		}
   343  		return ""
   344  	}
   345  	addRow("")
   346  	addRow("Last Minute Statistics")
   347  	addRow("----------------------")
   348  	objs := uint64(0)
   349  	x := sc.LastMinute.Actions["ScanObject"]
   350  	{
   351  		avg := x.Avg()
   352  		addRowF(title("Objects Scanned:")+"       %s objects; Avg: %v%s", ui(x.Count), metricsDuration(avg), getRate(x))
   353  		objs = x.Count
   354  	}
   355  	x = sc.LastMinute.Actions["ApplyVersion"]
   356  	{
   357  		avg := x.Avg()
   358  		addRowF(title("Versions Scanned:")+"      %s versions; Avg: %v%s", ui(x.Count), metricsDuration(avg), getRate(x))
   359  	}
   360  	x = sc.LastMinute.Actions["HealCheck"]
   361  	{
   362  		avg := x.Avg()
   363  		rate := ""
   364  		if x.AccTime > 0 {
   365  			rate = fmt.Sprintf("; Rate: %s/day", ui(uint64(float64(24*time.Hour)/(float64(time.Minute)/float64(x.Count)))))
   366  		}
   367  		addRowF(title("Versions Heal Checked:")+" %s versions; Avg: %v%s", ui(x.Count), metricsDuration(avg), rate)
   368  	}
   369  	x = sc.LastMinute.Actions["ReadMetadata"]
   370  	addRowF(title("Read Metadata:")+"         %s objects; Avg: %v, Size: %v bytes/obj", ui(x.Count), metricsDuration(x.Avg()), ui(x.AvgBytes()))
   371  	x = sc.LastMinute.Actions["ILM"]
   372  	addRowF(title("ILM checks:")+"            %s versions; Avg: %v", ui(x.Count), metricsDuration(x.Avg()))
   373  	x = sc.LastMinute.Actions["CheckReplication"]
   374  	addRowF(title("Check Replication:")+"     %s versions; Avg: %v", ui(x.Count), metricsDuration(x.Avg()))
   375  	x = sc.LastMinute.Actions["TierObjSweep"]
   376  	if x.Count > 0 {
   377  		addRowF(title("Sweep Tiered:")+"        %s versions; Avg: %v", ui(x.Count), metricsDuration(x.Avg()))
   378  	}
   379  	x = sc.LastMinute.Actions["CheckMissing"]
   380  	addRowF(title("Verify Deleted:")+"        %s folders; Avg: %v", ui(x.Count), metricsDuration(x.Avg()))
   381  	x = sc.LastMinute.Actions["HealAbandonedObject"]
   382  	if x.Count > 0 {
   383  		addRowF(title(" Missing Objects:")+"      %s objects healed; Avg: %v%s", ui(x.Count), metricsDuration(x.Avg()), getRate(x))
   384  	}
   385  	x = sc.LastMinute.Actions["HealAbandonedVersion"]
   386  	if x.Count > 0 {
   387  		addRowF(title(" Missing Versions:")+"     %s versions healed; Avg: %v%s; %v bytes/v", ui(x.Count), metricsDuration(x.Avg()), getRate(x), ui(x.AvgBytes()))
   388  	}
   389  
   390  	for k, x := range sc.LastMinute.ILM {
   391  		const length = 17
   392  		k += ":"
   393  		if len(k) < length {
   394  			k += strings.Repeat(" ", length-len(k))
   395  		}
   396  		addRowF(title("ILM, %s")+" %s actions; Avg: %v.", k, ui(x.Count), metricsDuration(x.Avg()))
   397  	}
   398  	x = sc.LastMinute.Actions["Yield"]
   399  	{
   400  		avg := fmt.Sprintf("%v", metricsDuration(x.Avg()))
   401  		if objs > 0 {
   402  			avg = console.Colorize("metrics-duration", fmt.Sprintf("%v/obj", metricsDuration(time.Duration(x.AccTime/objs))))
   403  		}
   404  		addRowF(title("Yield:")+"                 %v total; Avg: %s", metricsDuration(time.Duration(x.AccTime)), avg)
   405  	}
   406  	if errs := m.current.Errors; len(errs) > 0 {
   407  		addRow("------------------------------------------- Errors --------------------------------------------------")
   408  		for _, s := range errs {
   409  			addRow(console.Colorize("metrics-error", s))
   410  		}
   411  	}
   412  
   413  	if m.maxPaths != 0 && len(sc.ActivePaths) > 0 {
   414  		addRow("------------------------------------- Currently Scanning Paths --------------------------------------")
   415  		length := 100
   416  		if globalTermWidth > 5 {
   417  			length = globalTermWidth
   418  		}
   419  		for i, s := range sc.ActivePaths {
   420  			if i == m.maxPaths {
   421  				break
   422  			}
   423  			if globalTermHeight > 5 && writtenRows >= globalTermHeight-5 {
   424  				addRow(console.Colorize("metrics-path", fmt.Sprintf("( ... hiding %d more disk(s) .. )", len(sc.ActivePaths)-i)))
   425  				break
   426  			}
   427  			if len(s) > length {
   428  				s = s[:length-3] + "..."
   429  			}
   430  			s = strings.ReplaceAll(s, "\\", "/")
   431  			addRow(console.Colorize("metrics-path", s))
   432  		}
   433  	}
   434  	table.Render()
   435  	return s.String()
   436  }
   437  
   438  func metricsDuration(d time.Duration) string {
   439  	if d == 0 {
   440  		return console.Colorize("metrics-zero", "0ms")
   441  	}
   442  	if d > time.Millisecond {
   443  		d = d.Round(time.Microsecond)
   444  	}
   445  	if d > time.Second {
   446  		d = d.Round(time.Millisecond)
   447  	}
   448  	if d > time.Minute {
   449  		d = d.Round(time.Second / 10)
   450  	}
   451  	return console.Colorize("metrics-duration", d)
   452  }
   453  
   454  func metricsUint64(v uint64) string {
   455  	if v == 0 {
   456  		return console.Colorize("metrics-zero", v)
   457  	}
   458  	return console.Colorize("metrics-number", v)
   459  }
   460  
   461  func metricsTitle(s string) string {
   462  	return console.Colorize("metrics-title", s)
   463  }