Skip to content

Create uptimekuma module #1753

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: trunk
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/widget_maker.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ import (
"github.com/wtfutil/wtf/modules/twitterstats"
"github.com/wtfutil/wtf/modules/unknown"
"github.com/wtfutil/wtf/modules/updown"
"github.com/wtfutil/wtf/modules/uptimekuma"
"github.com/wtfutil/wtf/modules/uptimerobot"
"github.com/wtfutil/wtf/modules/urlcheck"
"github.com/wtfutil/wtf/modules/victorops"
Expand Down Expand Up @@ -345,6 +346,9 @@ func MakeWidget(
case "updown":
settings := updown.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = updown.NewWidget(tviewApp, redrawChan, pages, settings)
case "uptimekuma":
settings := uptimekuma.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = uptimekuma.NewWidget(tviewApp, redrawChan, pages, settings)
case "uptimerobot":
settings := uptimerobot.NewSettingsFromYAML(moduleName, moduleConfig, config)
widget = uptimerobot.NewWidget(tviewApp, redrawChan, pages, settings)
Expand Down
6 changes: 6 additions & 0 deletions modules/uptimekuma/keyboard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package uptimekuma

func (widget *Widget) initializeKeyboardControls() {
widget.InitializeHelpTextKeyboardControl(widget.ShowHelp)
widget.InitializeRefreshKeyboardControl(widget.Refresh)
}
29 changes: 29 additions & 0 deletions modules/uptimekuma/settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package uptimekuma

import (
"github.com/olebedev/config"
"github.com/wtfutil/wtf/cfg"
)

const (
defaultFocusable = true
defaultTitle = "Uptime Kuma"
)

type Settings struct {
common *cfg.Common

url string `help:"Status page URL; e.g. https://uptimekuma.example.com/status/overview"`
}

// NewSettingsFromYAML creates a new settings instance from a YAML config block
func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings {
settings := Settings{
common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig),

// Configure your settings attributes here. See http://github.com/olebedev/config for type details
url: ymlConfig.UString("url"),
}

return &settings
}
234 changes: 234 additions & 0 deletions modules/uptimekuma/widget.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
package uptimekuma

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/rivo/tview"
"github.com/wtfutil/wtf/view"
)

// HeartbeatStatus represents the status of a heartbeat
// Matches JS: DOWN=0, UP=1, PENDING=2, MAINTENANCE=3
type HeartbeatStatus int

const (
DOWN HeartbeatStatus = iota
UP
PENDING
MAINTENANCE
)

// StatusPageData represents the data from the /api/status-page/<slug> endpoint
type StatusPageData struct {
Incident *Incident `json:"incident"`
}

// Incident represents an incident in Uptime Kuma
type Incident struct {
CreatedDate string `json:"createdDate"`
}

// HeartbeatData represents the data from the /api/status-page/heartbeat/<slug> endpoint
type HeartbeatData struct {
HeartbeatList map[string][]*Heartbeat `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}

// Heartbeat represents a single heartbeat event
type Heartbeat struct {
Status int `json:"status"`
}

// Widget is the container for your module's data
type Widget struct {
view.TextWidget

settings *Settings
statusData *StatusPageData
heartbeatData *HeartbeatData
err error
}

// NewWidget creates and returns an instance of Widget
func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget {
widget := Widget{
TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common),
settings: settings,
}

widget.initializeKeyboardControls()

return &widget
}

/* -------------------- Exported Functions -------------------- */

// Refresh updates the onscreen contents of the widget
func (widget *Widget) Refresh() {
widget.err = nil

baseURL, slug, err := parseURL(widget.settings.url)
if err != nil {
widget.err = err
widget.display()
return
}

statusData, err := widget.fetchStatusData(baseURL, slug)
if err != nil {
widget.err = err
widget.display()
return
}
widget.statusData = statusData

heartbeatData, err := widget.fetchHeartbeatData(baseURL, slug)
if err != nil {
widget.err = err
widget.display()
return
}
widget.heartbeatData = heartbeatData

widget.display()
}

/* -------------------- Unexported Functions -------------------- */

func (widget *Widget) content() string {
if widget.err != nil {
return fmt.Sprintf("[red]Error: %v", widget.err)
}

if widget.statusData == nil || widget.heartbeatData == nil {
return "Loading..."
}

// Use a single indexed variable for status counts
statusCounts := [4]int{}
for _, siteList := range widget.heartbeatData.HeartbeatList {
if len(siteList) > 0 {
lastHeartbeat := siteList[len(siteList)-1]
status := HeartbeatStatus(lastHeartbeat.Status)
if status >= 0 && int(status) < len(statusCounts) {
statusCounts[status]++
}
}
}

var totalUptime float64
numMonitors := len(widget.heartbeatData.UptimeList)
if numMonitors > 0 {
for _, uptime := range widget.heartbeatData.UptimeList {
totalUptime += uptime
}
}

var avgUptime float64
if numMonitors > 0 {
avgUptime = (totalUptime / float64(numMonitors)) * 100
}

// Adapted from https://github.com/gethomepage/homepage/blob/00bb1a3f37940a0c3c681c3eef0a10d3e1fa0053/src/widgets/uptimekuma/component.jsx#L41C1-L48C1
var builder strings.Builder
var textColor = widget.settings.common.Colors.Text
downColor := "red"
if statusCounts[DOWN] == 0 {
downColor = "green"
}
builder.WriteString(fmt.Sprintf("[%s] Up: [green]%d", textColor, statusCounts[UP]))
builder.WriteString(fmt.Sprintf("[%s] (%.1f%%)", textColor, avgUptime))
builder.WriteString(fmt.Sprintf("[%s], Down: [%s]%d", textColor, downColor, statusCounts[DOWN]))
if statusCounts[MAINTENANCE] > 0 {
builder.WriteString(fmt.Sprintf("[%s], Maint: [%s]%d", textColor, "blue", statusCounts[MAINTENANCE]))
}
if statusCounts[PENDING] > 0 {
builder.WriteString(fmt.Sprintf("[%s], Pend: [%s]%d", textColor, "orange", statusCounts[PENDING]))
}

if widget.statusData.Incident != nil {
// Uptime Kuma's API returns dates like "2023-10-27 10:30:00.123"
layout := "2006-01-02 15:04:05.999"
created, err := time.Parse(layout, widget.statusData.Incident.CreatedDate)
if err == nil {
hoursAgo := time.Since(created).Hours()
builder.WriteString(fmt.Sprintf("[%s]\n Incident: %.0fh ago", textColor, hoursAgo))
} else {
builder.WriteString(fmt.Sprintf("[%s]\n Incident [unparsable date]", textColor))
}
}

return builder.String()
}

func (widget *Widget) display() {
widget.Redraw(func() (string, string, bool) {
return widget.CommonSettings().Title, widget.content(), false
})
}

func (*Widget) fetchStatusData(baseURL, slug string) (*StatusPageData, error) {
apiURL := fmt.Sprintf("%s/api/status-page/%s", baseURL, slug)

resp, err := http.Get(apiURL)
if resp != nil && resp.StatusCode != 200 {
return nil, fmt.Errorf("%s", resp.Status)
}
if resp == nil || err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()

var data StatusPageData
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}

return &data, nil
}

func (*Widget) fetchHeartbeatData(baseURL, slug string) (*HeartbeatData, error) {
apiURL := fmt.Sprintf("%s/api/status-page/heartbeat/%s", baseURL, slug)

resp, err := http.Get(apiURL)
if resp != nil && resp.StatusCode != 200 {
return nil, fmt.Errorf("%s", resp.Status)
}
if resp == nil || err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()

var data HeartbeatData
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
return nil, err
}

return &data, nil
}

func parseURL(rawURL string) (string, string, error) {
if rawURL == "" {
return "", "", fmt.Errorf("URL is not defined")
}

u, err := url.Parse(rawURL)
if err != nil {
return "", "", fmt.Errorf("invalid URL: %w", err)
}

parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) < 2 || parts[0] != "status" {
return "", "", fmt.Errorf("invalid status page URL format. Expected '.../status/<slug>'")
}

slug := parts[1]
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)

return baseURL, slug, nil
}
Loading