123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347 |
- package main
- import (
- "bytes"
- "embed"
- "encoding/json"
- "fmt"
- "net"
- "os"
- "os/exec"
- "runtime"
- "strings"
- "text/template"
- "time"
- _ "embed"
- cors "github.com/AdhityaRamadhanus/fasthttpcors"
- "github.com/go-echarts/go-echarts/v2/charts"
- "github.com/go-echarts/go-echarts/v2/components"
- "github.com/go-echarts/go-echarts/v2/opts"
- "github.com/go-echarts/go-echarts/v2/templates"
- "github.com/valyala/fasthttp"
- )
- //go:embed echarts.min.js
- //go:embed jquery.min.js
- var assetsFS embed.FS
- var (
- assetsPath = "/echarts/statics/"
- apiPath = "/data/"
- latencyView = "latency"
- rpsView = "rps"
- codeView = "code"
- timeFormat = "15:04:05"
- refreshInterval = time.Second
- templateRegistry = map[string]string{
- rpsView: ViewTpl,
- latencyView: ViewTpl,
- codeView: CodeViewTpl,
- }
- )
- const (
- ViewTpl = `
- $(function () { setInterval({{ .ViewID }}_sync, {{ .Interval }}); });
- function {{ .ViewID }}_sync() {
- $.ajax({
- type: "GET",
- url: "{{ .APIPath }}{{ .Route }}",
- dataType: "json",
- success: function (result) {
- let opt = goecharts_{{ .ViewID }}.getOption();
- let x = opt.xAxis[0].data;
- x.push(result.time);
- opt.xAxis[0].data = x;
- for (let i = 0; i < result.values.length; i++) {
- let y = opt.series[i].data;
- y.push({ value: result.values[i] });
- opt.series[i].data = y;
- goecharts_{{ .ViewID }}.setOption(opt);
- }
- }
- });
- }`
- PageTpl = `
- {{- define "page" }}
- <!DOCTYPE html>
- <html>
- {{- template "header" . }}
- <body>
- <p align="center">🚀 <a href="https://github.com/six-ddc/plow"><b>Plow</b></a> %s</p>
- <style> .box { justify-content:center; display:flex; flex-wrap:wrap } </style>
- <div class="box"> {{- range .Charts }} {{ template "base" . }} {{- end }} </div>
- </body>
- </html>
- {{ end }}
- `
- CodeViewTpl = `
- $(function () { setInterval({{ .ViewID }}_sync, {{ .Interval }}); });
- function {{ .ViewID }}_sync() {
- $.ajax({
- type: "GET",
- url: "{{ .APIPath }}{{ .Route }}",
- dataType: "json",
- success: function (result) {
- let opt = goecharts_{{ .ViewID }}.getOption();
- let x = opt.xAxis[0].data;
- x.push(result.time);
- opt.xAxis[0].data = x;
-
- let nameAndSeriesMapping = {};
- for (let i = 0; i < opt.series.length; i++) {
- nameAndSeriesMapping[opt.series[i].name] = opt.series[i];
- }
-
- let code200Count = nameAndSeriesMapping['200'].data.length;
-
- let codes = result.values[0];
- if (codes === null){
- for (let key in nameAndSeriesMapping) {
- let series = nameAndSeriesMapping[key];
- series.data.push({value:null});
- }
- }else{
- if (!('200' in codes)) {
- codes['200'] = null;
- }
-
- for (let code in codes) {
- let count = codes[code];
- if (code in nameAndSeriesMapping){
- let series = nameAndSeriesMapping[code];
- series.data.push({value:count});
- }else{
- let data = [];
- for (let i = 0; i < code200Count; i++) {
- data.push[null];
- }
- var newSeries = {
- name: code,
- type: 'line',
- data: data
- };
- opt.series.push(newSeries);
- }
- }
- }
-
- goecharts_{{ .ViewID }}.setOption(opt);
- }
- });
- }`
- )
- func (c *Charts) genViewTemplate(vid, route string) string {
- tpl, err := template.New("view").Parse(templateRegistry[route])
- if err != nil {
- panic("failed to parse template " + err.Error())
- }
- var d = struct {
- Interval int
- APIPath string
- Route string
- ViewID string
- }{
- Interval: int(refreshInterval.Milliseconds()),
- APIPath: apiPath,
- Route: route,
- ViewID: vid,
- }
- buf := bytes.Buffer{}
- if err := tpl.Execute(&buf, d); err != nil {
- panic("failed to execute template " + err.Error())
- }
- return buf.String()
- }
- func (c *Charts) newBasicView(route string) *charts.Line {
- graph := charts.NewLine()
- graph.SetGlobalOptions(
- charts.WithTooltipOpts(opts.Tooltip{Show: true, Trigger: "axis"}),
- charts.WithXAxisOpts(opts.XAxis{Name: "Time"}),
- charts.WithInitializationOpts(opts.Initialization{
- Width: "700px",
- Height: "400px",
- }),
- charts.WithDataZoomOpts(opts.DataZoom{
- Type: "slider",
- XAxisIndex: []int{0},
- }),
- )
- graph.SetXAxis([]string{}).SetSeriesOptions(charts.WithLineChartOpts(opts.LineChart{Smooth: true}))
- graph.AddJSFuncs(c.genViewTemplate(graph.ChartID, route))
- return graph
- }
- func (c *Charts) newLatencyView() components.Charter {
- graph := c.newBasicView(latencyView)
- graph.SetGlobalOptions(
- charts.WithTitleOpts(opts.Title{Title: "Latency"}),
- charts.WithYAxisOpts(opts.YAxis{Scale: true, AxisLabel: &opts.AxisLabel{Formatter: "{value} ms"}}),
- charts.WithLegendOpts(opts.Legend{Show: true, Selected: map[string]bool{"Min": false, "Max": false}}),
- )
- graph.AddSeries("Min", []opts.LineData{}).
- AddSeries("Mean", []opts.LineData{}).
- AddSeries("Max", []opts.LineData{})
- return graph
- }
- func (c *Charts) newRPSView() components.Charter {
- graph := c.newBasicView(rpsView)
- graph.SetGlobalOptions(
- charts.WithTitleOpts(opts.Title{Title: "Reqs/sec"}),
- charts.WithYAxisOpts(opts.YAxis{Scale: true}),
- )
- graph.AddSeries("RPS", []opts.LineData{})
- return graph
- }
- func (c *Charts) newCodeView() components.Charter {
- graph := c.newBasicView(codeView)
- graph.SetGlobalOptions(
- charts.WithTitleOpts(opts.Title{Title: "Response Status"}),
- charts.WithYAxisOpts(opts.YAxis{Scale: true}),
- charts.WithLegendOpts(opts.Legend{Show: true}),
- )
- graph.AddSeries("200", []opts.LineData{})
- return graph
- }
- type Metrics struct {
- Values []interface{} `json:"values"`
- Time string `json:"time"`
- }
- type Charts struct {
- page *components.Page
- ln net.Listener
- dataFunc func() *ChartsReport
- }
- func NewCharts(ln net.Listener, dataFunc func() *ChartsReport, desc string) (*Charts, error) {
- templates.PageTpl = fmt.Sprintf(PageTpl, desc)
- c := &Charts{ln: ln, dataFunc: dataFunc}
- c.page = components.NewPage()
- c.page.PageTitle = "plow"
- c.page.AssetsHost = assetsPath
- c.page.Assets.JSAssets.Add("jquery.min.js")
- c.page.AddCharts(c.newLatencyView(), c.newRPSView(), c.newCodeView())
- return c, nil
- }
- func (c *Charts) Handler(ctx *fasthttp.RequestCtx) {
- path := string(ctx.Path())
- if strings.HasPrefix(path, apiPath) {
- view := path[len(apiPath):]
- var values []interface{}
- reportData := c.dataFunc()
- switch view {
- case latencyView:
- if reportData != nil {
- values = append(values, reportData.Latency.min/1e6)
- values = append(values, reportData.Latency.Mean()/1e6)
- values = append(values, reportData.Latency.max/1e6)
- } else {
- values = append(values, nil, nil, nil)
- }
- case rpsView:
- if reportData != nil {
- values = append(values, reportData.RPS)
- } else {
- values = append(values, nil)
- }
- case codeView:
- if reportData != nil {
- values = append(values, reportData.CodeMap)
- } else {
- values = append(values, nil)
- }
- }
- metrics := &Metrics{
- Time: time.Now().Format(timeFormat),
- Values: values,
- }
- _ = json.NewEncoder(ctx).Encode(metrics)
- } else if path == "/" {
- ctx.SetContentType("text/html")
- _ = c.page.Render(ctx)
- } else if strings.HasPrefix(path, assetsPath) {
- ap := path[len(assetsPath):]
- f, err := assetsFS.Open(ap)
- if err != nil {
- ctx.Error(err.Error(), 404)
- } else {
- ctx.SetBodyStream(f, -1)
- }
- } else {
- ctx.Error("NotFound", fasthttp.StatusNotFound)
- }
- }
- func (c *Charts) Serve(open bool) {
- server := fasthttp.Server{
- Handler: cors.DefaultHandler().CorsMiddleware(c.Handler),
- }
- if open {
- go openBrowser("http://" + c.ln.Addr().String())
- }
- _ = server.Serve(c.ln)
- }
- // openBrowser go/src/cmd/internal/browser/browser.go
- func openBrowser(url string) bool {
- var cmds [][]string
- if exe := os.Getenv("BROWSER"); exe != "" {
- cmds = append(cmds, []string{exe})
- }
- switch runtime.GOOS {
- case "darwin":
- cmds = append(cmds, []string{"/usr/bin/open"})
- case "windows":
- cmds = append(cmds, []string{"cmd", "/c", "start"})
- default:
- if os.Getenv("DISPLAY") != "" {
- // xdg-open is only for use in a desktop environment.
- cmds = append(cmds, []string{"xdg-open"})
- }
- }
- cmds = append(cmds,
- []string{"chrome"},
- []string{"google-chrome"},
- []string{"chromium"},
- []string{"firefox"},
- )
- for _, args := range cmds {
- cmd := exec.Command(args[0], append(args[1:], url)...)
- if cmd.Start() == nil && appearsSuccessful(cmd, 3*time.Second) {
- return true
- }
- }
- return false
- }
- // appearsSuccessful reports whether the command appears to have run successfully.
- // If the command runs longer than the timeout, it's deemed successful.
- // If the command runs within the timeout, it's deemed successful if it exited cleanly.
- func appearsSuccessful(cmd *exec.Cmd, timeout time.Duration) bool {
- errc := make(chan error, 1)
- go func() {
- errc <- cmd.Wait()
- }()
- select {
- case <-time.After(timeout):
- return true
- case err := <-errc:
- return err == nil
- }
- }
|