charts.go 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. package main
  2. import (
  3. "bytes"
  4. "embed"
  5. "encoding/json"
  6. "fmt"
  7. "net"
  8. "os"
  9. "os/exec"
  10. "runtime"
  11. "strings"
  12. "text/template"
  13. "time"
  14. _ "embed"
  15. cors "github.com/AdhityaRamadhanus/fasthttpcors"
  16. "github.com/go-echarts/go-echarts/v2/charts"
  17. "github.com/go-echarts/go-echarts/v2/components"
  18. "github.com/go-echarts/go-echarts/v2/opts"
  19. "github.com/go-echarts/go-echarts/v2/templates"
  20. "github.com/valyala/fasthttp"
  21. )
  22. //go:embed echarts.min.js
  23. //go:embed jquery.min.js
  24. var assetsFS embed.FS
  25. var (
  26. assetsPath = "/echarts/statics/"
  27. apiPath = "/data/"
  28. latencyView = "latency"
  29. rpsView = "rps"
  30. codeView = "code"
  31. timeFormat = "15:04:05"
  32. refreshInterval = time.Second
  33. templateRegistry = map[string]string{
  34. rpsView: ViewTpl,
  35. latencyView: ViewTpl,
  36. codeView: CodeViewTpl,
  37. }
  38. )
  39. const (
  40. ViewTpl = `
  41. $(function () { setInterval({{ .ViewID }}_sync, {{ .Interval }}); });
  42. function {{ .ViewID }}_sync() {
  43. $.ajax({
  44. type: "GET",
  45. url: "{{ .APIPath }}{{ .Route }}",
  46. dataType: "json",
  47. success: function (result) {
  48. let opt = goecharts_{{ .ViewID }}.getOption();
  49. let x = opt.xAxis[0].data;
  50. x.push(result.time);
  51. opt.xAxis[0].data = x;
  52. for (let i = 0; i < result.values.length; i++) {
  53. let y = opt.series[i].data;
  54. y.push({ value: result.values[i] });
  55. opt.series[i].data = y;
  56. goecharts_{{ .ViewID }}.setOption(opt);
  57. }
  58. }
  59. });
  60. }`
  61. PageTpl = `
  62. {{- define "page" }}
  63. <!DOCTYPE html>
  64. <html>
  65. {{- template "header" . }}
  66. <body>
  67. <p align="center">🚀 <a href="https://github.com/six-ddc/plow"><b>Plow</b></a> %s</p>
  68. <style> .box { justify-content:center; display:flex; flex-wrap:wrap } </style>
  69. <div class="box"> {{- range .Charts }} {{ template "base" . }} {{- end }} </div>
  70. </body>
  71. </html>
  72. {{ end }}
  73. `
  74. CodeViewTpl = `
  75. $(function () { setInterval({{ .ViewID }}_sync, {{ .Interval }}); });
  76. function {{ .ViewID }}_sync() {
  77. $.ajax({
  78. type: "GET",
  79. url: "{{ .APIPath }}{{ .Route }}",
  80. dataType: "json",
  81. success: function (result) {
  82. let opt = goecharts_{{ .ViewID }}.getOption();
  83. let x = opt.xAxis[0].data;
  84. x.push(result.time);
  85. opt.xAxis[0].data = x;
  86. let nameAndSeriesMapping = {};
  87. for (let i = 0; i < opt.series.length; i++) {
  88. nameAndSeriesMapping[opt.series[i].name] = opt.series[i];
  89. }
  90. let code200Count = nameAndSeriesMapping['200'].data.length;
  91. let codes = result.values[0];
  92. if (codes === null){
  93. for (let key in nameAndSeriesMapping) {
  94. let series = nameAndSeriesMapping[key];
  95. series.data.push({value:null});
  96. }
  97. }else{
  98. if (!('200' in codes)) {
  99. codes['200'] = null;
  100. }
  101. for (let code in codes) {
  102. let count = codes[code];
  103. if (code in nameAndSeriesMapping){
  104. let series = nameAndSeriesMapping[code];
  105. series.data.push({value:count});
  106. }else{
  107. let data = [];
  108. for (let i = 0; i < code200Count; i++) {
  109. data.push[null];
  110. }
  111. var newSeries = {
  112. name: code,
  113. type: 'line',
  114. data: data
  115. };
  116. opt.series.push(newSeries);
  117. }
  118. }
  119. }
  120. goecharts_{{ .ViewID }}.setOption(opt);
  121. }
  122. });
  123. }`
  124. )
  125. func (c *Charts) genViewTemplate(vid, route string) string {
  126. tpl, err := template.New("view").Parse(templateRegistry[route])
  127. if err != nil {
  128. panic("failed to parse template " + err.Error())
  129. }
  130. var d = struct {
  131. Interval int
  132. APIPath string
  133. Route string
  134. ViewID string
  135. }{
  136. Interval: int(refreshInterval.Milliseconds()),
  137. APIPath: apiPath,
  138. Route: route,
  139. ViewID: vid,
  140. }
  141. buf := bytes.Buffer{}
  142. if err := tpl.Execute(&buf, d); err != nil {
  143. panic("failed to execute template " + err.Error())
  144. }
  145. return buf.String()
  146. }
  147. func (c *Charts) newBasicView(route string) *charts.Line {
  148. graph := charts.NewLine()
  149. graph.SetGlobalOptions(
  150. charts.WithTooltipOpts(opts.Tooltip{Show: true, Trigger: "axis"}),
  151. charts.WithXAxisOpts(opts.XAxis{Name: "Time"}),
  152. charts.WithInitializationOpts(opts.Initialization{
  153. Width: "700px",
  154. Height: "400px",
  155. }),
  156. charts.WithDataZoomOpts(opts.DataZoom{
  157. Type: "slider",
  158. XAxisIndex: []int{0},
  159. }),
  160. )
  161. graph.SetXAxis([]string{}).SetSeriesOptions(charts.WithLineChartOpts(opts.LineChart{Smooth: true}))
  162. graph.AddJSFuncs(c.genViewTemplate(graph.ChartID, route))
  163. return graph
  164. }
  165. func (c *Charts) newLatencyView() components.Charter {
  166. graph := c.newBasicView(latencyView)
  167. graph.SetGlobalOptions(
  168. charts.WithTitleOpts(opts.Title{Title: "Latency"}),
  169. charts.WithYAxisOpts(opts.YAxis{Scale: true, AxisLabel: &opts.AxisLabel{Formatter: "{value} ms"}}),
  170. charts.WithLegendOpts(opts.Legend{Show: true, Selected: map[string]bool{"Min": false, "Max": false}}),
  171. )
  172. graph.AddSeries("Min", []opts.LineData{}).
  173. AddSeries("Mean", []opts.LineData{}).
  174. AddSeries("Max", []opts.LineData{})
  175. return graph
  176. }
  177. func (c *Charts) newRPSView() components.Charter {
  178. graph := c.newBasicView(rpsView)
  179. graph.SetGlobalOptions(
  180. charts.WithTitleOpts(opts.Title{Title: "Reqs/sec"}),
  181. charts.WithYAxisOpts(opts.YAxis{Scale: true}),
  182. )
  183. graph.AddSeries("RPS", []opts.LineData{})
  184. return graph
  185. }
  186. func (c *Charts) newCodeView() components.Charter {
  187. graph := c.newBasicView(codeView)
  188. graph.SetGlobalOptions(
  189. charts.WithTitleOpts(opts.Title{Title: "Response Status"}),
  190. charts.WithYAxisOpts(opts.YAxis{Scale: true}),
  191. charts.WithLegendOpts(opts.Legend{Show: true}),
  192. )
  193. graph.AddSeries("200", []opts.LineData{})
  194. return graph
  195. }
  196. type Metrics struct {
  197. Values []interface{} `json:"values"`
  198. Time string `json:"time"`
  199. }
  200. type Charts struct {
  201. page *components.Page
  202. ln net.Listener
  203. dataFunc func() *ChartsReport
  204. }
  205. func NewCharts(ln net.Listener, dataFunc func() *ChartsReport, desc string) (*Charts, error) {
  206. templates.PageTpl = fmt.Sprintf(PageTpl, desc)
  207. c := &Charts{ln: ln, dataFunc: dataFunc}
  208. c.page = components.NewPage()
  209. c.page.PageTitle = "plow"
  210. c.page.AssetsHost = assetsPath
  211. c.page.Assets.JSAssets.Add("jquery.min.js")
  212. c.page.AddCharts(c.newLatencyView(), c.newRPSView(), c.newCodeView())
  213. return c, nil
  214. }
  215. func (c *Charts) Handler(ctx *fasthttp.RequestCtx) {
  216. path := string(ctx.Path())
  217. if strings.HasPrefix(path, apiPath) {
  218. view := path[len(apiPath):]
  219. var values []interface{}
  220. reportData := c.dataFunc()
  221. switch view {
  222. case latencyView:
  223. if reportData != nil {
  224. values = append(values, reportData.Latency.min/1e6)
  225. values = append(values, reportData.Latency.Mean()/1e6)
  226. values = append(values, reportData.Latency.max/1e6)
  227. } else {
  228. values = append(values, nil, nil, nil)
  229. }
  230. case rpsView:
  231. if reportData != nil {
  232. values = append(values, reportData.RPS)
  233. } else {
  234. values = append(values, nil)
  235. }
  236. case codeView:
  237. if reportData != nil {
  238. values = append(values, reportData.CodeMap)
  239. } else {
  240. values = append(values, nil)
  241. }
  242. }
  243. metrics := &Metrics{
  244. Time: time.Now().Format(timeFormat),
  245. Values: values,
  246. }
  247. _ = json.NewEncoder(ctx).Encode(metrics)
  248. } else if path == "/" {
  249. ctx.SetContentType("text/html")
  250. _ = c.page.Render(ctx)
  251. } else if strings.HasPrefix(path, assetsPath) {
  252. ap := path[len(assetsPath):]
  253. f, err := assetsFS.Open(ap)
  254. if err != nil {
  255. ctx.Error(err.Error(), 404)
  256. } else {
  257. ctx.SetBodyStream(f, -1)
  258. }
  259. } else {
  260. ctx.Error("NotFound", fasthttp.StatusNotFound)
  261. }
  262. }
  263. func (c *Charts) Serve(open bool) {
  264. server := fasthttp.Server{
  265. Handler: cors.DefaultHandler().CorsMiddleware(c.Handler),
  266. }
  267. if open {
  268. go openBrowser("http://" + c.ln.Addr().String())
  269. }
  270. _ = server.Serve(c.ln)
  271. }
  272. // openBrowser go/src/cmd/internal/browser/browser.go
  273. func openBrowser(url string) bool {
  274. var cmds [][]string
  275. if exe := os.Getenv("BROWSER"); exe != "" {
  276. cmds = append(cmds, []string{exe})
  277. }
  278. switch runtime.GOOS {
  279. case "darwin":
  280. cmds = append(cmds, []string{"/usr/bin/open"})
  281. case "windows":
  282. cmds = append(cmds, []string{"cmd", "/c", "start"})
  283. default:
  284. if os.Getenv("DISPLAY") != "" {
  285. // xdg-open is only for use in a desktop environment.
  286. cmds = append(cmds, []string{"xdg-open"})
  287. }
  288. }
  289. cmds = append(cmds,
  290. []string{"chrome"},
  291. []string{"google-chrome"},
  292. []string{"chromium"},
  293. []string{"firefox"},
  294. )
  295. for _, args := range cmds {
  296. cmd := exec.Command(args[0], append(args[1:], url)...)
  297. if cmd.Start() == nil && appearsSuccessful(cmd, 3*time.Second) {
  298. return true
  299. }
  300. }
  301. return false
  302. }
  303. // appearsSuccessful reports whether the command appears to have run successfully.
  304. // If the command runs longer than the timeout, it's deemed successful.
  305. // If the command runs within the timeout, it's deemed successful if it exited cleanly.
  306. func appearsSuccessful(cmd *exec.Cmd, timeout time.Duration) bool {
  307. errc := make(chan error, 1)
  308. go func() {
  309. errc <- cmd.Wait()
  310. }()
  311. select {
  312. case <-time.After(timeout):
  313. return true
  314. case err := <-errc:
  315. return err == nil
  316. }
  317. }