blob: b7f868230b8a1ca79679b42c2b96f97cd775ab9e [file] [log] [blame]
// 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
}