Commit
Author: Drew DeVault [sir@cmpwn.com]
Hash: a2562d79bf988de8cf3f6e3a977308aaf5989c7f
Timestamp: Fri, 18 Sep 2020 19:02:31 +0000 (4 years ago)

+395 -0 +/-5 browse
Initial commit
1diff --git a/LICENSE b/LICENSE
2new file mode 100644
3index 0000000..2048d4d
4--- /dev/null
5+++ b/LICENSE
6 @@ -0,0 +1,19 @@
7+ Copyright (c) 2020 Drew DeVault
8+
9+ Permission is hereby granted, free of charge, to any person obtaining a copy of
10+ this software and associated documentation files (the "Software"), to deal in
11+ the Software without restriction, including without limitation the rights to
12+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
13+ of the Software, and to permit persons to whom the Software is furnished to do
14+ so, subject to the following conditions:
15+
16+ The above copyright notice and this permission notice shall be included in all
17+ copies or substantial portions of the Software.
18+
19+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25+ SOFTWARE.
26 diff --git a/README.md b/README.md
27new file mode 100644
28index 0000000..4d8f80e
29--- /dev/null
30+++ b/README.md
31 @@ -0,0 +1,33 @@
32+ # chartsrv
33+
34+ chartsrv is a dead-simple web application which runs [Prometheus][0] queries and
35+ charts the result as an SVG.
36+
37+ [0]: https://prometheus.io/
38+
39+ ## Running the daemon
40+
41+ ```
42+ $ go build -o chartsrv main.go
43+ $ ./chartsrv https://prometheus.example.org
44+ Listening on :8142
45+ ```
46+
47+ Forward `/chart.svg` to this address with your favorite reverse proxy.
48+
49+ ## Usage
50+
51+ Create a URL like `https://chartsrv.example.org/chart.svg?query=...&args...` and
52+ set the query parameters as appropriate:
53+
54+ - **query**: required. Prometheus query to execute.
55+ - **title**: chart title
56+ - **stacked**: set to create an area chart instead of a line chart
57+ - **since**: [time.ParseDuration][1] to set distance in the past to start
58+ charting from
59+ - **width**: chart width in inches
60+ - **height**: chart height in inches
61+ - **step**: number of seconds between data points
62+ - **max**: maximum Y value
63+
64+ [1]: https://golang.org/pkg/time/#ParseDuration
65 diff --git a/go.mod b/go.mod
66new file mode 100644
67index 0000000..04ecdc6
68--- /dev/null
69+++ b/go.mod
70 @@ -0,0 +1,8 @@
71+ module git.sr.ht/~sircmpwn/promsvg
72+
73+ go 1.15
74+
75+ require (
76+ github.com/go-chi/chi v4.1.2+incompatible // indirect
77+ gonum.org/v1/plot v0.8.0 // indirect
78+ )
79 diff --git a/go.sum b/go.sum
80new file mode 100644
81index 0000000..817203a
82--- /dev/null
83+++ b/go.sum
84 @@ -0,0 +1,63 @@
85+ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
86+ gioui.org v0.0.0-20200628203458-851255f7a67b/go.mod h1:jiUwifN9cRl/zmco43aAqh0aV+s9GbhG13KcD+gEpkU=
87+ github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
88+ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af h1:wVe6/Ea46ZMeNkQjjBW6xcqyQA/j5e0D6GytH95g0gQ=
89+ github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
90+ github.com/boombuler/barcode v1.0.0/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
91+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
92+ github.com/fogleman/gg v1.2.1-0.20190220221249-0403632d5b90/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
93+ github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
94+ github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
95+ github.com/go-chi/chi v1.0.0 h1:s/kv1cTXfivYjdKJdyUzNGyAWZ/2t7duW1gKn5ivu+c=
96+ github.com/go-chi/chi v4.1.2+incompatible h1:fGFk2Gmi/YKXk0OmGfBh0WgmN3XB8lVnEyNz34tQRec=
97+ github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ=
98+ github.com/go-fonts/dejavu v0.1.0/go.mod h1:4Wt4I4OU2Nq9asgDCteaAaWZOV24E+0/Pwo0gppep4g=
99+ github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
100+ github.com/go-latex/latex v0.0.0-20200518072620-0806b477ea35 h1:uroDDLmuCK5Pz5J/Ef5vCL6F0sJmAtZFTm0/cF027F4=
101+ github.com/go-latex/latex v0.0.0-20200518072620-0806b477ea35/go.mod h1:PNI+CcWytn/2Z/9f1SGOOYn0eILruVyp0v2/iAs8asQ=
102+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
103+ github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
104+ github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
105+ github.com/jung-kurt/gofpdf v1.0.3-0.20190309125859-24315acbbda5/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
106+ github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
107+ github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
108+ github.com/phpdave11/gofpdi v1.0.7/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI=
109+ github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
110+ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
111+ github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
112+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
113+ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
114+ golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
115+ golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
116+ golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
117+ golang.org/x/exp v0.0.0-20190125153040-c74c464bbbf2/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
118+ golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
119+ golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
120+ golang.org/x/image v0.0.0-20180708004352-c73c2afc3b81/go.mod h1:ux5Hcp/YLpHSI86hEcLt0YII63i6oz57MZXIpbrjZUs=
121+ golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
122+ golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
123+ golang.org/x/image v0.0.0-20190910094157-69e4b8554b2a/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
124+ golang.org/x/image v0.0.0-20200430140353-33d19683fad8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
125+ golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=
126+ golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
127+ golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
128+ golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
129+ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
130+ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
131+ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
132+ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
133+ golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
134+ golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
135+ golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
136+ golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
137+ golang.org/x/tools v0.0.0-20180525024113-a5b4c53f6e8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
138+ golang.org/x/tools v0.0.0-20190206041539-40960b6deb8e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
139+ golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
140+ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
141+ gonum.org/v1/gonum v0.0.0-20180816165407-929014505bf4/go.mod h1:Y+Yx5eoAFn32cQvJDxZx5Dpnq+c3wtXuadVZAcxbbBo=
142+ gonum.org/v1/gonum v0.8.1/go.mod h1:oe/vMfY3deqTw+1EZJhuvEW2iwGF1bW9wwu7XCu0+v0=
143+ gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0/go.mod h1:wa6Ws7BG/ESfp6dHfk7C6KdzKA7wR7u/rKwOGE66zvw=
144+ gonum.org/v1/plot v0.0.0-20190515093506-e2840ee46a6b/go.mod h1:Wt8AAjI+ypCyYX3nZBvf6cAIx93T+c/OS2HFAYskSZc=
145+ gonum.org/v1/plot v0.8.0 h1:dNgubmltsMoehfn6XgbutHpicbUfbkcGSxkICy1bC4o=
146+ gonum.org/v1/plot v0.8.0/go.mod h1:3GH8dTfoceRTELDnv+4HNwbvM/eMfdDUGHFG2bo3NeE=
147+ rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
148 diff --git a/main.go b/main.go
149new file mode 100644
150index 0000000..3ab820e
151--- /dev/null
152+++ b/main.go
153 @@ -0,0 +1,272 @@
154+ package main
155+
156+ import (
157+ "encoding/json"
158+ "fmt"
159+ "image/color"
160+ "log"
161+ "net/http"
162+ "net/url"
163+ "os"
164+ "sort"
165+ "strconv"
166+ "strings"
167+ "time"
168+
169+ "github.com/go-chi/chi"
170+ "github.com/go-chi/chi/middleware"
171+ "gonum.org/v1/plot"
172+ "gonum.org/v1/plot/plotter"
173+ "gonum.org/v1/plot/plotutil"
174+ "gonum.org/v1/plot/vg"
175+ )
176+
177+ var (
178+ upstream string
179+ )
180+
181+ type PromResponse struct {
182+ Status string `json:"status"`
183+ Data struct {
184+ ResultType string `json:"resultType"`
185+ Result []struct {
186+ Metric map[string]string `json:"metric"`
187+ Values [][]interface{} `json:"values"`
188+ } `json:"result"`
189+ } `json:"data"`
190+ }
191+
192+ type Datapoint struct {
193+ Time time.Time
194+ Value float64
195+ }
196+
197+ type PromResult struct {
198+ Metric string
199+ Values []Datapoint
200+ }
201+
202+ func Query(q string, start time.Time, end time.Time, step int) ([]PromResult, error) {
203+ body := url.Values{}
204+ body.Set("query", q)
205+ body.Set("start", fmt.Sprintf("%d", start.Unix()))
206+ body.Set("end", fmt.Sprintf("%d", end.Unix()))
207+ body.Set("step", fmt.Sprintf("%d", step))
208+ resp, err := http.Post(fmt.Sprintf("%s/api/v1/query_range", upstream),
209+ "application/x-www-form-urlencoded", strings.NewReader(body.Encode()))
210+ if err != nil {
211+ return nil, err
212+ }
213+ defer resp.Body.Close()
214+
215+ if resp.StatusCode != 200 {
216+ return nil, fmt.Errorf("Received %d response from upstream", resp.StatusCode)
217+ }
218+
219+ var data PromResponse
220+ if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
221+ return nil, err
222+ }
223+
224+ if data.Data.ResultType != "matrix" {
225+ return nil, fmt.Errorf("result type isn't of type matrix: %s",
226+ data.Data.ResultType)
227+ }
228+
229+ if len(data.Data.Result) == 0 {
230+ return nil, fmt.Errorf("No data")
231+ }
232+
233+ var results []PromResult
234+ for _, res := range data.Data.Result {
235+ r := PromResult{}
236+ r.Metric = metricName(res.Metric)
237+
238+ var values []Datapoint
239+ for _, vals := range res.Values {
240+ timestamp := vals[0].(float64)
241+ value := vals[1].(string)
242+ fv, _ := strconv.ParseFloat(value, 64)
243+ values = append(values, Datapoint{
244+ time.Unix(int64(timestamp), 0),
245+ fv,
246+ })
247+ }
248+ r.Values = values
249+
250+ results = append(results, r)
251+ }
252+ return results, nil
253+ }
254+
255+ func metricName(metric map[string]string) string {
256+ if len(metric) == 0 {
257+ return "{}"
258+ }
259+
260+ out := ""
261+ var inner []string
262+ for key, value := range metric {
263+ if key == "__name__" {
264+ out = value
265+ continue
266+ }
267+ inner = append(inner, fmt.Sprintf(`%s="%s"`, key, value))
268+ }
269+
270+ if len(inner) == 0 {
271+ return out
272+ }
273+
274+ sort.Slice(inner, func(i, j int) bool {
275+ return inner[i] < inner[j]
276+ })
277+
278+ return out + "{" + strings.Join(inner, ",") + "}"
279+ }
280+
281+ func main() {
282+ plotutil.DefaultDashes = [][]vg.Length{{}}
283+
284+ upstream = os.Args[1]
285+ router := chi.NewRouter()
286+ router.Use(middleware.RealIP)
287+ router.Use(middleware.Logger)
288+
289+ router.Get("/chart.svg", func(w http.ResponseWriter, r *http.Request) {
290+ args := r.URL.Query()
291+ var query string
292+ if q, ok := args["query"]; !ok {
293+ w.WriteHeader(400)
294+ w.Write([]byte("Expected ?query=... parameter"))
295+ return
296+ } else {
297+ query = q[0]
298+ }
299+
300+ start := time.Now().Add(-24 * 60 * time.Minute)
301+ end := time.Now()
302+ if s, ok := args["since"]; ok {
303+ d, _ := time.ParseDuration(s[0])
304+ start = time.Now().Add(-d)
305+ }
306+
307+ width := 12*vg.Inch
308+ height := 6*vg.Inch
309+ if ws, ok := args["width"]; ok {
310+ w, _ := strconv.ParseFloat(ws[0], 32)
311+ width = vg.Length(w)*vg.Inch
312+ }
313+ if hs, ok := args["height"]; ok {
314+ h, _ := strconv.ParseFloat(hs[0], 32)
315+ height = vg.Length(h)*vg.Inch
316+ }
317+
318+ // Set step so that there's approximately 25 data points per inch
319+ step := int(end.Sub(start).Seconds() / (25 * float64(width / vg.Inch)))
320+ if s, ok := args["step"]; ok {
321+ d, _ := strconv.ParseInt(s[0], 10, 32)
322+ step = int(d)
323+ }
324+ _, stacked := args["stacked"]
325+
326+ data, err := Query(query, start, end, step)
327+ if err != nil {
328+ w.WriteHeader(400)
329+ w.Write([]byte(fmt.Sprintf("%v", err)))
330+ return
331+ }
332+
333+ p, err := plot.New()
334+ if err != nil {
335+ panic(err)
336+ }
337+ if t, ok := args["title"]; ok {
338+ p.Title.Text = t[0]
339+ }
340+ p.X.Label.Text = "Time"
341+ p.X.Tick.Marker = dateTicks{}
342+ if ms, ok := args["max"]; ok {
343+ m, _ := strconv.ParseFloat(ms[0], 64)
344+ p.Y.Max = m
345+ }
346+ p.Legend.Top = true
347+
348+ sums := make([]float64, len(data[0].Values))
349+
350+ plotters := make([]plot.Plotter, len(data))
351+ var nextColor int
352+ colors := plotutil.SoftColors
353+ for i, res := range data {
354+ var points plotter.XYs
355+ for j, d := range res.Values {
356+ value := d.Value
357+ if stacked {
358+ value += sums[j]
359+ }
360+ points = append(points, plotter.XY{
361+ float64(d.Time.Unix()),
362+ value,
363+ })
364+ sums[j] += d.Value
365+ }
366+
367+ l, _, err := plotter.NewLinePoints(points)
368+ if err != nil {
369+ w.WriteHeader(400)
370+ w.Write([]byte(fmt.Sprintf("%v", err)))
371+ return
372+ }
373+ if stacked {
374+ l.FillColor = colors[nextColor]
375+ if i != len(data) - 1 {
376+ l.Color = color.RGBA{0, 0, 0, 0}
377+ }
378+ } else {
379+ l.Color = colors[nextColor]
380+ }
381+ nextColor += 1
382+ if nextColor >= len(colors) {
383+ nextColor = 0
384+ }
385+ plotters[i] = l
386+ p.Legend.Add(res.Metric, l)
387+ }
388+ for i := len(plotters) - 1; i >= 0; i-- {
389+ p.Add(plotters[i])
390+ }
391+
392+ writer, err := p.WriterTo(width, height, "svg")
393+ if err != nil {
394+ w.WriteHeader(400)
395+ w.Write([]byte(fmt.Sprintf("%v", err)))
396+ return
397+ }
398+
399+ w.Header().Add("Content-Type", "image/svg+xml")
400+ writer.WriteTo(w)
401+ })
402+
403+ addr := ":8142"
404+ if len(os.Args) > 2 {
405+ addr = os.Args[2]
406+ }
407+ log.Printf("Listening on %s", addr)
408+ http.ListenAndServe(addr, router)
409+ }
410+
411+ type dateTicks struct{}
412+ // Ticks computes the default tick marks, but inserts commas
413+ // into the labels for the major tick marks.
414+ func (dateTicks) Ticks(min, max float64) []plot.Tick {
415+ tks := plot.DefaultTicks{}.Ticks(min, max)
416+ for i, t := range tks {
417+ if t.Label == "" { // Skip minor ticks, they are fine.
418+ continue
419+ }
420+ d, _ := strconv.ParseFloat(t.Label, 64)
421+ tm := time.Unix(int64(d), 0)
422+ tks[i].Label = tm.Format("15:04:05")
423+ }
424+ return tks
425+ }