Skip to content

Commit

Permalink
plot: Highcharts and refactoring WIP
Browse files Browse the repository at this point in the history
  • Loading branch information
tsenart committed Jul 18, 2018
1 parent 0d2e7f7 commit 4a4e7ba
Show file tree
Hide file tree
Showing 3 changed files with 152 additions and 89 deletions.
199 changes: 111 additions & 88 deletions lib/plot.go
Original file line number Diff line number Diff line change
@@ -1,155 +1,179 @@
package vegeta

import (
"fmt"
"encoding/json"
"html/template"
"io"
"strconv"
"strings"
"sort"
"time"

lttb "github.com/dgryski/go-lttb"
colorful "github.com/lucasb-eyer/go-colorful"
)

// An HTMLPlot represents an interactive HTML time series
// plot of Result latencies over time.
type HTMLPlot struct {
title string
threshold int
series map[string]*points
series []*htmlPlotSeries
}

type points struct {
attack string
began time.Time
ok []lttb.Point
err []lttb.Point
type htmlPlotSeries struct {
Attack string
Began time.Time
Ok []lttb.Point
Err []lttb.Point
}

// NewHTMLPlot returns an HTMLPlot with the given title,
// downsampling threshold and latency data points based on the
// given Results.
func NewHTMLPlot(title string, threshold int, rs Results) *HTMLPlot {
// group by Attack, each split in Error and OK series
series := map[string]*points{}
return &HTMLPlot{
title: title,
threshold: threshold,
series: newHTMLPlotSeries(rs),
}
}

// newHTMLPlotSeries groups all Results by attack and then partions each
// attack's results into OK and Error data points to be plotted. The
// X axis is the relative time offset in seconds since the beginning of
// the corresponding attack. The Y axis is the latency in milliseconds.
func newHTMLPlotSeries(rs Results) []*htmlPlotSeries {
if !sort.IsSorted(rs) {
sort.Sort(rs)
}

groups := map[string]*htmlPlotSeries{}
for _, r := range rs {
s, ok := series[r.Attack]
s, ok := groups[r.Attack]
if !ok {
s = &points{attack: r.Attack, began: r.Timestamp}
series[r.Attack] = s
s = &htmlPlotSeries{Attack: r.Attack, Began: r.Timestamp}
groups[r.Attack] = s
}

point := lttb.Point{
X: r.Timestamp.Sub(s.began).Seconds(),
X: r.Timestamp.Sub(s.Began).Seconds(),
Y: r.Latency.Seconds() * 1000,
}

if r.Error == "" {
s.ok = append(s.ok, point)
s.Ok = append(s.Ok, point)
} else {
s.err = append(s.err, point)
s.Err = append(s.Err, point)
}
}

return &HTMLPlot{
title: title,
threshold: threshold,
series: series,
ss := make([]*htmlPlotSeries, 0, len(groups))
for _, s := range groups {
ss = append(ss, s)
}

sort.Slice(ss, func(x, y int) bool {
return ss[x].Began.Before(ss[y].Began)
})

return ss
}

// WriteTo writes the HTML plot to the give io.Writer.
func (p HTMLPlot) WriteTo(w io.Writer) (err error) {
_, err = fmt.Fprintf(w, plotsTemplateHead, p.title, asset(dygraphs), asset(html2canvas))
if err != nil {
return err
func (p HTMLPlot) WriteTo(w io.Writer) (n int64, err error) {
type chart struct {
Type string `json:"type"`
RenderTo string `json:"renderTo"`
}

const count = 2 // OK and Errors
i, offsets := 0, make(map[string]int, len(p.series))
for name := range p.series {
offsets[name] = 1 + i*count
i++
type title struct {
Text string `json:"text"`
}

const nan = "NaN"
type axis struct {
Type string `json:"type"`
Title title `json:"title"`
}

data := make([]string, 1+len(p.series)*count)
for attack, points := range p.series {
for idx, ps := range [2][]lttb.Point{points.err, points.ok} {
for i, p := range lttb.LTTB(ps, p.threshold) {
for j := range data {
data[j] = nan
}
type data struct {
Name string `json:"name"`
Data [][2]float64 `json:"data"`
}

offset := offsets[attack] + idx
data[0] = strconv.FormatFloat(p.X, 'f', -1, 32)
data[offset] = strconv.FormatFloat(p.Y, 'f', -1, 32)
type highChartOpts struct {
Chart chart `json:"chart"`
Title title `json:"title"`
XAxis axis `json:"xAxis"`
YAxis axis `json:"yAxis"`
Series []data `json:"series"`
}

s := "[" + strings.Join(data, ",") + "]"
type templateData struct {
Title string
HTML2CanvasJS string
HighChartOptsJSON string
}

if i < len(ps)-1 {
s += ","
}
opts := highChartOpts{
Chart: chart{Type: "line", RenderTo: "latencies"},
Title: title{Text: p.title},
XAxis: axis{Title: title{Text: "Time elapsed (s)"}},
YAxis: axis{
Title: title{Text: "Latency (ms)"},
Type: "logarithmic",
},
}

if _, err = io.WriteString(w, s); err != nil {
return err
}
labels := []string{"OK", "ERROR"}
for _, ss := range p.series {
for i, ps := range [][]lttb.Point{ss.Err, ss.Ok} {
s := data{Name: ss.Attack + ": " + labels[i]}
for _, p := range ps {
s.Data = append(s.Data, [2]float64{p.X, p.Y})
}
opts.Series = append(opts.Series, s)
}
}

labels := make([]string, len(data))
labels[0] = strconv.Quote("Seconds")

for attack, offset := range offsets {
labels[offset] = strconv.Quote(attack + " - ERR")
labels[offset+1] = strconv.Quote(attack + " - OK")
}

colors := make([]string, 0, len(labels)-1)
palette, err := colorful.HappyPalette(cap(colors))
bs, err := json.Marshal(&opts)
if err != nil {
return err
return 0, err
}

for _, color := range palette {
colors = append(colors, strconv.Quote(color.Hex()))
}
cw := countingWriter{w: w}
err = plotTemplate.Execute(&cw, &templateData{
Title: p.title,
HTML2CanvasJS: string(asset(html2canvas)),
HighChartOptsJSON: string(bs),
})

return cw.n, err
}

_, err = fmt.Fprintf(w, plotsTemplateTail, p.title, strings.Join(labels, ","), strings.Join(colors, ","))
return err
type countingWriter struct {
n int64
w io.Writer
}

const (
plotsTemplateHead = `<!doctype html>
func (cw *countingWriter) Write(p []byte) (int, error) {
n, err := cw.w.Write(p)
cw.n += int64(n)
return n, err
}

var plotTemplate = template.Must(template.New("plot").Parse(`
<!doctype html>
<html>
<head>
<title>%s</title>
<title>{{.Title}}</title>
<meta charset="utf-8">
</head>
<body>
<div id="latencies" style="font-family: Courier; width: 100%%; height: 600px"></div>
<button id="download">Download as PNG</button>
<script>%s</script>
<script>%s</script>
<script src="https://code.highcharts.com/highcharts.src.js"></script>
<script>{{.HTML2CanvasJS}}</script>
<script>
new Dygraph(
document.getElementById("latencies"),
[`
plotsTemplateTail = `],
{
title: '%s',
labels: [%s],
ylabel: 'Latency (ms)',
xlabel: 'Seconds elapsed',
colors: [%s],
showRoller: true,
legend: 'always',
logscale: true,
strokeWidth: 1.3
}
);
// Plotly.newPlot("latencies", data, layout);
Highcharts.chart(JSON.parse("{{.HighChartOptsJSON | js}}"));
document.getElementById("download").addEventListener("click", function(e) {
html2canvas(document.body, {background: "#fff"}).then(function(canvas) {
var url = canvas.toDataURL('image/png').replace(/^data:image\/[^;]/, 'data:application/octet-stream');
Expand All @@ -161,5 +185,4 @@ const (
});
</script>
</body>
</html>`
)
</html>`))
39 changes: 39 additions & 0 deletions lib/plot_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,47 @@ import (
"io/ioutil"
"testing"
"time"

lttb "github.com/dgryski/go-lttb"
"github.com/google/go-cmp/cmp"
)

func TestNewHTMLPlotSeries(t *testing.T) {
for _, tc := range []struct {
name string
in Results
out []*htmlPlotSeries
}{
{
name: "single attack",
in: Results{
{Attack: "foo", Timestamp: time.Unix(0, 0), Latency: time.Second},
{Attack: "foo", Timestamp: time.Unix(1, 0), Latency: time.Millisecond},
},
out: []*htmlPlotSeries{
{
Attack: "foo",
Began: time.Unix(0, 0),
Ok: []lttb.Point{
{X: 0, Y: 0},
{X: 1, Y: 0},
},
},
},
},
} {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()

diff := cmp.Diff(newHTMLPlotSeries(tc.in), tc.out)
if diff != "" {
t.Error("\n" + diff)
}
})
}
}

func BenchmarkHTMLPlot(b *testing.B) {
b.StopTimer()
// Build result set
Expand Down
3 changes: 2 additions & 1 deletion plot.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,5 +90,6 @@ decode:
}

plot := vegeta.NewHTMLPlot(title, threshold, rs)
return plot.WriteTo(out)
_, err = plot.WriteTo(out)
return err
}

0 comments on commit 4a4e7ba

Please sign in to comment.