You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by ke...@apache.org on 2020/09/15 13:17:19 UTC

[skywalking-cli] branch master updated: Implement auto-refresh for `dashboard` (#63)

This is an automated email from the ASF dual-hosted git repository.

kezhenxu94 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/skywalking-cli.git


The following commit(s) were added to refs/heads/master by this push:
     new 98f1a92  Implement auto-refresh for `dashboard` (#63)
98f1a92 is described below

commit 98f1a9270985769b0e5d224c2d7044f3ab707cfe
Author: Hoshea Jiang <fg...@gmail.com>
AuthorDate: Tue Sep 15 21:14:06 2020 +0800

    Implement auto-refresh for `dashboard` (#63)
---
 README.md                             |   1 +
 commands/dashboard/global/global.go   |   6 ++
 commands/flags/duration.go            |   4 +
 commands/interceptor/duration.go      |  24 +++--
 commands/interceptor/duration_test.go |   2 +-
 display/graph/dashboard/global.go     | 190 +++++++++++++++++++++++++++-------
 display/graph/gauge/gauge.go          |  67 +++++++++---
 display/graph/heatmap/heatmap.go      |  13 ++-
 display/graph/linear/linear.go        |  31 ++++--
 graphql/utils/constants.go            |  13 +++
 lib/heatmap/heatmap.go                |  12 +++
 11 files changed, 286 insertions(+), 77 deletions(-)

diff --git a/README.md b/README.md
index 4c634c8..c1d0dee 100644
--- a/README.md
+++ b/README.md
@@ -311,6 +311,7 @@ You can imitate the content of [the default template file](example/Dashboard.Glo
 | argument | description | default |
 | :--- | :--- | :--- |
 | `--template` | The template file to customize how to display information | `templates/Dashboard.Global.json` |
+| `--refresh` | The interval of auto-refresh (s). When `start` and `end` are both present, auto-refresh is disabled. | `6` |
 | `--start` | See [Common options](#common-options) | See [Common options](#common-options) |
 | `--end` | See [Common options](#common-options) | See [Common options](#common-options) |
 
diff --git a/commands/dashboard/global/global.go b/commands/dashboard/global/global.go
index baa0226..577abde 100644
--- a/commands/dashboard/global/global.go
+++ b/commands/dashboard/global/global.go
@@ -45,6 +45,12 @@ var GlobalCommand = cli.Command{
 				Required: false,
 				Value:    dashboard.DefaultTemplatePath,
 			},
+			cli.IntFlag{
+				Name:     "refresh",
+				Usage:    "the auto refreshing interval (s)",
+				Required: false,
+				Value:    6,
+			},
 		},
 	),
 	Before: interceptor.BeforeChain([]cli.BeforeFunc{
diff --git a/commands/flags/duration.go b/commands/flags/duration.go
index f026aea..152ba0e 100644
--- a/commands/flags/duration.go
+++ b/commands/flags/duration.go
@@ -45,4 +45,8 @@ var DurationFlags = []cli.Flag{
 			Selected: schema.StepMinute,
 		},
 	},
+	cli.StringFlag{
+		Name:  "durationType",
+		Usage: "the type of duration",
+	},
 }
diff --git a/commands/interceptor/duration.go b/commands/interceptor/duration.go
index c52ae69..6e382e0 100644
--- a/commands/interceptor/duration.go
+++ b/commands/interceptor/duration.go
@@ -29,7 +29,7 @@ import (
 	"github.com/apache/skywalking-cli/logger"
 )
 
-func tryParseTime(unparsed string) (schema.Step, time.Time, error) {
+func TryParseTime(unparsed string) (schema.Step, time.Time, error) {
 	var possibleError error = nil
 	for step, layout := range utils.StepFormats {
 		t, err := time.Parse(layout, unparsed)
@@ -48,7 +48,7 @@ func DurationInterceptor(ctx *cli.Context) error {
 	end := ctx.String("end")
 	timezone := ctx.GlobalString("timezone")
 
-	startTime, endTime, step := ParseDuration(start, end, timezone)
+	startTime, endTime, step, dt := ParseDuration(start, end, timezone)
 
 	if err := ctx.Set("start", startTime.Format(utils.StepFormats[step])); err != nil {
 		return err
@@ -56,6 +56,8 @@ func DurationInterceptor(ctx *cli.Context) error {
 		return err
 	} else if err := ctx.Set("step", step.String()); err != nil {
 		return err
+	} else if err := ctx.Set("durationType", dt.String()); err != nil {
+		return err
 	}
 	return nil
 }
@@ -68,7 +70,7 @@ func DurationInterceptor(ctx *cli.Context) error {
 //   then: end := now + 30 units, where unit is the precision of `start`, (hours, minutes, etc.)
 // if --start is absent, --end is given,
 //   then: start := end - 30 units, where unit is the precision of `end`, (hours, minutes, etc.)
-func ParseDuration(start, end, timezone string) (startTime, endTime time.Time, step schema.Step) {
+func ParseDuration(start, end, timezone string) (startTime, endTime time.Time, step schema.Step, dt utils.DurationType) {
 	logger.Log.Debugln("Start time:", start, "end time:", end, "timezone:", timezone)
 
 	now := time.Now()
@@ -84,7 +86,7 @@ func ParseDuration(start, end, timezone string) (startTime, endTime time.Time, s
 
 	// both are absent
 	if start == "" && end == "" {
-		return now.Add(-30 * time.Minute), now, schema.StepMinute
+		return now.Add(-30 * time.Minute), now, schema.StepMinute, utils.BothAbsent
 	}
 
 	var err error
@@ -93,24 +95,24 @@ func ParseDuration(start, end, timezone string) (startTime, endTime time.Time, s
 	if len(start) > 0 && len(end) > 0 {
 		start, end = AlignPrecision(start, end)
 
-		if _, startTime, err = tryParseTime(start); err != nil {
+		if _, startTime, err = TryParseTime(start); err != nil {
 			logger.Log.Fatalln("Unsupported time format:", start, err)
 		}
-		if step, endTime, err = tryParseTime(end); err != nil {
+		if step, endTime, err = TryParseTime(end); err != nil {
 			logger.Log.Fatalln("Unsupported time format:", end, err)
 		}
 
-		return startTime, endTime, step
+		return startTime, endTime, step, utils.BothPresent
 	} else if end == "" { // end is absent
-		if step, startTime, err = tryParseTime(start); err != nil {
+		if step, startTime, err = TryParseTime(start); err != nil {
 			logger.Log.Fatalln("Unsupported time format:", start, err)
 		}
-		return startTime, startTime.Add(30 * utils.StepDuration[step]), step
+		return startTime, startTime.Add(30 * utils.StepDuration[step]), step, utils.EndAbsent
 	} else { // start is absent
-		if step, endTime, err = tryParseTime(end); err != nil {
+		if step, endTime, err = TryParseTime(end); err != nil {
 			logger.Log.Fatalln("Unsupported time format:", end, err)
 		}
-		return endTime.Add(-30 * utils.StepDuration[step]), endTime, step
+		return endTime.Add(-30 * utils.StepDuration[step]), endTime, step, utils.StartAbsent
 	}
 }
 
diff --git a/commands/interceptor/duration_test.go b/commands/interceptor/duration_test.go
index b4f8c90..10c8de5 100644
--- a/commands/interceptor/duration_test.go
+++ b/commands/interceptor/duration_test.go
@@ -82,7 +82,7 @@ func TestParseDuration(t *testing.T) {
 	}
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
-			gotStartTime, gotEndTime, gotStep := ParseDuration(tt.args.start, tt.args.end, "")
+			gotStartTime, gotEndTime, gotStep, _ := ParseDuration(tt.args.start, tt.args.end, "")
 			current := gotStartTime.Truncate(time.Minute).Format(timeFormat)
 			spec := tt.wantedStartTime.Truncate(time.Minute).Format(timeFormat)
 			if !reflect.DeepEqual(current, spec) {
diff --git a/display/graph/dashboard/global.go b/display/graph/dashboard/global.go
index 1d4a2d6..d28f3a7 100644
--- a/display/graph/dashboard/global.go
+++ b/display/graph/dashboard/global.go
@@ -22,9 +22,12 @@ import (
 	"fmt"
 	"math"
 	"strings"
+	"time"
 
+	"github.com/apache/skywalking-cli/commands/interceptor"
+	"github.com/apache/skywalking-cli/graphql/schema"
 	"github.com/apache/skywalking-cli/graphql/utils"
-	"github.com/apache/skywalking-cli/lib/heatmap"
+	lib "github.com/apache/skywalking-cli/lib/heatmap"
 
 	"github.com/mattn/go-runewidth"
 	"github.com/mum4k/termdash"
@@ -35,6 +38,7 @@ import (
 	"github.com/urfave/cli"
 
 	"github.com/apache/skywalking-cli/display/graph/gauge"
+	"github.com/apache/skywalking-cli/display/graph/heatmap"
 	"github.com/apache/skywalking-cli/display/graph/linear"
 	"github.com/apache/skywalking-cli/graphql/dashboard"
 
@@ -71,7 +75,7 @@ var strToLayoutType = map[string]layoutType{
 type widgets struct {
 	gauges  []*gauge.MetricColumn
 	linears []*linechart.LineChart
-	heatmap *heatmap.HeatMap
+	heatmap *lib.HeatMap
 
 	// buttons are used to change the layout.
 	buttons []*button.Button
@@ -80,9 +84,20 @@ type widgets struct {
 // linearTitles are titles of each line chart, load from the template file.
 var linearTitles []string
 
+// template determines how the global dashboard is displayed.
+var template *dashboard.GlobalTemplate
+
+var allWidgets *widgets
+
+var initStartStr string
+var initEndStr string
+
+var curStartTime time.Time
+var curEndTime time.Time
+
 // setLayout sets the specified layout.
-func setLayout(c *container.Container, w *widgets, lt layoutType) error {
-	gridOpts, err := gridLayout(w, lt)
+func setLayout(c *container.Container, lt layoutType) error {
+	gridOpts, err := gridLayout(lt)
 	if err != nil {
 		return err
 	}
@@ -90,16 +105,16 @@ func setLayout(c *container.Container, w *widgets, lt layoutType) error {
 }
 
 // newLayoutButtons returns buttons that dynamically switch the layouts.
-func newLayoutButtons(c *container.Container, w *widgets, template *dashboard.ButtonTemplate) ([]*button.Button, error) {
+func newLayoutButtons(c *container.Container) ([]*button.Button, error) {
 	var buttons []*button.Button
 
 	opts := []button.Option{
-		button.WidthFor(longestString(template.Texts)),
-		button.FillColor(cell.ColorNumber(template.ColorNum)),
-		button.Height(template.Height),
+		button.WidthFor(longestString(template.Buttons.Texts)),
+		button.FillColor(cell.ColorNumber(template.Buttons.ColorNum)),
+		button.Height(template.Buttons.Height),
 	}
 
-	for _, text := range template.Texts {
+	for _, text := range template.Buttons.Texts {
 		// declare a local variable lt to avoid closure.
 		lt, ok := strToLayoutType[text]
 		if !ok {
@@ -107,7 +122,7 @@ func newLayoutButtons(c *container.Container, w *widgets, template *dashboard.Bu
 		}
 
 		b, err := button.New(text, func() error {
-			return setLayout(c, w, lt)
+			return setLayout(c, lt)
 		}, opts...)
 		if err != nil {
 			return nil, err
@@ -119,13 +134,13 @@ func newLayoutButtons(c *container.Container, w *widgets, template *dashboard.Bu
 }
 
 // gridLayout prepares container options that represent the desired screen layout.
-func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
+func gridLayout(lt layoutType) ([]container.Option, error) {
 	const buttonRowHeight = 15
 
-	buttonColWidthPerc := 100 / len(w.buttons)
+	buttonColWidthPerc := 100 / len(allWidgets.buttons)
 	var buttonCols []grid.Element
 
-	for _, b := range w.buttons {
+	for _, b := range allWidgets.buttons {
 		buttonCols = append(buttonCols, grid.ColWidthPerc(buttonColWidthPerc, grid.Widget(b)))
 	}
 
@@ -136,11 +151,11 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
 	switch lt {
 	case layoutMetrics:
 		rows = append(rows,
-			grid.RowHeightPerc(70, gauge.MetricColumnsElement(w.gauges)...),
+			grid.RowHeightPerc(70, gauge.MetricColumnsElement(allWidgets.gauges)...),
 		)
 
 	case layoutLineChart:
-		lcElements := linear.LineChartElements(w.linears, linearTitles)
+		lcElements := linear.LineChartElements(allWidgets.linears, linearTitles)
 		percentage := int(math.Min(99, float64((100-buttonRowHeight)/len(lcElements))))
 
 		for _, e := range lcElements {
@@ -156,7 +171,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
 			grid.RowHeightPerc(
 				99-buttonRowHeight,
 				grid.ColWidthPerc((99-heatmapColWidth)/2), // Use two empty cols to center the heatmap.
-				grid.ColWidthPerc(heatmapColWidth, grid.Widget(w.heatmap)),
+				grid.ColWidthPerc(heatmapColWidth, grid.Widget(allWidgets.heatmap)),
 				grid.ColWidthPerc((99-heatmapColWidth)/2),
 			),
 		)
@@ -174,7 +189,7 @@ func gridLayout(w *widgets, lt layoutType) ([]container.Option, error) {
 }
 
 // newWidgets creates all widgets used by the dashboard.
-func newWidgets(data *dashboard.GlobalData, template *dashboard.GlobalTemplate) (*widgets, error) {
+func newWidgets(data *dashboard.GlobalData) error {
 	var columns []*gauge.MetricColumn
 	var linears []*linechart.LineChart
 
@@ -182,7 +197,7 @@ func newWidgets(data *dashboard.GlobalData, template *dashboard.GlobalTemplate)
 	for i, t := range template.Metrics {
 		col, err := gauge.NewMetricColumn(data.Metrics[i], &t)
 		if err != nil {
-			return nil, err
+			return err
 		}
 		columns = append(columns, col)
 	}
@@ -191,26 +206,21 @@ func newWidgets(data *dashboard.GlobalData, template *dashboard.GlobalTemplate)
 	for _, input := range data.ResponseLatency {
 		l, err := linear.NewLineChart(input)
 		if err != nil {
-			return nil, err
+			return err
 		}
 		linears = append(linears, l)
 	}
 
 	// Create a heat map.
-	hp, err := heatmap.NewHeatMap()
+	hp, err := heatmap.NewHeatMapWidget(data.HeatMap)
 	if err != nil {
-		return nil, err
+		return err
 	}
-	hpColumns := utils.HeatMapToMap(&data.HeatMap)
-	yLabels := utils.BucketsToStrings(data.HeatMap.Buckets)
-	hp.SetColumns(hpColumns)
-	hp.SetYLabels(yLabels)
-
-	return &widgets{
-		gauges:  columns,
-		linears: linears,
-		heatmap: hp,
-	}, nil
+
+	allWidgets.gauges = columns
+	allWidgets.linears = linears
+	allWidgets.heatmap = hp
+	return nil
 }
 
 func Display(ctx *cli.Context, data *dashboard.GlobalData) error {
@@ -229,23 +239,31 @@ func Display(ctx *cli.Context, data *dashboard.GlobalData) error {
 		return err
 	}
 
-	template, err := dashboard.LoadTemplate(ctx.String("template"))
+	te, err := dashboard.LoadTemplate(ctx.String("template"))
 	if err != nil {
 		return err
 	}
+	template = te
 	linearTitles = strings.Split(template.ResponseLatency.Labels, ", ")
 
-	w, err := newWidgets(data, template)
+	// Initialization
+	allWidgets = &widgets{
+		gauges:  nil,
+		linears: nil,
+		heatmap: nil,
+		buttons: nil,
+	}
+	err = newWidgets(data)
 	if err != nil {
-		panic(err)
+		return err
 	}
-	lb, err := newLayoutButtons(c, w, &template.Buttons)
+	lb, err := newLayoutButtons(c)
 	if err != nil {
 		return err
 	}
-	w.buttons = lb
+	allWidgets.buttons = lb
 
-	gridOpts, err := gridLayout(w, layoutMetrics)
+	gridOpts, err := gridLayout(layoutMetrics)
 	if err != nil {
 		return err
 	}
@@ -261,7 +279,15 @@ func Display(ctx *cli.Context, data *dashboard.GlobalData) error {
 		}
 	}
 
-	err = termdash.Run(con, t, c, termdash.KeyboardSubscriber(quitter))
+	refreshInterval := time.Duration(ctx.Int("refresh")) * time.Second
+	dt := utils.DurationType(ctx.String("durationType"))
+
+	// 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
 }
@@ -277,3 +303,91 @@ func longestString(strs []string) (ret string) {
 	}
 	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")
+
+	_, start, err := interceptor.TryParseTime(initStartStr)
+	if err != nil {
+		return
+	}
+	_, end, err := interceptor.TryParseTime(initEndStr)
+	if err != nil {
+		return
+	}
+
+	curStartTime = start
+	curEndTime = end
+
+	for {
+		select {
+		case <-ticker.C:
+			d, err := updateDuration(interval)
+			if err != nil {
+				continue
+			}
+
+			data := dashboard.Global(ctx, d)
+			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) (schema.Duration, error) {
+	step, _, err := interceptor.TryParseTime(initStartStr)
+	if err != nil {
+		return schema.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 schema.Duration{}, fmt.Errorf("the duration does not update")
+	}
+
+	initStartStr = curStartStr
+	initEndStr = curEndStr
+	return schema.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
+}
diff --git a/display/graph/gauge/gauge.go b/display/graph/gauge/gauge.go
index e143578..c7571ee 100644
--- a/display/graph/gauge/gauge.go
+++ b/display/graph/gauge/gauge.go
@@ -28,6 +28,7 @@ import (
 
 	"github.com/apache/skywalking-cli/graphql/dashboard"
 	"github.com/apache/skywalking-cli/graphql/schema"
+	"github.com/apache/skywalking-cli/util"
 
 	"github.com/mum4k/termdash"
 	"github.com/mum4k/termdash/cell"
@@ -43,13 +44,38 @@ import (
 const RootID = "root"
 
 type MetricColumn struct {
-	title  *text.Text
-	gauges []*gauge.Gauge
+	title          *text.Text
+	gauges         []*gauge.Gauge
+	aggregationNum int
+}
+
+// Update updates the MetricColumn's `Absolute` and `BorderTitle`.
+func (mc *MetricColumn) Update(data []*schema.SelectedRecord) error {
+	for i, item := range data {
+		strValue := *(item.Value)
+		v, err := strconv.Atoi(strValue)
+		if err != nil {
+			return err
+		}
+
+		if mc.aggregationNum != 0 {
+			strValue = fmt.Sprintf("%.4f", float64(v)/float64(mc.aggregationNum))
+		}
+
+		maxValue, err := findMaxValue(data)
+		if err != nil {
+			return err
+		}
+
+		if err := mc.gauges[i].Absolute(v, maxValue, gauge.BorderTitle("["+strValue+"]")); err != nil {
+			return err
+		}
+	}
+	return nil
 }
 
 func NewMetricColumn(column []*schema.SelectedRecord, config *dashboard.MetricTemplate) (*MetricColumn, error) {
 	var ret MetricColumn
-	var maxValue int
 
 	t, err := text.New()
 	if err != nil {
@@ -60,18 +86,8 @@ func NewMetricColumn(column []*schema.SelectedRecord, config *dashboard.MetricTe
 	}
 	ret.title = t
 
-	if config.Condition.Order == schema.OrderDes {
-		temp, err := strconv.Atoi(*(column[0].Value))
-		if err != nil {
-			return nil, err
-		}
-		maxValue = temp
-	} else if config.Condition.Order == schema.OrderAsc {
-		temp, err := strconv.Atoi(*(column[len(column)-1].Value))
-		if err != nil {
-			return nil, err
-		}
-		maxValue = temp
+	if len(column) == 0 {
+		return nil, fmt.Errorf("the metrics data is empty, please check the GraphQL backend")
 	}
 
 	for _, item := range column {
@@ -87,6 +103,7 @@ func NewMetricColumn(column []*schema.SelectedRecord, config *dashboard.MetricTe
 				return nil, convErr
 			}
 			strValue = fmt.Sprintf("%.4f", float64(v)/float64(aggregationNum))
+			ret.aggregationNum = aggregationNum
 		}
 
 		g, err := gauge.New(
@@ -101,6 +118,11 @@ func NewMetricColumn(column []*schema.SelectedRecord, config *dashboard.MetricTe
 			return nil, err
 		}
 
+		maxValue, err := findMaxValue(column)
+		if err != nil {
+			return nil, err
+		}
+
 		if err := g.Absolute(v, maxValue); err != nil {
 			return nil, err
 		}
@@ -222,3 +244,18 @@ func Display(ctx *cli.Context, metrics [][]*schema.SelectedRecord) error {
 
 	return err
 }
+
+// findMaxValue finds the maximum value in the array of `schema.SelectedRecord`.
+func findMaxValue(column []*schema.SelectedRecord) (int, error) {
+	var ret int
+
+	for _, c := range column {
+		v, err := strconv.Atoi(*(c.Value))
+		if err != nil {
+			return ret, err
+		}
+		ret = util.MaxInt(ret, v)
+	}
+
+	return ret, nil
+}
diff --git a/display/graph/heatmap/heatmap.go b/display/graph/heatmap/heatmap.go
index 1a40d29..12f64d4 100644
--- a/display/graph/heatmap/heatmap.go
+++ b/display/graph/heatmap/heatmap.go
@@ -44,11 +44,20 @@ func NewHeatMapWidget(data schema.HeatMap) (hp *heatmap.HeatMap, err error) {
 		return hp, err
 	}
 
-	hpColumns := utils.HeatMapToMap(&data)
-	yLabels := utils.BucketsToStrings(data.Buckets)
+	SetData(hp, data)
+	return
+}
+
+func SetData(hp *heatmap.HeatMap, data schema.HeatMap) {
+	hpColumns, yLabels := processData(data)
 	hp.SetColumns(hpColumns)
 	hp.SetYLabels(yLabels)
+}
 
+// processData converts data into hpColumns and yValues for the heat map.
+func processData(data schema.HeatMap) (hpColumns map[string][]int64, yLabels []string) {
+	hpColumns = utils.HeatMapToMap(&data)
+	yLabels = utils.BucketsToStrings(data.Buckets)
 	return
 }
 
diff --git a/display/graph/linear/linear.go b/display/graph/linear/linear.go
index da219f4..bf0bc5c 100644
--- a/display/graph/linear/linear.go
+++ b/display/graph/linear/linear.go
@@ -36,11 +36,29 @@ import (
 
 const RootID = "root"
 
+const defaultSeriesLabel = "linear"
+
 func NewLineChart(inputs map[string]float64) (lineChart *linechart.LineChart, err error) {
+	if lineChart, err = linechart.New(linechart.YAxisAdaptive()); err != nil {
+		return
+	}
+	if err = SetLineChartSeries(lineChart, inputs); err != nil {
+		return
+	}
+	return lineChart, err
+}
+
+func SetLineChartSeries(lc *linechart.LineChart, inputs map[string]float64) error {
+	xLabels, yValues := processInputs(inputs)
+	return lc.Series(defaultSeriesLabel, yValues, linechart.SeriesXLabels(xLabels))
+}
+
+// processInputs converts inputs into xLabels and yValues for line charts.
+func processInputs(inputs map[string]float64) (xLabels map[int]string, yValues []float64) {
 	index := 0
 
-	xLabels := map[int]string{}
-	yValues := make([]float64, len(inputs))
+	xLabels = map[int]string{}
+	yValues = make([]float64, len(inputs))
 
 	// The iteration order of map is uncertain, so the keys must be sorted explicitly.
 	var names []string
@@ -54,14 +72,7 @@ func NewLineChart(inputs map[string]float64) (lineChart *linechart.LineChart, er
 		yValues[index] = inputs[name]
 		index++
 	}
-
-	if lineChart, err = linechart.New(linechart.YAxisAdaptive()); err != nil {
-		return
-	}
-
-	err = lineChart.Series("graph-linear", yValues, linechart.SeriesXLabels(xLabels))
-
-	return lineChart, err
+	return
 }
 
 // LineChartElements is the part that separated from layout,
diff --git a/graphql/utils/constants.go b/graphql/utils/constants.go
index 5650c57..2f527e3 100644
--- a/graphql/utils/constants.go
+++ b/graphql/utils/constants.go
@@ -38,3 +38,16 @@ var StepDuration = map[schema.Step]time.Duration{
 	schema.StepHour:   time.Hour,
 	schema.StepDay:    time.Hour * 24,
 }
+
+type DurationType string
+
+const (
+	BothAbsent  DurationType = "BothAbsent"
+	BothPresent DurationType = "BothPresent"
+	StartAbsent DurationType = "StartAbsent"
+	EndAbsent   DurationType = "EndAbsent"
+)
+
+func (dt DurationType) String() string {
+	return string(dt)
+}
diff --git a/lib/heatmap/heatmap.go b/lib/heatmap/heatmap.go
index 3826ddb..cdc466f 100644
--- a/lib/heatmap/heatmap.go
+++ b/lib/heatmap/heatmap.go
@@ -106,6 +106,12 @@ func (hp *HeatMap) SetColumns(values map[string][]int64) {
 	}
 	sort.Strings(names)
 
+	// Clear XLabels and columns.
+	if len(hp.XLabels) > 0 {
+		hp.XLabels = hp.XLabels[:0]
+	}
+	hp.columns = make(map[string]*columnValues)
+
 	for _, name := range names {
 		cv := newColumnValues(values[name])
 		hp.columns[name] = cv
@@ -123,8 +129,14 @@ func (hp *HeatMap) SetYLabels(labels []string) {
 	hp.mu.Lock()
 	defer hp.mu.Unlock()
 
+	// Clear YLabels.
+	if len(hp.YLabels) > 0 {
+		hp.YLabels = hp.YLabels[:0]
+	}
+
 	hp.YLabels = append(hp.YLabels, labels...)
 
+	// Reverse the array.
 	for i, j := 0, len(hp.YLabels)-1; i < j; i, j = i+1, j-1 {
 		hp.YLabels[i], hp.YLabels[j] = hp.YLabels[j], hp.YLabels[i]
 	}