Skip to content

Commit f84c641

Browse files
authored
Create uptimekuma module (#1753)
1 parent 3fb4e1e commit f84c641

File tree

4 files changed

+273
-0
lines changed

4 files changed

+273
-0
lines changed

app/widget_maker.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ import (
8080
"github.com/wtfutil/wtf/modules/twitterstats"
8181
"github.com/wtfutil/wtf/modules/unknown"
8282
"github.com/wtfutil/wtf/modules/updown"
83+
"github.com/wtfutil/wtf/modules/uptimekuma"
8384
"github.com/wtfutil/wtf/modules/uptimerobot"
8485
"github.com/wtfutil/wtf/modules/urlcheck"
8586
"github.com/wtfutil/wtf/modules/victorops"
@@ -345,6 +346,9 @@ func MakeWidget(
345346
case "updown":
346347
settings := updown.NewSettingsFromYAML(moduleName, moduleConfig, config)
347348
widget = updown.NewWidget(tviewApp, redrawChan, pages, settings)
349+
case "uptimekuma":
350+
settings := uptimekuma.NewSettingsFromYAML(moduleName, moduleConfig, config)
351+
widget = uptimekuma.NewWidget(tviewApp, redrawChan, pages, settings)
348352
case "uptimerobot":
349353
settings := uptimerobot.NewSettingsFromYAML(moduleName, moduleConfig, config)
350354
widget = uptimerobot.NewWidget(tviewApp, redrawChan, pages, settings)

modules/uptimekuma/keyboard.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
package uptimekuma
2+
3+
func (widget *Widget) initializeKeyboardControls() {
4+
widget.InitializeHelpTextKeyboardControl(widget.ShowHelp)
5+
widget.InitializeRefreshKeyboardControl(widget.Refresh)
6+
}

modules/uptimekuma/settings.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package uptimekuma
2+
3+
import (
4+
"github.com/olebedev/config"
5+
"github.com/wtfutil/wtf/cfg"
6+
)
7+
8+
const (
9+
defaultFocusable = true
10+
defaultTitle = "Uptime Kuma"
11+
)
12+
13+
type Settings struct {
14+
common *cfg.Common
15+
16+
url string `help:"Status page URL; e.g. https://uptimekuma.example.com/status/overview"`
17+
}
18+
19+
// NewSettingsFromYAML creates a new settings instance from a YAML config block
20+
func NewSettingsFromYAML(name string, ymlConfig *config.Config, globalConfig *config.Config) *Settings {
21+
settings := Settings{
22+
common: cfg.NewCommonSettingsFromModule(name, defaultTitle, defaultFocusable, ymlConfig, globalConfig),
23+
24+
// Configure your settings attributes here. See http://github.com/olebedev/config for type details
25+
url: ymlConfig.UString("url"),
26+
}
27+
28+
return &settings
29+
}

modules/uptimekuma/widget.go

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package uptimekuma
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/url"
8+
"strings"
9+
"time"
10+
11+
"github.com/rivo/tview"
12+
"github.com/wtfutil/wtf/view"
13+
)
14+
15+
// HeartbeatStatus represents the status of a heartbeat
16+
// Matches JS: DOWN=0, UP=1, PENDING=2, MAINTENANCE=3
17+
type HeartbeatStatus int
18+
19+
const (
20+
DOWN HeartbeatStatus = iota
21+
UP
22+
PENDING
23+
MAINTENANCE
24+
)
25+
26+
// StatusPageData represents the data from the /api/status-page/<slug> endpoint
27+
type StatusPageData struct {
28+
Incident *Incident `json:"incident"`
29+
}
30+
31+
// Incident represents an incident in Uptime Kuma
32+
type Incident struct {
33+
CreatedDate string `json:"createdDate"`
34+
}
35+
36+
// HeartbeatData represents the data from the /api/status-page/heartbeat/<slug> endpoint
37+
type HeartbeatData struct {
38+
HeartbeatList map[string][]*Heartbeat `json:"heartbeatList"`
39+
UptimeList map[string]float64 `json:"uptimeList"`
40+
}
41+
42+
// Heartbeat represents a single heartbeat event
43+
type Heartbeat struct {
44+
Status int `json:"status"`
45+
}
46+
47+
// Widget is the container for your module's data
48+
type Widget struct {
49+
view.TextWidget
50+
51+
settings *Settings
52+
statusData *StatusPageData
53+
heartbeatData *HeartbeatData
54+
err error
55+
}
56+
57+
// NewWidget creates and returns an instance of Widget
58+
func NewWidget(tviewApp *tview.Application, redrawChan chan bool, pages *tview.Pages, settings *Settings) *Widget {
59+
widget := Widget{
60+
TextWidget: view.NewTextWidget(tviewApp, redrawChan, pages, settings.common),
61+
settings: settings,
62+
}
63+
64+
widget.initializeKeyboardControls()
65+
66+
return &widget
67+
}
68+
69+
/* -------------------- Exported Functions -------------------- */
70+
71+
// Refresh updates the onscreen contents of the widget
72+
func (widget *Widget) Refresh() {
73+
widget.err = nil
74+
75+
baseURL, slug, err := parseURL(widget.settings.url)
76+
if err != nil {
77+
widget.err = err
78+
widget.display()
79+
return
80+
}
81+
82+
statusData, err := widget.fetchStatusData(baseURL, slug)
83+
if err != nil {
84+
widget.err = err
85+
widget.display()
86+
return
87+
}
88+
widget.statusData = statusData
89+
90+
heartbeatData, err := widget.fetchHeartbeatData(baseURL, slug)
91+
if err != nil {
92+
widget.err = err
93+
widget.display()
94+
return
95+
}
96+
widget.heartbeatData = heartbeatData
97+
98+
widget.display()
99+
}
100+
101+
/* -------------------- Unexported Functions -------------------- */
102+
103+
func (widget *Widget) content() string {
104+
if widget.err != nil {
105+
return fmt.Sprintf("[red]Error: %v", widget.err)
106+
}
107+
108+
if widget.statusData == nil || widget.heartbeatData == nil {
109+
return "Loading..."
110+
}
111+
112+
// Use a single indexed variable for status counts
113+
statusCounts := [4]int{}
114+
for _, siteList := range widget.heartbeatData.HeartbeatList {
115+
if len(siteList) > 0 {
116+
lastHeartbeat := siteList[len(siteList)-1]
117+
status := HeartbeatStatus(lastHeartbeat.Status)
118+
if status >= 0 && int(status) < len(statusCounts) {
119+
statusCounts[status]++
120+
}
121+
}
122+
}
123+
124+
var totalUptime float64
125+
numMonitors := len(widget.heartbeatData.UptimeList)
126+
if numMonitors > 0 {
127+
for _, uptime := range widget.heartbeatData.UptimeList {
128+
totalUptime += uptime
129+
}
130+
}
131+
132+
var avgUptime float64
133+
if numMonitors > 0 {
134+
avgUptime = (totalUptime / float64(numMonitors)) * 100
135+
}
136+
137+
// Adapted from https://github.com/gethomepage/homepage/blob/00bb1a3f37940a0c3c681c3eef0a10d3e1fa0053/src/widgets/uptimekuma/component.jsx#L41C1-L48C1
138+
var builder strings.Builder
139+
var textColor = widget.settings.common.Colors.Text
140+
downColor := "red"
141+
if statusCounts[DOWN] == 0 {
142+
downColor = "green"
143+
}
144+
builder.WriteString(fmt.Sprintf("[%s] Up: [green]%d", textColor, statusCounts[UP]))
145+
builder.WriteString(fmt.Sprintf("[%s] (%.1f%%)", textColor, avgUptime))
146+
builder.WriteString(fmt.Sprintf("[%s], Down: [%s]%d", textColor, downColor, statusCounts[DOWN]))
147+
if statusCounts[MAINTENANCE] > 0 {
148+
builder.WriteString(fmt.Sprintf("[%s], Maint: [%s]%d", textColor, "blue", statusCounts[MAINTENANCE]))
149+
}
150+
if statusCounts[PENDING] > 0 {
151+
builder.WriteString(fmt.Sprintf("[%s], Pend: [%s]%d", textColor, "orange", statusCounts[PENDING]))
152+
}
153+
154+
if widget.statusData.Incident != nil {
155+
// Uptime Kuma's API returns dates like "2023-10-27 10:30:00.123"
156+
layout := "2006-01-02 15:04:05.999"
157+
created, err := time.Parse(layout, widget.statusData.Incident.CreatedDate)
158+
if err == nil {
159+
hoursAgo := time.Since(created).Hours()
160+
builder.WriteString(fmt.Sprintf("[%s]\n Incident: %.0fh ago", textColor, hoursAgo))
161+
} else {
162+
builder.WriteString(fmt.Sprintf("[%s]\n Incident [unparsable date]", textColor))
163+
}
164+
}
165+
166+
return builder.String()
167+
}
168+
169+
func (widget *Widget) display() {
170+
widget.Redraw(func() (string, string, bool) {
171+
return widget.CommonSettings().Title, widget.content(), false
172+
})
173+
}
174+
175+
func (*Widget) fetchStatusData(baseURL, slug string) (*StatusPageData, error) {
176+
apiURL := fmt.Sprintf("%s/api/status-page/%s", baseURL, slug)
177+
178+
resp, err := http.Get(apiURL)
179+
if resp != nil && resp.StatusCode != 200 {
180+
return nil, fmt.Errorf("%s", resp.Status)
181+
}
182+
if resp == nil || err != nil {
183+
return nil, err
184+
}
185+
defer func() { _ = resp.Body.Close() }()
186+
187+
var data StatusPageData
188+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
189+
return nil, err
190+
}
191+
192+
return &data, nil
193+
}
194+
195+
func (*Widget) fetchHeartbeatData(baseURL, slug string) (*HeartbeatData, error) {
196+
apiURL := fmt.Sprintf("%s/api/status-page/heartbeat/%s", baseURL, slug)
197+
198+
resp, err := http.Get(apiURL)
199+
if resp != nil && resp.StatusCode != 200 {
200+
return nil, fmt.Errorf("%s", resp.Status)
201+
}
202+
if resp == nil || err != nil {
203+
return nil, err
204+
}
205+
defer func() { _ = resp.Body.Close() }()
206+
207+
var data HeartbeatData
208+
if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
209+
return nil, err
210+
}
211+
212+
return &data, nil
213+
}
214+
215+
func parseURL(rawURL string) (string, string, error) {
216+
if rawURL == "" {
217+
return "", "", fmt.Errorf("URL is not defined")
218+
}
219+
220+
u, err := url.Parse(rawURL)
221+
if err != nil {
222+
return "", "", fmt.Errorf("invalid URL: %w", err)
223+
}
224+
225+
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
226+
if len(parts) < 2 || parts[0] != "status" {
227+
return "", "", fmt.Errorf("invalid status page URL format. Expected '.../status/<slug>'")
228+
}
229+
230+
slug := parts[1]
231+
baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
232+
233+
return baseURL, slug, nil
234+
}

0 commit comments

Comments
 (0)