pkg/display/graph/dashboard/global.go (294 lines of code) (raw):

// Licensed to Apache Software Foundation (ASF) under one or more contributor // license agreements. See the NOTICE file distributed with // this work for additional information regarding copyright // ownership. Apache Software Foundation (ASF) licenses this file to you under // the Apache License, Version 2.0 (the "License"); you may // not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, // software distributed under the License is distributed on an // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. package dashboard import ( "context" "fmt" "math" "strings" "time" api "skywalking.apache.org/repo/goapi/query" "github.com/apache/skywalking-cli/internal/commands/interceptor" "github.com/apache/skywalking-cli/pkg/graphql/utils" lib "github.com/apache/skywalking-cli/pkg/heatmap" "github.com/mattn/go-runewidth" "github.com/mum4k/termdash" "github.com/mum4k/termdash/container/grid" "github.com/mum4k/termdash/linestyle" "github.com/mum4k/termdash/terminal/termbox" "github.com/mum4k/termdash/terminal/terminalapi" "github.com/urfave/cli/v2" "github.com/apache/skywalking-cli/internal/model" "github.com/apache/skywalking-cli/pkg/display/graph/gauge" "github.com/apache/skywalking-cli/pkg/display/graph/heatmap" "github.com/apache/skywalking-cli/pkg/display/graph/linear" "github.com/apache/skywalking-cli/pkg/graphql/dashboard" "github.com/mum4k/termdash/cell" "github.com/mum4k/termdash/container" "github.com/mum4k/termdash/widgets/button" "github.com/mum4k/termdash/widgets/linechart" ) // rootID is the ID assigned to the root container. const rootID = "root" type layoutType int const ( // layoutMetrics displays all the widgets. layoutMetrics layoutType = iota // layoutLineChart focuses onto the line chart. layoutLineChart // layoutHeatMap focuses onto the heat map. layoutHeatMap ) // strToLayoutType ensures the order of buttons is fixed. var strToLayoutType = map[string]layoutType{ "Metrics": layoutMetrics, "ResponseLatency": layoutLineChart, "HeatMap": layoutHeatMap, } // widgets holds the widgets used by the dashboard. type widgets struct { gauges []*gauge.MetricColumn linears map[string]*linechart.LineChart heatmap *lib.HeatMap // buttons are used to change the layout. buttons []*button.Button } // template determines how the global dashboard is displayed. var template *dashboard.GlobalTemplate var allWidgets *widgets var initStartStr string var initStep = api.StepMinute var initEndStr string var curStartTime time.Time var curEndTime time.Time // setLayout sets the specified layout. func setLayout(c *container.Container, lt layoutType) error { gridOpts, err := gridLayout(lt) if err != nil { return err } return c.Update(rootID, gridOpts...) } // newLayoutButtons returns buttons that dynamically switch the layouts. func newLayoutButtons(c *container.Container) ([]*button.Button, error) { buttons := make([]*button.Button, len(strToLayoutType)) ls := longestString(template.Buttons.Texts) if ls == "" { return nil, fmt.Errorf("failed to parse texts of buttons") } opts := []button.Option{ button.WidthFor(ls), button.FillColor(cell.ColorNumber(template.Buttons.ColorNum)), button.Height(template.Buttons.Height), } for _, text := range template.Buttons.Texts { // declare a local variable lt to avoid closure. lt, ok := strToLayoutType[text] if !ok { return nil, fmt.Errorf("the '%s' is not supposed to be the button's text", text) } b, err := button.New(text, func() error { return setLayout(c, lt) }, opts...) if err != nil { return nil, err } buttons[lt] = b } return buttons, nil } // gridLayout prepares container options that represent the desired screen layout. func gridLayout(lt layoutType) ([]container.Option, error) { const buttonRowHeight = 15 buttonColWidthPerc := 99 / len(allWidgets.buttons) var buttonCols []grid.Element for _, b := range allWidgets.buttons { if b != nil { buttonCols = append(buttonCols, grid.ColWidthPerc(buttonColWidthPerc, grid.Widget(b))) } } rows := []grid.Element{ grid.RowHeightPerc(buttonRowHeight, buttonCols...), } switch lt { case layoutMetrics: rows = append(rows, grid.RowHeightPerc(70, gauge.MetricColumnsElement(allWidgets.gauges)...), ) case layoutLineChart: lcElements := linear.LineChartElements(allWidgets.linears) percentage := int(math.Min(99, float64((100-buttonRowHeight)/len(lcElements)))) for _, e := range lcElements { rows = append(rows, grid.RowHeightPerc(percentage, e...), ) } case layoutHeatMap: const heatmapColWidth = 85 rows = append(rows, grid.RowHeightPerc( 99-buttonRowHeight, grid.ColWidthPerc((99-heatmapColWidth)/2), // Use two empty cols to center the heatmap. grid.ColWidthPerc(heatmapColWidth, grid.Widget(allWidgets.heatmap)), grid.ColWidthPerc((99-heatmapColWidth)/2), ), ) } builder := grid.New() builder.Add( grid.RowHeightPerc(99, rows...), ) gridOpts, err := builder.Build() if err != nil { return nil, err } return gridOpts, nil } // newWidgets creates all widgets used by the dashboard. func newWidgets(data *dashboard.GlobalData) error { var columns []*gauge.MetricColumn linears := make(map[string]*linechart.LineChart) // Create gauges to display global metrics. for i := range template.Metrics { col, err := gauge.NewMetricColumn(data.Metrics[i], &template.Metrics[i]) if err != nil { return err } columns = append(columns, col) } // Create line charts to display global response latency. for label, input := range data.ResponseLatency { l, err := linear.NewLineChart(input) if err != nil { return err } linears[label] = l } // Create a heat map. hp, err := heatmap.NewHeatMapWidget(data.HeatMap) if err != nil { return err } allWidgets.gauges = columns allWidgets.linears = linears allWidgets.heatmap = hp return nil } func Display(ctx *cli.Context, data *dashboard.GlobalData) error { t, err := termbox.New(termbox.ColorMode(terminalapi.ColorMode256)) if err != nil { return err } defer t.Close() c, err := container.New( t, container.Border(linestyle.Light), container.BorderTitle("[Global Dashboard]-PRESS Q TO QUIT"), container.ID(rootID)) if err != nil { return err } te, err := dashboard.LoadTemplate(ctx.String("template")) if err != nil { return err } template = te // Initialization allWidgets = &widgets{ gauges: nil, linears: nil, heatmap: nil, buttons: nil, } err = newWidgets(data) if err != nil { return err } lb, err := newLayoutButtons(c) if err != nil { return err } allWidgets.buttons = lb gridOpts, err := gridLayout(layoutMetrics) if err != nil { return err } if e := c.Update(rootID, gridOpts...); e != nil { return e } con, cancel := context.WithCancel(context.Background()) quitter := func(keyboard *terminalapi.Keyboard) { if strings.EqualFold(keyboard.Key.String(), "q") { cancel() } } refreshInterval := time.Duration(ctx.Int("refresh")) * time.Second dt := utils.DurationType(ctx.String("duration-type")) // Only when users use the relative time, the duration will be adjusted to refresh. if dt != utils.BothPresent { go refresh(con, ctx, refreshInterval) } err = termdash.Run(con, t, c, termdash.KeyboardSubscriber(quitter), termdash.RedrawInterval(refreshInterval)) return err } // longestString returns the longest string in the string array. func longestString(strs []string) (ret string) { maxLen := 0 for _, s := range strs { if l := runewidth.StringWidth(s); l > maxLen { ret = s maxLen = l } } return } // refresh updates the duration and query the new data to update all of widgets, once every delay. func refresh(con context.Context, ctx *cli.Context, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() initStartStr = ctx.String("start") initEndStr = ctx.String("end") if s := ctx.Generic("step"); s != nil { initStep = s.(*model.StepEnumValue).Selected } _, start, err := interceptor.TryParseTime(initStartStr, initStep) if err != nil { return } _, end, err := interceptor.TryParseTime(initEndStr, initStep) if err != nil { return } curStartTime = start curEndTime = end for { select { case <-ticker.C: d, err := updateDuration(interval) if err != nil { continue } data, err := dashboard.Global(ctx, d) if err != nil { continue } if err := updateAllWidgets(data); err != nil { continue } case <-con.Done(): return } } } // updateDuration will check if the duration changes after adding the interval. // If the duration doesn't change, an error will be returned, and the dashboard will not refresh. // Otherwise, a new duration will be returned, which is used to get the latest global data. func updateDuration(interval time.Duration) (api.Duration, error) { step, _, err := interceptor.TryParseTime(initStartStr, initStep) if err != nil { return api.Duration{}, err } curStartTime = curStartTime.Add(interval) curEndTime = curEndTime.Add(interval) curStartStr := curStartTime.Format(utils.StepFormats[step]) curEndStr := curEndTime.Format(utils.StepFormats[step]) if curStartStr == initStartStr && curEndStr == initEndStr { return api.Duration{}, fmt.Errorf("the duration does not update") } initStartStr = curStartStr initEndStr = curEndStr return api.Duration{ Start: curStartStr, End: curEndStr, Step: step, }, nil } // updateAllWidgets will update all of widgets' data to be displayed. func updateAllWidgets(data *dashboard.GlobalData) error { // Update gauges for i, mcData := range data.Metrics { if err := allWidgets.gauges[i].Update(mcData); err != nil { return err } } // Update line charts. for i, inputs := range data.ResponseLatency { if err := linear.SetLineChartSeries(allWidgets.linears[i], inputs); err != nil { return err } } // Update the heat map. heatmap.SetData(allWidgets.heatmap, data.HeatMap) return nil }