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 }