diff --git a/app/widget_maker.go b/app/widget_maker.go index 12abc86d..72a52a39 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -33,6 +33,7 @@ import ( "github.com/wtfutil/wtf/modules/gitlabtodo" "github.com/wtfutil/wtf/modules/gitter" "github.com/wtfutil/wtf/modules/googleanalytics" + "github.com/wtfutil/wtf/modules/grafana" "github.com/wtfutil/wtf/modules/gspreadsheets" "github.com/wtfutil/wtf/modules/hackernews" "github.com/wtfutil/wtf/modules/hibp" @@ -189,6 +190,9 @@ func MakeWidget( case "gspreadsheets": settings := gspreadsheets.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = gspreadsheets.NewWidget(app, settings) + case "grafana": + settings := grafana.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = grafana.NewWidget(app, pages, settings) case "hackernews": settings := hackernews.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = hackernews.NewWidget(app, pages, settings) diff --git a/modules/grafana/client.go b/modules/grafana/client.go new file mode 100644 index 00000000..02e5c7d8 --- /dev/null +++ b/modules/grafana/client.go @@ -0,0 +1,105 @@ +package grafana + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "sort" + + "github.com/wtfutil/wtf/utils" +) + +type AlertState int + +const ( + Alerting AlertState = iota + Pending + NoData + Paused + Ok +) + +var toString = map[AlertState]string{ + Alerting: "alerting", + Pending: "pending", + NoData: "no_data", + Paused: "paused", + Ok: "ok", +} + +var toID = map[string]AlertState{ + "alerting": Alerting, + "pending": Pending, + "no_data": NoData, + "paused": Paused, + "ok": Ok, +} + +// MarshalJSON marshals the enum as a quoted json string +func (s AlertState) MarshalJSON() ([]byte, error) { + buffer := bytes.NewBufferString(`"`) + buffer.WriteString(toString[s]) + buffer.WriteString(`"`) + return buffer.Bytes(), nil +} + +// UnmarshalJSON unmashals a quoted json string to the enum value +func (s *AlertState) UnmarshalJSON(b []byte) error { + var j string + err := json.Unmarshal(b, &j) + if err != nil { + return err + } + // if we somehow get an invalid value we'll end up in the alerting state + *s = toID[j] + return nil +} + +type Alert struct { + Name string `json:"name"` + State AlertState `json:"state"` + URL string `json:"url"` +} + +type Client struct { + apiKey string + baseURI string +} + +func NewClient(settings *Settings) *Client { + return &Client{ + apiKey: settings.apiKey, + baseURI: settings.baseURI, + } +} + +func (client *Client) Alerts() ([]Alert, error) { + // query the alerts API of Grafana https://grafana.com/docs/grafana/latest/http_api/alerting/ + req, err := http.NewRequest("GET", fmt.Sprintf("%s/api/alerts", client.baseURI), nil) + if err != nil { + return nil, err + } + + if client.apiKey != "" { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", client.apiKey)) + } + + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer func() { _ = res.Body.Close() }() + + var out []Alert + err = utils.ParseJSON(&out, res.Body) + if err != nil { + return nil, err + } + + sort.SliceStable(out, func(i, j int) bool { + return out[i].State < out[j].State + }) + + return out, nil +} diff --git a/modules/grafana/display.go b/modules/grafana/display.go new file mode 100644 index 00000000..424cffa6 --- /dev/null +++ b/modules/grafana/display.go @@ -0,0 +1,57 @@ +package grafana + +import "fmt" + +func (widget *Widget) content() (string, string, bool) { + title := widget.CommonSettings().Title + + var out string + if widget.Err != nil { + return title, widget.Err.Error(), false + } else { + for idx, alert := range widget.Alerts { + out += fmt.Sprintf(` ["%d"][%s]%s - %s[""]`, + idx, + stateColor(alert.State), + stateToEmoji(alert.State), + alert.Name, + ) + out += "\n" + } + } + + return title, out, false +} + +func stateColor(state AlertState) string { + switch state { + case Ok: + return "green" + case Paused: + return "yellow" + case Alerting: + return "red" + case Pending: + return "orange" + case NoData: + return "yellow" + default: + return "white" + } +} + +func stateToEmoji(state AlertState) string { + switch state { + case Ok: + return "✔" + case Paused: + return "⏸" + case Alerting: + return "✘" + case Pending: + return "?" + case NoData: + return "?" + } + return "" +} diff --git a/modules/grafana/keyboard.go b/modules/grafana/keyboard.go new file mode 100644 index 00000000..43846959 --- /dev/null +++ b/modules/grafana/keyboard.go @@ -0,0 +1,10 @@ +package grafana + +import "github.com/gdamore/tcell" + +func (widget *Widget) initializeKeyboardControls() { + widget.SetKeyboardKey(tcell.KeyUp, widget.Prev, "Select previous alert") + widget.SetKeyboardKey(tcell.KeyDown, widget.Next, "Select next alert") + widget.SetKeyboardKey(tcell.KeyEnter, widget.openAlert, "Open alert in browser") + widget.SetKeyboardKey(tcell.KeyEsc, widget.Unselect, "Clear selection") +} diff --git a/modules/grafana/settings.go b/modules/grafana/settings.go new file mode 100644 index 00000000..f2c319f4 --- /dev/null +++ b/modules/grafana/settings.go @@ -0,0 +1,40 @@ +package grafana + +import ( + "log" + "os" + "strings" + + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" +) + +const ( + defaultFocusable = true + defaultTitle = "Grafana" +) + +type Settings struct { + common *cfg.Common + + apiKey string `help:"Your Grafana API token."` + baseURI string `help:"Base url of your grafana instance"` +} + +func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings { + + settings := Settings{ + common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig), + + apiKey: ymlConfig.UString("apiKey", os.Getenv("WTF_GRAFANA_API_KEY")), + baseURI: ymlConfig.UString("baseUri", ""), + } + + if settings.baseURI == "" { + log.Fatal("baseUri for grafana is empty, but is required") + } else { + settings.baseURI = strings.TrimSuffix(settings.baseURI, "/") + } + + return &settings +} diff --git a/modules/grafana/widget.go b/modules/grafana/widget.go new file mode 100644 index 00000000..e83d853b --- /dev/null +++ b/modules/grafana/widget.go @@ -0,0 +1,107 @@ +package grafana + +import ( + "fmt" + "strconv" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/utils" + "github.com/wtfutil/wtf/view" +) + +type Widget struct { + view.KeyboardWidget + view.TextWidget + + Client *Client + Alerts []Alert + Err error + Selected int + + settings *Settings +} + +func NewWidget(app *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := Widget{ + KeyboardWidget: view.NewKeyboardWidget(app, pages, settings.common), + TextWidget: view.NewTextWidget(app, settings.common), + + Client: NewClient(settings), + Selected: -1, + + settings: settings, + } + + widget.initializeKeyboardControls() + widget.View.SetRegions(true) + widget.View.SetInputCapture(widget.InputCapture) + + widget.KeyboardWidget.SetView(widget.View) + + return &widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Refresh() { + alerts, err := widget.Client.Alerts() + if err != nil { + widget.Err = err + widget.Alerts = nil + } else { + widget.Err = nil + widget.Alerts = alerts + } + + widget.Redraw(widget.content) +} + +// GetSelected returns the index of the currently highlighted item as an int +func (widget *Widget) GetSelected() int { + if widget.Selected < 0 { + return 0 + } + return widget.Selected +} + +// Next cycles the currently highlighted text down +func (widget *Widget) Next() { + widget.Selected++ + if widget.Selected >= len(widget.Alerts) { + widget.Selected = 0 + } + widget.View.Highlight(strconv.Itoa(widget.Selected)).ScrollToHighlight() +} + +// Prev cycles the currently highlighted text up +func (widget *Widget) Prev() { + widget.Selected-- + if widget.Selected < 0 { + widget.Selected = len(widget.Alerts) - 1 + } + widget.View.Highlight(strconv.Itoa(widget.Selected)).ScrollToHighlight() +} + +// Unselect stops highlighting the text and jumps the scroll position to the top +func (widget *Widget) Unselect() { + widget.Selected = -1 + widget.View.Highlight() + widget.View.ScrollToBeginning() +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) HelpText() string { + return widget.KeyboardWidget.HelpText() +} + +func (widget *Widget) openAlert() { + currentSelection := widget.View.GetHighlights() + if widget.Selected >= 0 && currentSelection[0] != "" { + url := widget.Alerts[widget.GetSelected()].URL + if url[0] == '/' { + url = fmt.Sprintf("%s%s", widget.settings.baseURI, url) + } + utils.OpenFile(url) + } +}