1 | package main
|
2 |
|
3 | import (
|
4 | "bytes"
|
5 | "encoding/json"
|
6 | "fmt"
|
7 | "image/color"
|
8 | "log"
|
9 | "math"
|
10 | "net/http"
|
11 | "net/url"
|
12 | "os"
|
13 | "sort"
|
14 | "strconv"
|
15 | "strings"
|
16 | "text/template"
|
17 | "time"
|
18 |
|
19 | "github.com/dustin/go-humanize"
|
20 | "github.com/go-chi/chi"
|
21 | "github.com/go-chi/chi/middleware"
|
22 | "gonum.org/v1/plot"
|
23 | "gonum.org/v1/plot/plotter"
|
24 | "gonum.org/v1/plot/plotutil"
|
25 | "gonum.org/v1/plot/vg"
|
26 | )
|
27 |
|
28 | var (
|
29 | upstream string
|
30 | )
|
31 |
|
32 | type PromResponse struct {
|
33 | Status string `json:"status"`
|
34 | Data struct {
|
35 | ResultType string `json:"resultType"`
|
36 | Result []struct {
|
37 | Metric map[string]string `json:"metric"`
|
38 | Values [][]interface{} `json:"values"`
|
39 | } `json:"result"`
|
40 | } `json:"data"`
|
41 | }
|
42 |
|
43 | type Datapoint struct {
|
44 | Time time.Time
|
45 | Value float64
|
46 | }
|
47 |
|
48 | type PromResult struct {
|
49 | Metric map[string]string
|
50 | Values []Datapoint
|
51 | }
|
52 |
|
53 | func Query(q string, start time.Time, end time.Time, step int) ([]PromResult, error) {
|
54 | body := url.Values{}
|
55 | body.Set("query", q)
|
56 | body.Set("start", fmt.Sprintf("%d", start.Unix()))
|
57 | body.Set("end", fmt.Sprintf("%d", end.Unix()))
|
58 | body.Set("step", fmt.Sprintf("%d", step))
|
59 | resp, err := http.Post(fmt.Sprintf("%s/api/v1/query_range", upstream),
|
60 | "application/x-www-form-urlencoded", strings.NewReader(body.Encode()))
|
61 | if err != nil {
|
62 | return nil, err
|
63 | }
|
64 | defer resp.Body.Close()
|
65 |
|
66 | if resp.StatusCode != 200 {
|
67 | return nil, fmt.Errorf("Received %d response from upstream", resp.StatusCode)
|
68 | }
|
69 |
|
70 | var data PromResponse
|
71 | if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
|
72 | return nil, err
|
73 | }
|
74 |
|
75 | if data.Data.ResultType != "matrix" {
|
76 | return nil, fmt.Errorf("result type isn't of type matrix: %s",
|
77 | data.Data.ResultType)
|
78 | }
|
79 |
|
80 | if len(data.Data.Result) == 0 {
|
81 | return nil, fmt.Errorf("No data")
|
82 | }
|
83 |
|
84 | var results []PromResult
|
85 | for _, res := range data.Data.Result {
|
86 | r := PromResult{}
|
87 | r.Metric = res.Metric
|
88 |
|
89 | var values []Datapoint
|
90 | isValid := true
|
91 | for _, vals := range res.Values {
|
92 | timestamp := vals[0].(float64)
|
93 | value := vals[1].(string)
|
94 | fv, _ := strconv.ParseFloat(value, 64)
|
95 | if math.IsNaN(fv) || math.IsInf(fv, 0) {
|
96 | isValid = false
|
97 | break
|
98 | }
|
99 | values = append(values, Datapoint{
|
100 | time.Unix(int64(timestamp), 0),
|
101 | fv,
|
102 | })
|
103 | }
|
104 | r.Values = values
|
105 |
|
106 | if isValid {
|
107 | results = append(results, r)
|
108 | }
|
109 | }
|
110 | return results, nil
|
111 | }
|
112 |
|
113 | func metricName(metric map[string]string) string {
|
114 | if len(metric) == 0 {
|
115 | return "{}"
|
116 | }
|
117 |
|
118 | out := ""
|
119 | var inner []string
|
120 | for key, value := range metric {
|
121 | if key == "__name__" {
|
122 | out = value
|
123 | continue
|
124 | }
|
125 | inner = append(inner, fmt.Sprintf(`%s="%s"`, key, value))
|
126 | }
|
127 |
|
128 | if len(inner) == 0 {
|
129 | return out
|
130 | }
|
131 |
|
132 | sort.Slice(inner, func(i, j int) bool {
|
133 | return inner[i] < inner[j]
|
134 | })
|
135 |
|
136 | return out + "{" + strings.Join(inner, ",") + "}"
|
137 | }
|
138 |
|
139 | func handleLabel(p *plot.Plot, l *plotter.Line, label string, metric map[string]string) {
|
140 | tmpl, err := template.New("label").Parse(label)
|
141 | if err != nil {
|
142 | log.Printf("Failed to parse label template: %v", err)
|
143 | } else {
|
144 | var label_out bytes.Buffer
|
145 | tmpl.Execute(&label_out, metric)
|
146 | p.Legend.Add(label_out.String(), l)
|
147 | }
|
148 | }
|
149 |
|
150 | func registerExtension(router chi.Router, extension string, mime string) {
|
151 | router.Get("/chart."+extension, func(w http.ResponseWriter, r *http.Request) {
|
152 | args := r.URL.Query()
|
153 | var query string
|
154 | if q, ok := args["query"]; !ok {
|
155 | w.WriteHeader(400)
|
156 | w.Write([]byte("Expected ?query=... parameter"))
|
157 | return
|
158 | } else {
|
159 | query = q[0]
|
160 | }
|
161 |
|
162 | start := time.Now().Add(-24 * 60 * time.Minute)
|
163 | end := time.Now()
|
164 | if s, ok := args["since"]; ok {
|
165 | d, _ := time.ParseDuration(s[0])
|
166 | start = time.Now().Add(-d)
|
167 | }
|
168 | if u, ok := args["until"]; ok {
|
169 | d, _ := time.ParseDuration(u[0])
|
170 | end = time.Now().Add(-d)
|
171 | }
|
172 |
|
173 | width := 12 * vg.Inch
|
174 | height := 6 * vg.Inch
|
175 | if ws, ok := args["width"]; ok {
|
176 | w, _ := strconv.ParseFloat(ws[0], 32)
|
177 | width = vg.Length(w) * vg.Inch
|
178 | }
|
179 | if hs, ok := args["height"]; ok {
|
180 | h, _ := strconv.ParseFloat(hs[0], 32)
|
181 | height = vg.Length(h) * vg.Inch
|
182 | }
|
183 |
|
184 | // Label template
|
185 | var label string
|
186 | if l, ok := args["label"]; ok {
|
187 | label = l[0]
|
188 | }
|
189 |
|
190 | // Set step so that there's approximately 25 data points per inch
|
191 | step := int(end.Sub(start).Seconds() / (25 * float64(width/vg.Inch)))
|
192 | if s, ok := args["step"]; ok {
|
193 | d, _ := strconv.ParseInt(s[0], 10, 32)
|
194 | step = int(d)
|
195 | }
|
196 | _, stacked := args["stacked"]
|
197 |
|
198 | data, err := Query(query, start, end, step)
|
199 | if err != nil {
|
200 | w.WriteHeader(400)
|
201 | w.Write([]byte(fmt.Sprintf("%v", err)))
|
202 | return
|
203 | }
|
204 |
|
205 | p, err := plot.New()
|
206 | if err != nil {
|
207 | panic(err)
|
208 | }
|
209 | if t, ok := args["title"]; ok {
|
210 | p.Title.Text = t[0]
|
211 | }
|
212 | p.X.Label.Text = "Time"
|
213 | p.X.Tick.Marker = dateTicks{start, end}
|
214 | if ms, ok := args["max"]; ok {
|
215 | m, _ := strconv.ParseFloat(ms[0], 64)
|
216 | p.Y.Max = m
|
217 | }
|
218 |
|
219 | p.Y.Tick.Marker = humanTicks{}
|
220 | if ms, ok := args["min"]; ok {
|
221 | m, _ := strconv.ParseFloat(ms[0], 64)
|
222 | p.Y.Min = m
|
223 | }
|
224 | p.Legend.Top = true
|
225 |
|
226 | sums := make([]float64, len(data[0].Values))
|
227 |
|
228 | plotters := make([]plot.Plotter, len(data))
|
229 | var nextColor int
|
230 | colors := plotutil.SoftColors
|
231 | for i, res := range data {
|
232 | var points plotter.XYs
|
233 | for j, d := range res.Values {
|
234 | value := d.Value
|
235 | if stacked {
|
236 | value += sums[j]
|
237 | }
|
238 | points = append(points, plotter.XY{
|
239 | float64(d.Time.Unix()),
|
240 | value,
|
241 | })
|
242 | sums[j] += d.Value
|
243 | }
|
244 |
|
245 | l, _, err := plotter.NewLinePoints(points)
|
246 | if err != nil {
|
247 | w.WriteHeader(400)
|
248 | w.Write([]byte(fmt.Sprintf("%v", err)))
|
249 | return
|
250 | }
|
251 | if stacked {
|
252 | l.FillColor = colors[nextColor]
|
253 | if i != len(data)-1 {
|
254 | l.Color = color.RGBA{0, 0, 0, 0}
|
255 | }
|
256 | } else {
|
257 | l.Color = colors[nextColor]
|
258 | }
|
259 | nextColor += 1
|
260 | if nextColor >= len(colors) {
|
261 | nextColor = 0
|
262 | }
|
263 | plotters[i] = l
|
264 | if label != "" {
|
265 | handleLabel(p, l, label, res.Metric)
|
266 | } else {
|
267 | p.Legend.Add(metricName(res.Metric), l)
|
268 | }
|
269 | }
|
270 | for i := len(plotters) - 1; i >= 0; i-- {
|
271 | p.Add(plotters[i])
|
272 | }
|
273 |
|
274 | writer, err := p.WriterTo(width, height, extension)
|
275 | if err != nil {
|
276 | w.WriteHeader(400)
|
277 | w.Write([]byte(fmt.Sprintf("%v", err)))
|
278 | return
|
279 | }
|
280 |
|
281 | w.Header().Add("Content-Type", mime)
|
282 | writer.WriteTo(w)
|
283 | })
|
284 |
|
285 | }
|
286 |
|
287 | func main() {
|
288 | plotutil.DefaultDashes = [][]vg.Length{{}}
|
289 |
|
290 | if len(os.Args) < 2 {
|
291 | fmt.Printf("Usage: %s server\n", os.Args[0])
|
292 | os.Exit(1)
|
293 | }
|
294 | upstream = os.Args[1]
|
295 | router := chi.NewRouter()
|
296 |
|
297 | router.Use(middleware.RealIP)
|
298 | router.Use(middleware.Logger)
|
299 |
|
300 | registerExtension(router, "svg", "image/svg+xml")
|
301 | registerExtension(router, "png", "image/png")
|
302 |
|
303 | staticDir := os.Getenv("CHARTSRV_STATICDIR")
|
304 | if staticDir == "" {
|
305 | staticDir = "static"
|
306 | }
|
307 |
|
308 | router.Get("/*", func(w http.ResponseWriter, r *http.Request) {
|
309 | fs := http.FileServer(http.Dir(staticDir))
|
310 | fs.ServeHTTP(w, r)
|
311 | })
|
312 |
|
313 | addr := ":8142"
|
314 | if len(os.Args) > 2 {
|
315 | addr = os.Args[2]
|
316 | }
|
317 | log.Printf("Listening on %s", addr)
|
318 | http.ListenAndServe(addr, router)
|
319 | }
|
320 |
|
321 | type dateTicks struct {
|
322 | Start time.Time
|
323 | End time.Time
|
324 | }
|
325 |
|
326 | // Ticks computes the default tick marks, but inserts commas
|
327 | // into the labels for the major tick marks.
|
328 | func (dt dateTicks) Ticks(min, max float64) []plot.Tick {
|
329 | fmt := "15:04:05"
|
330 | if dt.End.Sub(dt.Start).Hours() >= 24 {
|
331 | fmt = "Jan 2 15:04:05"
|
332 | }
|
333 |
|
334 | tks := plot.DefaultTicks{}.Ticks(min, max)
|
335 | for i, t := range tks {
|
336 | if t.Label == "" { // Skip minor ticks, they are fine.
|
337 | continue
|
338 | }
|
339 | d, _ := strconv.ParseFloat(t.Label, 64)
|
340 | tm := time.Unix(int64(d), 0)
|
341 | tks[i].Label = tm.Format(fmt)
|
342 | }
|
343 | return tks
|
344 | }
|
345 |
|
346 | type humanTicks struct{}
|
347 |
|
348 | func (ht humanTicks) Ticks(min, max float64) []plot.Tick {
|
349 | tks := plot.DefaultTicks{}.Ticks(min, max)
|
350 | for i, t := range tks {
|
351 | if t.Label == "" { // Skip minor ticks, they are fine.
|
352 | continue
|
353 | }
|
354 | d, _ := strconv.ParseFloat(t.Label, 64)
|
355 | tks[i].Label = humanize.SI(d, "")
|
356 | }
|
357 | return tks
|
358 | }
|