You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2020/03/08 07:15:11 UTC

[skywalking-cli] branch master updated: [Feature] Support visualization of heat map and enhance Display logic (#34)

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

wusheng 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 f2b51ae  [Feature] Support visualization of heat map and enhance Display logic (#34)
f2b51ae is described below

commit f2b51ae2c6a6bf03cb7dbc71267ec08536d034ad
Author: kezhenxu94 <ke...@apache.org>
AuthorDate: Sun Mar 8 15:15:01 2020 +0800

    [Feature] Support visualization of heat map and enhance Display logic (#34)
    
    ### Motivation
    
    Visualize heat map and enhance Display logic
    
    ### Modification
    
    - Add HeatMap widget to visualize thermodynamic metrics.
    
    - Refactor display logic.
    
    ### Result
    
    - Thermodynamic metrics now can be visualized.
    
    - Display function takes more extra information to determine more details
    
    - Closes https://github.com/apache/skywalking/issues/4461
---
 README.md                                          |   8 +-
 commands/endpoint/list.go                          |   4 +-
 commands/instance/list.go                          |   4 +-
 commands/instance/search.go                        |   4 +-
 commands/metrics/aggregation/topn.go               |   4 +-
 commands/metrics/linear/linear-metrics.go          |   4 +-
 commands/metrics/linear/multiple-linear-metrics.go |   4 +-
 commands/metrics/single/single-metrics.go          |   4 +-
 commands/metrics/thermodynamic/thermodynamic.go    |   8 +-
 commands/service/list.go                           |   4 +-
 display/display.go                                 |  12 ++-
 .../{json/json.go => displayable/displayable.go}   |  19 ++--
 display/graph/graph.go                             |  22 ++--
 display/graph/heatmap/heatmap.go                   | 114 +++++++++++++++++++++
 display/json/json.go                               |  13 +--
 display/json/json_test.go                          |   4 +-
 display/table/table.go                             |   6 +-
 display/table/table_test.go                        |   4 +-
 display/yaml/yaml.go                               |  13 +--
 display/yaml/yaml_test.go                          |   4 +-
 dist/LICENSE                                       |   2 +
 dist/licenses/LICENSE-go-runewidth                 |  21 ++++
 dist/licenses/LICENSE-termui                       |  21 ++++
 go.mod                                             |   4 +-
 go.sum                                             |   7 ++
 graphql/metrics/metrics.go                         |   2 +-
 lib/heatmap.go                                     | 114 +++++++++++++++++++++
 display/json/json.go => util/math.go               |  18 +---
 28 files changed, 381 insertions(+), 67 deletions(-)

diff --git a/README.md b/README.md
index 9fba39b..7e748b7 100644
--- a/README.md
+++ b/README.md
@@ -461,13 +461,17 @@ $ ./bin/swctl metrics top 5 --name endpoint_sla --service-id 3
 
 <details>
 
-<summary>Query the overall heatmap</summary>
+<summary>Query the overall heat map</summary>
 
 ```shell
-$  ./bin/swctl metrics thermodynamic --name all_heatmap
+$ ./bin/swctl metrics thermodynamic --name all_heatmap
 {"nodes":[[0,0,238],[0,1,1],[0,2,39],[0,3,31],[0,4,12],[0,5,13],[0,6,4],[0,7,3],[0,8,3],[0,9,0],[0,10,48],[0,11,3],[0,12,49],[0,13,54],[0,14,11],[0,15,9],[0,16,2],[0,17,4],[0,18,0],[0,19,1],[0,20,186],[1,0,264],[1,1,3],[1,2,51],[1,3,38],[1,4,16],[1,5,14],[1,6,3],[1,7,2],[1,8,1],[1,9,2],[1,10,51],[1,11,1],[1,12,41],[1,13,56],[1,14,16],[1,15,15],[1,16,7],[1,17,7],[1,18,3],[1,19,1],[1,20,174],[2,0,231],[2,1,3],[2,2,42],[2,3,41],[2,4,18],[2,5,4],[2,6,2],[2,7,1],[2,8,2],[2,9,0],[2,10,54],[2,1 [...]
 ```
 
+```shell
+$ ./bin/swctl --display=graph metrics thermodynamic --name all_heatmap 
+```
+
 </details>
 
 <details>
diff --git a/commands/endpoint/list.go b/commands/endpoint/list.go
index 034c6a5..3c6767b 100644
--- a/commands/endpoint/list.go
+++ b/commands/endpoint/list.go
@@ -20,6 +20,8 @@ package endpoint
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metadata"
 
 	"github.com/apache/skywalking-cli/display"
@@ -56,6 +58,6 @@ var ListCommand = cli.Command{
 
 		endpoints := metadata.SearchEndpoints(ctx, serviceID, keyword, limit)
 
-		return display.Display(ctx, endpoints)
+		return display.Display(ctx, &displayable.Displayable{Data: endpoints})
 	},
 }
diff --git a/commands/instance/list.go b/commands/instance/list.go
index eda72c1..1cfcd88 100644
--- a/commands/instance/list.go
+++ b/commands/instance/list.go
@@ -20,6 +20,8 @@ package instance
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metadata"
 
 	"github.com/apache/skywalking-cli/commands/flags"
@@ -51,6 +53,6 @@ var ListCommand = cli.Command{
 			Step:  step.(*model.StepEnumValue).Selected,
 		})
 
-		return display.Display(ctx, instances)
+		return display.Display(ctx, &displayable.Displayable{Data: instances})
 	},
 }
diff --git a/commands/instance/search.go b/commands/instance/search.go
index b97adae..548606f 100644
--- a/commands/instance/search.go
+++ b/commands/instance/search.go
@@ -20,6 +20,8 @@ package instance
 import (
 	"regexp"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metadata"
 
 	"github.com/urfave/cli"
@@ -62,6 +64,6 @@ var SearchCommand = cli.Command{
 				}
 			}
 		}
-		return display.Display(ctx, result)
+		return display.Display(ctx, &displayable.Displayable{Data: result})
 	},
 }
diff --git a/commands/metrics/aggregation/topn.go b/commands/metrics/aggregation/topn.go
index 77876f6..ce6c63d 100644
--- a/commands/metrics/aggregation/topn.go
+++ b/commands/metrics/aggregation/topn.go
@@ -22,6 +22,8 @@ import (
 	"strconv"
 	"strings"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/commands/interceptor"
 
 	"github.com/urfave/cli"
@@ -107,6 +109,6 @@ var TopN = cli.Command{
 			metricsValues = aggregation.ServiceTopN(ctx, name, topN, duration, order)
 		}
 
-		return display.Display(ctx, metricsValues)
+		return display.Display(ctx, &displayable.Displayable{Data: metricsValues})
 	},
 }
diff --git a/commands/metrics/linear/linear-metrics.go b/commands/metrics/linear/linear-metrics.go
index d435063..c1aa19b 100644
--- a/commands/metrics/linear/linear-metrics.go
+++ b/commands/metrics/linear/linear-metrics.go
@@ -20,6 +20,8 @@ package linear
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metrics"
 	"github.com/apache/skywalking-cli/graphql/utils"
 
@@ -75,6 +77,6 @@ var Single = cli.Command{
 			ID:   id,
 		}, duration)
 
-		return display.Display(ctx, utils.MetricsToMap(duration, metricsValues))
+		return display.Display(ctx, &displayable.Displayable{Data: utils.MetricsToMap(duration, metricsValues)})
 	},
 }
diff --git a/commands/metrics/linear/multiple-linear-metrics.go b/commands/metrics/linear/multiple-linear-metrics.go
index 31db251..fae3580 100644
--- a/commands/metrics/linear/multiple-linear-metrics.go
+++ b/commands/metrics/linear/multiple-linear-metrics.go
@@ -20,6 +20,8 @@ package linear
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metrics"
 	"github.com/apache/skywalking-cli/graphql/utils"
 
@@ -88,6 +90,6 @@ var Multiple = cli.Command{
 			reshaped[index] = utils.MetricsToMap(duration, value)
 		}
 
-		return display.Display(ctx, reshaped)
+		return display.Display(ctx, &displayable.Displayable{Data: reshaped})
 	},
 }
diff --git a/commands/metrics/single/single-metrics.go b/commands/metrics/single/single-metrics.go
index 69405ea..1f710c0 100644
--- a/commands/metrics/single/single-metrics.go
+++ b/commands/metrics/single/single-metrics.go
@@ -20,6 +20,8 @@ package single
 import (
 	"strings"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metrics"
 
 	"github.com/urfave/cli"
@@ -75,6 +77,6 @@ var Command = cli.Command{
 			Step:  step.(*model.StepEnumValue).Selected,
 		})
 
-		return display.Display(ctx, metricsValues.Values)
+		return display.Display(ctx, &displayable.Displayable{Data: metricsValues.Values})
 	},
 }
diff --git a/commands/metrics/thermodynamic/thermodynamic.go b/commands/metrics/thermodynamic/thermodynamic.go
index 5eefb3a..05c4ba4 100644
--- a/commands/metrics/thermodynamic/thermodynamic.go
+++ b/commands/metrics/thermodynamic/thermodynamic.go
@@ -20,6 +20,8 @@ package thermodynamic
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/commands/flags"
 	"github.com/apache/skywalking-cli/commands/interceptor"
 	"github.com/apache/skywalking-cli/commands/model"
@@ -62,6 +64,10 @@ var Command = cli.Command{
 			Name: metricsName,
 		}, duration)
 
-		return display.Display(ctx, metricsValues)
+		return display.Display(ctx, &displayable.Displayable{
+			Data:     metricsValues,
+			Duration: duration,
+			Title:    metricsName,
+		})
 	},
 }
diff --git a/commands/service/list.go b/commands/service/list.go
index 2604a6f..14e2830 100644
--- a/commands/service/list.go
+++ b/commands/service/list.go
@@ -20,6 +20,8 @@ package service
 import (
 	"github.com/urfave/cli"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/metadata"
 
 	"github.com/apache/skywalking-cli/commands/flags"
@@ -58,6 +60,6 @@ var ListCommand = cli.Command{
 			services = []schema.Service{service}
 		}
 
-		return display.Display(ctx, services)
+		return display.Display(ctx, &displayable.Displayable{Data: services})
 	},
 }
diff --git a/display/display.go b/display/display.go
index e620673..af3a598 100644
--- a/display/display.go
+++ b/display/display.go
@@ -21,6 +21,8 @@ import (
 	"fmt"
 	"strings"
 
+	d "github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/display/graph"
 
 	"github.com/urfave/cli"
@@ -38,18 +40,18 @@ const (
 )
 
 // Display the object in the style specified in flag --display
-func Display(ctx *cli.Context, object interface{}) error {
+func Display(ctx *cli.Context, displayable *d.Displayable) error {
 	displayStyle := ctx.GlobalString("display")
 
 	switch strings.ToLower(displayStyle) {
 	case JSON:
-		return json.Display(object)
+		return json.Display(displayable)
 	case YAML:
-		return yaml.Display(object)
+		return yaml.Display(displayable)
 	case TABLE:
-		return table.Display(object)
+		return table.Display(displayable)
 	case GRAPH:
-		return graph.Display(object)
+		return graph.Display(displayable)
 	default:
 		return fmt.Errorf("unsupported display style: %s", displayStyle)
 	}
diff --git a/display/json/json.go b/display/displayable/displayable.go
similarity index 79%
copy from display/json/json.go
copy to display/displayable/displayable.go
index 25aaccf..12ca7d2 100644
--- a/display/json/json.go
+++ b/display/displayable/displayable.go
@@ -15,19 +15,12 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package json
+package displayable
 
-import (
-	"encoding/json"
-	"fmt"
-)
+import "github.com/apache/skywalking-cli/graphql/schema"
 
-func Display(object interface{}) error {
-	if bytes, e := json.Marshal(object); e == nil {
-		fmt.Printf("%v\n", string(bytes))
-	} else {
-		return e
-	}
-
-	return nil
+type Displayable struct {
+	Data     interface{}
+	Duration schema.Duration
+	Title    string
 }
diff --git a/display/graph/graph.go b/display/graph/graph.go
index c27f835..e8fc072 100644
--- a/display/graph/graph.go
+++ b/display/graph/graph.go
@@ -21,21 +21,31 @@ import (
 	"fmt"
 	"reflect"
 
+	"github.com/apache/skywalking-cli/display/graph/heatmap"
+	"github.com/apache/skywalking-cli/graphql/schema"
+
+	d "github.com/apache/skywalking-cli/display/displayable"
 	"github.com/apache/skywalking-cli/display/graph/linear"
 )
 
-func Display(object interface{}) error {
-	if reflect.TypeOf(object) == reflect.TypeOf(map[string]float64{}) {
-		kvs := []map[string]float64{object.(map[string]float64)}
+func Display(displayable *d.Displayable) error {
+	data := displayable.Data
+
+	if reflect.TypeOf(data) == reflect.TypeOf(schema.Thermodynamic{}) {
+		return heatmap.Display(displayable)
+	}
+
+	if reflect.TypeOf(data) == reflect.TypeOf(map[string]float64{}) {
+		kvs := []map[string]float64{data.(map[string]float64)}
 
 		return linear.Display(kvs)
 	}
 
-	if reflect.TypeOf(object) == reflect.TypeOf([]map[string]float64{}) {
-		kvs := object.([]map[string]float64)
+	if reflect.TypeOf(data) == reflect.TypeOf([]map[string]float64{}) {
+		kvs := data.([]map[string]float64)
 
 		return linear.Display(kvs)
 	}
 
-	return fmt.Errorf("type of %T is not supported to be displayed as ascii graph", reflect.TypeOf(object))
+	return fmt.Errorf("type of %T is not supported to be displayed as ascii graph", reflect.TypeOf(data))
 }
diff --git a/display/graph/heatmap/heatmap.go b/display/graph/heatmap/heatmap.go
new file mode 100644
index 0000000..0ca653a
--- /dev/null
+++ b/display/graph/heatmap/heatmap.go
@@ -0,0 +1,114 @@
+// 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 heatmap
+
+import (
+	"fmt"
+	"math"
+	"time"
+
+	"github.com/apache/skywalking-cli/graphql/utils"
+	"github.com/apache/skywalking-cli/util"
+
+	ui "github.com/gizak/termui/v3"
+
+	d "github.com/apache/skywalking-cli/display/displayable"
+	"github.com/apache/skywalking-cli/graphql/schema"
+	"github.com/apache/skywalking-cli/lib"
+)
+
+func Display(displayable *d.Displayable) error {
+	data := displayable.Data.(schema.Thermodynamic)
+
+	nodes := data.Nodes
+	duration := displayable.Duration
+
+	rows, cols, min, max := statistics(nodes)
+
+	if err := ui.Init(); err != nil {
+		return err
+	}
+	defer ui.Close()
+
+	termW, _ := ui.TerminalDimensions()
+
+	hm := lib.NewHeatMap()
+	hm.Title = fmt.Sprintf(" %s ", displayable.Title)
+	hm.XLabels = make([]string, rows)
+	hm.YLabels = make([]string, cols)
+	for i := 0; i < rows; i++ {
+		step := utils.StepDuration[duration.Step]
+		format := utils.StepFormats[duration.Step]
+		startTime, err := time.Parse(format, duration.Start)
+
+		if err != nil {
+			return err
+		}
+
+		hm.XLabels[i] = startTime.Add(time.Duration(i) * step).Format("15:04")
+	}
+	for i := 0; i < cols; i++ {
+		hm.YLabels[i] = fmt.Sprintf("%4d", i*data.AxisYStep)
+	}
+
+	hm.Data = make([][]float64, rows)
+	hm.CellColors = make([][]ui.Color, rows)
+	hm.NumStyles = make([][]ui.Style, rows)
+	for row := 0; row < rows; row++ {
+		hm.Data[row] = make([]float64, cols)
+		hm.CellColors[row] = make([]ui.Color, cols)
+		hm.NumStyles[row] = make([]ui.Style, cols)
+	}
+
+	scale := max - min
+	for _, node := range nodes {
+		color := ui.Color(255 - (float64(*node[2])/scale)*23)
+		hm.Data[*node[0]][*node[1]] = float64(*node[2])
+		hm.CellColors[*node[0]][*node[1]] = color
+		hm.NumStyles[*node[0]][*node[1]] = ui.Style{Fg: ui.ColorMagenta}
+	}
+
+	hm.Formatter = nil
+	hm.XLabelStyles = []ui.Style{{Fg: ui.ColorWhite}}
+	hm.CellGap = 0
+	hm.CellWidth = int(float64(termW) / float64(rows))
+	realWidth := (hm.CellWidth+hm.CellGap)*(rows+1) - hm.CellGap + 5
+	hm.SetRect(int(float64(termW-realWidth)/2), 2, realWidth, cols+5)
+
+	ui.Render(hm)
+
+	events := ui.PollEvents()
+	for e := <-events; e.ID != "q" && e.ID != "<C-c>"; e = <-events {
+	}
+	return nil
+}
+
+func statistics(nodes [][]*int) (rows, cols int, min, max float64) {
+	min = math.MaxFloat64
+
+	for _, node := range nodes {
+		rows = util.MaxInt(rows, *node[0])
+		cols = util.MaxInt(cols, *node[1])
+		max = math.Max(max, float64(*node[2]))
+		min = math.Min(min, float64(*node[2]))
+	}
+
+	rows++
+	cols++
+	return
+}
diff --git a/display/json/json.go b/display/json/json.go
index 25aaccf..12a506f 100644
--- a/display/json/json.go
+++ b/display/json/json.go
@@ -20,14 +20,15 @@ package json
 import (
 	"encoding/json"
 	"fmt"
+
+	d "github.com/apache/skywalking-cli/display/displayable"
 )
 
-func Display(object interface{}) error {
-	if bytes, e := json.Marshal(object); e == nil {
-		fmt.Printf("%v\n", string(bytes))
-	} else {
+func Display(displayable *d.Displayable) error {
+	bytes, e := json.Marshal(displayable.Data)
+	if e != nil {
 		return e
 	}
-
-	return nil
+	_, e = fmt.Printf("%v\n", string(bytes))
+	return e
 }
diff --git a/display/json/json_test.go b/display/json/json_test.go
index 8e30107..9d8ead4 100644
--- a/display/json/json_test.go
+++ b/display/json/json_test.go
@@ -20,6 +20,8 @@ package json
 import (
 	"testing"
 
+	d "github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/schema"
 )
 
@@ -36,7 +38,7 @@ func TestJsonDisplay(t *testing.T) {
 }
 
 func display(t *testing.T, result []schema.Service) {
-	if err := Display(result); err != nil {
+	if err := Display(&d.Displayable{Data: result}); err != nil {
 		t.Error(err)
 	}
 }
diff --git a/display/table/table.go b/display/table/table.go
index ce2a5ea..8e2227d 100644
--- a/display/table/table.go
+++ b/display/table/table.go
@@ -21,15 +21,17 @@ import (
 	"encoding/json"
 	"os"
 
+	d "github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/logger"
 
 	"github.com/olekukonko/tablewriter"
 )
 
-func Display(object interface{}) error {
+func Display(displayable *d.Displayable) error {
 	var stringMapArrays []map[string]string
 
-	bytes, _ := json.Marshal(object)
+	bytes, _ := json.Marshal(displayable.Data)
 	_ = json.Unmarshal(bytes, &stringMapArrays)
 
 	if len(stringMapArrays) < 1 {
diff --git a/display/table/table_test.go b/display/table/table_test.go
index fd24460..ec2b265 100644
--- a/display/table/table_test.go
+++ b/display/table/table_test.go
@@ -20,6 +20,8 @@ package table
 import (
 	"testing"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/schema"
 )
 
@@ -36,7 +38,7 @@ func TestTableDisplay(t *testing.T) {
 }
 
 func display(t *testing.T, result []schema.Service) {
-	if err := Display(result); err != nil {
+	if err := Display(&displayable.Displayable{Data: result}); err != nil {
 		t.Error(err)
 	}
 }
diff --git a/display/yaml/yaml.go b/display/yaml/yaml.go
index bb8cb79..3f3c98b 100644
--- a/display/yaml/yaml.go
+++ b/display/yaml/yaml.go
@@ -20,15 +20,16 @@ package yaml
 import (
 	"fmt"
 
+	d "github.com/apache/skywalking-cli/display/displayable"
+
 	"gopkg.in/yaml.v2"
 )
 
-func Display(object interface{}) error {
-	if bytes, e := yaml.Marshal(object); e == nil {
-		fmt.Printf("%v", string(bytes))
-	} else {
+func Display(displayable *d.Displayable) error {
+	bytes, e := yaml.Marshal(displayable.Data)
+	if e != nil {
 		return e
 	}
-
-	return nil
+	_, e = fmt.Printf("%v", string(bytes))
+	return e
 }
diff --git a/display/yaml/yaml_test.go b/display/yaml/yaml_test.go
index 81a772b..aaada3a 100644
--- a/display/yaml/yaml_test.go
+++ b/display/yaml/yaml_test.go
@@ -20,6 +20,8 @@ package yaml
 import (
 	"testing"
 
+	"github.com/apache/skywalking-cli/display/displayable"
+
 	"github.com/apache/skywalking-cli/graphql/schema"
 )
 
@@ -36,7 +38,7 @@ func TestYamlDisplay(t *testing.T) {
 }
 
 func display(t *testing.T, result []schema.Service) {
-	if err := Display(result); err != nil {
+	if err := Display(&displayable.Displayable{Data: result}); err != nil {
 		t.Error(err)
 	}
 }
diff --git a/dist/LICENSE b/dist/LICENSE
index e5b6732..4dfc4c4 100644
--- a/dist/LICENSE
+++ b/dist/LICENSE
@@ -222,6 +222,8 @@ The text of each license is also included at licenses/LICENSE-[project].txt.
 	sirupsen (logrus) 1.4.2: https://github.com/sirupsen/logrus MIT
 	urfave (cli) 1.22.1: https://github.com/urfave/cli MIT
 	nsf (termbox-go) 0.0.0-20190817171036-93860e161317: https://github.com/nsf/termbox-go MIT
+	gizak (termui) v3: https://github.com/gizak/termui MIT
+	mattn (go-runewidth) v3: https://github.com/mattn/go-runewidth MIT
 
 ========================================================================
 BSD licenses
diff --git a/dist/licenses/LICENSE-go-runewidth b/dist/licenses/LICENSE-go-runewidth
new file mode 100644
index 0000000..91b5cef
--- /dev/null
+++ b/dist/licenses/LICENSE-go-runewidth
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2016 Yasuhiro Matsumoto
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/dist/licenses/LICENSE-termui b/dist/licenses/LICENSE-termui
new file mode 100644
index 0000000..b8beeb7
--- /dev/null
+++ b/dist/licenses/LICENSE-termui
@@ -0,0 +1,21 @@
+The MIT License (MIT)
+
+Copyright (c) 2015 Zack Guo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/go.mod b/go.mod
index f28ffcc..7586d33 100644
--- a/go.mod
+++ b/go.mod
@@ -4,9 +4,11 @@ go 1.13
 
 require (
 	github.com/99designs/gqlgen v0.11.1 // indirect
+	github.com/gizak/termui/v3 v3.1.0
 	github.com/machinebox/graphql v0.2.2
+	github.com/mattn/go-runewidth v0.0.4
 	github.com/mum4k/termdash v0.10.0
-	github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317 // indirect
+	github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317
 	github.com/olekukonko/tablewriter v0.0.2
 	github.com/sirupsen/logrus v1.4.2
 	github.com/urfave/cli v1.22.1
diff --git a/go.sum b/go.sum
index cd718b5..932479d 100644
--- a/go.sum
+++ b/go.sum
@@ -12,6 +12,9 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/dgryski/trifles v0.0.0-20190318185328-a8d75aae118c/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
+github.com/gizak/termui v3.1.0+incompatible h1:N3CFm+j087lanTxPpHOmQs0uS3s5I9TxoAFy6DqPqv8=
+github.com/gizak/termui/v3 v3.1.0 h1:ZZmVDgwHl7gR7elfKf1xc4IudXZ5qqfDh4wExk4Iajc=
+github.com/gizak/termui/v3 v3.1.0/go.mod h1:bXQEBkJpzxUAKf0+xq9MSWAvWZlE7c+aidmyFlkYTrY=
 github.com/go-chi/chi v3.3.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
 github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
 github.com/gorilla/context v0.0.0-20160226214623-1ea25387ff6f/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg=
@@ -34,12 +37,16 @@ github.com/matryer/moq v0.0.0-20200106131100-75d0ddfc0007/go.mod h1:9ELz6aaclSIG
 github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
+github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 github.com/mattn/go-runewidth v0.0.4 h1:2BvfKmzob6Bmd4YsL0zygOqfdFnK7GR4QL06Do4/p7Y=
 github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
+github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
+github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
 github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047 h1:zCoDWFD5nrJJVjbXiDZcVhOBSzKn3o9LgRLLMRNuru8=
 github.com/mitchellh/mapstructure v0.0.0-20180203102830-a4e142e9c047/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
 github.com/mum4k/termdash v0.10.0 h1:uqM6ePiMf+smecb1tJJeON36o1hREeCfOmLFG0iz4a0=
 github.com/mum4k/termdash v0.10.0/go.mod h1:l3tO+lJi9LZqXRq7cu7h5/8rDIK3AzelSuq2v/KncxI=
+github.com/nsf/termbox-go v0.0.0-20190121233118-02980233997d/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
 github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317 h1:hhGN4SFXgXo61Q4Sjj/X9sBjyeSa2kdpaOzCO+8EVQw=
 github.com/nsf/termbox-go v0.0.0-20190817171036-93860e161317/go.mod h1:IuKpRQcYE1Tfu+oAQqaLisqDeXgjyyltCfsaoYN18NQ=
 github.com/olekukonko/tablewriter v0.0.1/go.mod h1:vsDQFd/mU46D+Z4whnwzcISnGGzXWMclvtLoiIKAKIo=
diff --git a/graphql/metrics/metrics.go b/graphql/metrics/metrics.go
index 68cf36d..ef71e32 100644
--- a/graphql/metrics/metrics.go
+++ b/graphql/metrics/metrics.go
@@ -85,7 +85,7 @@ func Thermodynamic(ctx *cli.Context, condition schema.MetricCondition, duration
 	request := graphql.NewRequest(`
 		query ($metric: MetricCondition!, $duration: Duration!) {
 			metrics: getThermodynamic(metric: $metric, duration: $duration) {
-				nodes responseTimeStep: axisYStep
+				nodes axisYStep
 			}
 		}
 	`)
diff --git a/lib/heatmap.go b/lib/heatmap.go
new file mode 100644
index 0000000..bf24b86
--- /dev/null
+++ b/lib/heatmap.go
@@ -0,0 +1,114 @@
+// 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 lib
+
+import (
+	"fmt"
+	im "image"
+
+	ui "github.com/gizak/termui/v3"
+	rw "github.com/mattn/go-runewidth"
+)
+
+type HeatMap struct {
+	ui.Block
+	XLabelStyles []ui.Style
+	CellColors   [][]ui.Color
+	NumStyles    [][]ui.Style
+	Formatter    func(float64) string
+	Data         [][]float64
+	XLabels      []string
+	YLabels      []string
+	CellWidth    int
+	CellGap      int
+}
+
+func NewHeatMap() *HeatMap {
+	return &HeatMap{
+		Block:        *ui.NewBlock(),
+		CellColors:   [][]ui.Color{ui.StandardColors, ui.StandardColors},
+		NumStyles:    [][]ui.Style{ui.StandardStyles, ui.StandardStyles},
+		Formatter:    func(n float64) string { return fmt.Sprint(n) },
+		XLabelStyles: ui.StandardStyles,
+		CellGap:      1,
+		CellWidth:    3,
+	}
+}
+
+func (hm *HeatMap) Draw(buffer *ui.Buffer) {
+	hm.Block.Draw(buffer)
+
+	cellX := hm.Inner.Min.X
+
+	for i, column := range hm.Data {
+		cellY := 0
+		for j, datum := range column {
+			buffer.SetString(
+				hm.YLabels[j],
+				ui.StyleClear,
+				im.Pt(hm.Inner.Min.X, (hm.Inner.Max.Y-2)-cellY),
+			)
+			for x := cellX + 5; x < ui.MinInt(cellX+hm.CellWidth, hm.Inner.Max.X)+5; x++ {
+				for y := (hm.Inner.Max.Y - 2) - cellY; y > (hm.Inner.Max.Y-2)-cellY-1; y-- {
+					cell := ui.NewCell(' ', ui.NewStyle(ui.ColorClear, color(hm.CellColors, i, j)))
+					buffer.SetCell(cell, im.Pt(x, y))
+				}
+			}
+
+			if hm.Formatter != nil {
+				hm.drawNumber(buffer, datum, i, j, cellX+5, cellY)
+			}
+
+			cellY++
+		}
+
+		if i < len(hm.XLabels) {
+			hm.drawLabel(buffer, cellX+5, i)
+		}
+
+		cellX += hm.CellWidth + hm.CellGap
+	}
+}
+
+func (hm *HeatMap) drawLabel(buffer *ui.Buffer, cellX, i int) {
+	labelX := cellX + ui.MaxInt(int(float64(hm.CellWidth)/2)-int(float64(rw.StringWidth(hm.XLabels[i]))/2), 0)
+	buffer.SetString(
+		ui.TrimString(hm.XLabels[i], hm.CellWidth),
+		ui.SelectStyle(hm.XLabelStyles, i),
+		im.Pt(labelX, hm.Inner.Max.Y-1),
+	)
+}
+
+func (hm *HeatMap) drawNumber(buffer *ui.Buffer, datum float64, i, j, cellX, cellY int) {
+	x := cellX + int(float64(hm.CellWidth)/2) - 1
+	numberStyle := style(hm.NumStyles, i, j)
+	cellColor := color(hm.CellColors, i, j)
+	buffer.SetString(
+		hm.Formatter(datum),
+		ui.NewStyle(numberStyle.Fg, cellColor, numberStyle.Modifier),
+		im.Pt(x, (hm.Inner.Max.Y-2)-cellY),
+	)
+}
+
+func color(colors [][]ui.Color, i, j int) ui.Color {
+	return colors[i%len(colors)][j%len(colors)]
+}
+
+func style(styles [][]ui.Style, i, j int) ui.Style {
+	return styles[i%len(styles)][j%len(styles)]
+}
diff --git a/display/json/json.go b/util/math.go
similarity index 79%
copy from display/json/json.go
copy to util/math.go
index 25aaccf..ddbe2a8 100644
--- a/display/json/json.go
+++ b/util/math.go
@@ -15,19 +15,11 @@
 // specific language governing permissions and limitations
 // under the License.
 
-package json
+package util
 
-import (
-	"encoding/json"
-	"fmt"
-)
-
-func Display(object interface{}) error {
-	if bytes, e := json.Marshal(object); e == nil {
-		fmt.Printf("%v\n", string(bytes))
-	} else {
-		return e
+func MaxInt(a, b int) int {
+	if a > b {
+		return a
 	}
-
-	return nil
+	return b
 }