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