diff --git a/app/widget_maker.go b/app/widget_maker.go index 02d373af..ec221574 100644 --- a/app/widget_maker.go +++ b/app/widget_maker.go @@ -37,6 +37,7 @@ import ( "github.com/wtfutil/wtf/modules/grafana" "github.com/wtfutil/wtf/modules/gspreadsheets" "github.com/wtfutil/wtf/modules/hackernews" + "github.com/wtfutil/wtf/modules/healthchecks" "github.com/wtfutil/wtf/modules/hibp" "github.com/wtfutil/wtf/modules/ipaddresses/ipapi" "github.com/wtfutil/wtf/modules/ipaddresses/ipinfo" @@ -201,6 +202,9 @@ func MakeWidget( case "hackernews": settings := hackernews.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = hackernews.NewWidget(tviewApp, pages, settings) + case "healthchecks": + settings := healthchecks.NewSettingsFromYAML(moduleName, moduleConfig, config) + widget = healthchecks.NewWidget(tviewApp, pages, settings) case "hibp": settings := hibp.NewSettingsFromYAML(moduleName, moduleConfig, config) widget = hibp.NewWidget(tviewApp, settings) diff --git a/modules/healthchecks/keyboard.go b/modules/healthchecks/keyboard.go new file mode 100644 index 00000000..8a25c3f4 --- /dev/null +++ b/modules/healthchecks/keyboard.go @@ -0,0 +1,6 @@ +package healthchecks + +func (widget *Widget) initializeKeyboardControls() { + widget.InitializeHelpTextKeyboardControl(widget.ShowHelp) + widget.InitializeRefreshKeyboardControl(widget.Refresh) +} diff --git a/modules/healthchecks/settings.go b/modules/healthchecks/settings.go new file mode 100644 index 00000000..762a87de --- /dev/null +++ b/modules/healthchecks/settings.go @@ -0,0 +1,38 @@ +package healthchecks + +import ( + "os" + + "github.com/olebedev/config" + "github.com/wtfutil/wtf/cfg" + "github.com/wtfutil/wtf/utils" +) + +const ( + defaultFocusable = true + defaultTitle = "Healthchecks.io" +) + +type Settings struct { + *cfg.Common + + apiKey string `help:"An healthchecks API key." optional:"false"` + apiURL string `help:"Base URL for API" optional:"true"` + tags []string `help:"Filters the checks and returns only the checks that are tagged with the specified value"` +} + +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_HEALTHCHECKS_APIKEY")), + apiURL: ymlConfig.UString("apiURL", "https://hc-ping.com/"), + tags: utils.ToStrs(ymlConfig.UList("tags")), + } + + cfg.ModuleSecret(name, globalConfig, &settings.apiKey). + Service("https://hc-ping.com/").Load() + + return &settings +} diff --git a/modules/healthchecks/widget.go b/modules/healthchecks/widget.go new file mode 100644 index 00000000..e2400d9e --- /dev/null +++ b/modules/healthchecks/widget.go @@ -0,0 +1,184 @@ +package healthchecks + +import ( + "fmt" + "net/http" + "net/url" + "time" + + "github.com/rivo/tview" + "github.com/wtfutil/wtf/utils" + "github.com/wtfutil/wtf/view" +) + +const ( + userAgent = "WTFUtil" +) + +type Widget struct { + view.ScrollableWidget + checks []Checks + settings *Settings + err error +} + +type Health struct { + Checks []Checks `json:"checks"` +} + +type Checks struct { + Name string `json:"name"` + Tags string `json:"tags"` + Desc string `json:"desc"` + Grace int `json:"grace"` + NPings int `json:"n_pings"` + Status string `json:"status"` + LastPing time.Time `json:"last_ping"` + NextPing time.Time `json:"next_ping"` + ManualResume bool `json:"manual_resume"` + Methods string `json:"methods"` + PingURL string `json:"ping_url"` + UpdateURL string `json:"update_url"` + PauseURL string `json:"pause_url"` + Channels string `json:"channels"` + Timeout int `json:"timeout,omitempty"` + Schedule string `json:"schedule,omitempty"` + Tz string `json:"tz,omitempty"` +} + +func NewWidget(tviewApp *tview.Application, pages *tview.Pages, settings *Settings) *Widget { + widget := &Widget{ + ScrollableWidget: view.NewScrollableWidget(tviewApp, pages, settings.Common), + settings: settings, + } + + widget.SetRenderFunction(widget.Render) + widget.initializeKeyboardControls() + + return widget +} + +/* -------------------- Exported Functions -------------------- */ + +func (widget *Widget) Refresh() { + if widget.Disabled() { + return + } + + checks, err := widget.getExistingChecks() + widget.checks = checks + widget.err = err + widget.SetItemCount(len(checks)) + widget.Render() +} + +// Render sets up the widget data for redrawing to the screen +func (widget *Widget) Render() { + widget.Redraw(widget.content) +} + +/* -------------------- Unexported Functions -------------------- */ + +func (widget *Widget) content() (string, string, bool) { + numUp := 0 + for _, check := range widget.checks { + if check.Status == "up" { + numUp++ + } + } + + title := fmt.Sprintf("Healthchecks (%d/%d)", numUp, len(widget.checks)) + + if widget.err != nil { + return title, widget.err.Error(), true + } + + if widget.checks == nil { + return title, "No checks to display", false + } + + str := widget.contentFrom(widget.checks) + + return title, str, false +} + +func (widget *Widget) contentFrom(checks []Checks) string { + var str string + + for _, check := range checks { + prefix := "" + + switch check.Status { + case "up": + prefix += "[green] + " + case "down": + prefix += "[red] - " + default: + prefix += "[yellow] ~ " + } + + str += fmt.Sprintf(`%s%s [gray](%s|%d)[white]%s`, + prefix, + check.Name, + timeSincePing(check.LastPing), + check.NPings, + "\n", + ) + } + + return str +} + +func timeSincePing(ts time.Time) string { + dur := time.Since(ts) + return dur.Truncate(time.Second).String() +} + +func makeURL(baseurl string, path string, tags []string) (string, error) { + u, err := url.Parse(baseurl) + if err != nil { + return "", err + } + u.Path = path + q := u.Query() + // If we have several tags + if len(tags) > 0 { + for _, tag := range tags { + q.Add("tag", tag) + } + u.RawQuery = q.Encode() + } + return u.String(), nil +} + +func (widget *Widget) getExistingChecks() ([]Checks, error) { + // See: https://healthchecks.io/docs/api/#list-checks + u, err := makeURL(widget.settings.apiURL, "/api/v1/checks/", widget.settings.tags) + if err != nil { + return nil, err + } + req, err := http.NewRequest("GET", u, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("X-Api-Key", widget.settings.apiKey) + resp, err := http.DefaultClient.Do(req) + + if resp.StatusCode != 200 { + return nil, fmt.Errorf(resp.Status) + } + + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + var health Health + err = utils.ParseJSON(&health, resp.Body) + if err != nil { + return nil, err + } + + return health.Checks, nil +}