diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..2048d4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2020 Drew DeVault + +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/README.md b/README.md new file mode 100644 index 0000000..4d8f80e --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +# chartsrv + +chartsrv is a dead-simple web application which runs [Prometheus][0] queries and +charts the result as an SVG. + +[0]: https://prometheus.io/ + +## Running the daemon + +``` +$ go build -o chartsrv main.go +$ ./chartsrv https://prometheus.example.org +Listening on :8142 +``` + +Forward `/chart.svg` to this address with your favorite reverse proxy. + +## Usage + +Create a URL like `https://chartsrv.example.org/chart.svg?query=...&args...` and +set the query parameters as appropriate: + +- **query**: required. Prometheus query to execute. +- **title**: chart title +- **stacked**: set to create an area chart instead of a line chart +- **since**: [time.ParseDuration][1] to set distance in the past to start + charting from +- **width**: chart width in inches +- **height**: chart height in inches +- **step**: number of seconds between data points +- **max**: maximum Y value + +[1]: https://golang.org/pkg/time/#ParseDuration diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..04ecdc6 --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module git.sr.ht/~sircmpwn/promsvg + +go 1.15 + +require ( + github.com/go-chi/chi v4.1.2+incompatible // indirect + gonum.org/v1/plot v0.8.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..817203a --- /dev/null +++ b/go.sum @@ -0,0 +1,63 @@ +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +gioui.org v0.0.0-20200628203458-851255f7a67b/go.mod h1:jiUwifN9cRl/zmco43aAqh0aV+s9GbhG13KcD+gEpkU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af h1:wVe6/Ea46ZMeNkQjjBW6xcqyQA/j5e0D6GytH95g0gQ= +github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw= +github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-chi/chi v1.0.0 h1:s/kv1cTXfivYjdKJdyUzNGyAWZ/2t7duW1gKn5ivu+c= +github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-latex/latex v0.0.0-20200518072620-0806b477ea35 h1:uroDDLmuCK5Pz5J/Ef5vCL6F0sJmAtZFTm0/cF027F4= +github.com/go-latex/latex v0.0.0-20200518072620-0806b477ea35/go.mod h1:PNI+CcWytn/2Z/9f1SGOOYn0eILruVyp0v2/iAs8asQ= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes= +github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc= +github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0= +github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= +golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= +golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo= +gonum.org/v1/gonum v0.8.1/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0= +gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw= +gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc= +gonum.org/v1/plot v0.8.0 h1:dNgubmltsMoehfn6XgbutHpicbUfbkcGSxkICy1bC4o= +gonum.org/v1/plot v0.8.0/go.mod h1:3GH8dTfoceRTELDnv+4HNwbvM/eMfdDUGHFG2bo3NeE= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/main.go b/main.go new file mode 100644 index 0000000..3ab820e --- /dev/null +++ b/main.go @@ -0,0 +1,272 @@ +package main + +import ( + "encoding/json" + "fmt" + "image/color" + "log" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/go-chi/chi" + "github.com/go-chi/chi/middleware" + "gonum.org/v1/plot" + "gonum.org/v1/plot/plotter" + "gonum.org/v1/plot/plotutil" + "gonum.org/v1/plot/vg" +) + +var ( + upstream string +) + +type PromResponse struct { + Status string `json:"status"` + Data struct { + ResultType string `json:"resultType"` + Result []struct { + Metric map[string]string `json:"metric"` + Values [][]interface{} `json:"values"` + } `json:"result"` + } `json:"data"` +} + +type Datapoint struct { + Time time.Time + Value float64 +} + +type PromResult struct { + Metric string + Values []Datapoint +} + +func Query(q string, start time.Time, end time.Time, step int) ([]PromResult, error) { + body := url.Values{} + body.Set("query", q) + body.Set("start", fmt.Sprintf("%d", start.Unix())) + body.Set("end", fmt.Sprintf("%d", end.Unix())) + body.Set("step", fmt.Sprintf("%d", step)) + resp, err := http.Post(fmt.Sprintf("%s/api/v1/query_range", upstream), + "application/x-www-form-urlencoded", strings.NewReader(body.Encode())) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + return nil, fmt.Errorf("Received %d response from upstream", resp.StatusCode) + } + + var data PromResponse + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return nil, err + } + + if data.Data.ResultType != "matrix" { + return nil, fmt.Errorf("result type isn't of type matrix: %s", + data.Data.ResultType) + } + + if len(data.Data.Result) == 0 { + return nil, fmt.Errorf("No data") + } + + var results []PromResult + for _, res := range data.Data.Result { + r := PromResult{} + r.Metric = metricName(res.Metric) + + var values []Datapoint + for _, vals := range res.Values { + timestamp := vals[0].(float64) + value := vals[1].(string) + fv, _ := strconv.ParseFloat(value, 64) + values = append(values, Datapoint{ + time.Unix(int64(timestamp), 0), + fv, + }) + } + r.Values = values + + results = append(results, r) + } + return results, nil +} + +func metricName(metric map[string]string) string { + if len(metric) == 0 { + return "{}" + } + + out := "" + var inner []string + for key, value := range metric { + if key == "__name__" { + out = value + continue + } + inner = append(inner, fmt.Sprintf(`%s="%s"`, key, value)) + } + + if len(inner) == 0 { + return out + } + + sort.Slice(inner, func(i, j int) bool { + return inner[i] < inner[j] + }) + + return out + "{" + strings.Join(inner, ",") + "}" +} + +func main() { + plotutil.DefaultDashes = [][]vg.Length{{}} + + upstream = os.Args[1] + router := chi.NewRouter() + router.Use(middleware.RealIP) + router.Use(middleware.Logger) + + router.Get("/chart.svg", func(w http.ResponseWriter, r *http.Request) { + args := r.URL.Query() + var query string + if q, ok := args["query"]; !ok { + w.WriteHeader(400) + w.Write([]byte("Expected ?query=... parameter")) + return + } else { + query = q[0] + } + + start := time.Now().Add(-24 * 60 * time.Minute) + end := time.Now() + if s, ok := args["since"]; ok { + d, _ := time.ParseDuration(s[0]) + start = time.Now().Add(-d) + } + + width := 12*vg.Inch + height := 6*vg.Inch + if ws, ok := args["width"]; ok { + w, _ := strconv.ParseFloat(ws[0], 32) + width = vg.Length(w)*vg.Inch + } + if hs, ok := args["height"]; ok { + h, _ := strconv.ParseFloat(hs[0], 32) + height = vg.Length(h)*vg.Inch + } + + // Set step so that there's approximately 25 data points per inch + step := int(end.Sub(start).Seconds() / (25 * float64(width / vg.Inch))) + if s, ok := args["step"]; ok { + d, _ := strconv.ParseInt(s[0], 10, 32) + step = int(d) + } + _, stacked := args["stacked"] + + data, err := Query(query, start, end, step) + if err != nil { + w.WriteHeader(400) + w.Write([]byte(fmt.Sprintf("%v", err))) + return + } + + p, err := plot.New() + if err != nil { + panic(err) + } + if t, ok := args["title"]; ok { + p.Title.Text = t[0] + } + p.X.Label.Text = "Time" + p.X.Tick.Marker = dateTicks{} + if ms, ok := args["max"]; ok { + m, _ := strconv.ParseFloat(ms[0], 64) + p.Y.Max = m + } + p.Legend.Top = true + + sums := make([]float64, len(data[0].Values)) + + plotters := make([]plot.Plotter, len(data)) + var nextColor int + colors := plotutil.SoftColors + for i, res := range data { + var points plotter.XYs + for j, d := range res.Values { + value := d.Value + if stacked { + value += sums[j] + } + points = append(points, plotter.XY{ + float64(d.Time.Unix()), + value, + }) + sums[j] += d.Value + } + + l, _, err := plotter.NewLinePoints(points) + if err != nil { + w.WriteHeader(400) + w.Write([]byte(fmt.Sprintf("%v", err))) + return + } + if stacked { + l.FillColor = colors[nextColor] + if i != len(data) - 1 { + l.Color = color.RGBA{0, 0, 0, 0} + } + } else { + l.Color = colors[nextColor] + } + nextColor += 1 + if nextColor >= len(colors) { + nextColor = 0 + } + plotters[i] = l + p.Legend.Add(res.Metric, l) + } + for i := len(plotters) - 1; i >= 0; i-- { + p.Add(plotters[i]) + } + + writer, err := p.WriterTo(width, height, "svg") + if err != nil { + w.WriteHeader(400) + w.Write([]byte(fmt.Sprintf("%v", err))) + return + } + + w.Header().Add("Content-Type", "image/svg+xml") + writer.WriteTo(w) + }) + + addr := ":8142" + if len(os.Args) > 2 { + addr = os.Args[2] + } + log.Printf("Listening on %s", addr) + http.ListenAndServe(addr, router) +} + +type dateTicks struct{} +// Ticks computes the default tick marks, but inserts commas +// into the labels for the major tick marks. +func (dateTicks) Ticks(min, max float64) []plot.Tick { + tks := plot.DefaultTicks{}.Ticks(min, max) + for i, t := range tks { + if t.Label == "" { // Skip minor ticks, they are fine. + continue + } + d, _ := strconv.ParseFloat(t.Label, 64) + tm := time.Unix(int64(d), 0) + tks[i].Label = tm.Format("15:04:05") + } + return tks +}