Files
jbergner 9b3227348d
release-tag / release-image (push) Successful in 1m33s
init
2026-07-27 17:32:35 +02:00

317 lines
8.4 KiB
Go

package uptimekuma
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strconv"
"strings"
"time"
"github.com/example/glpi-ai-agent/internal/model"
)
type Client struct {
baseURL string
mode string
apiKey string
http *http.Client
}
func New(baseURL, mode, apiKey string, timeout time.Duration) *Client {
return &Client{baseURL: strings.TrimRight(baseURL, "/"), mode: strings.ToLower(strings.TrimSpace(mode)), apiKey: apiKey, http: &http.Client{Timeout: timeout}}
}
type statusPageResponse struct {
Config struct {
Slug string `json:"slug"`
Title string `json:"title"`
} `json:"config"`
Incident *struct {
Title string `json:"title"`
Content string `json:"content"`
Style string `json:"style"`
CreatedDate string `json:"createdDate"`
LastUpdatedDate string `json:"lastUpdatedDate"`
Pin bool `json:"pin"`
} `json:"incident"`
PublicGroupList []struct {
Name string `json:"name"`
MonitorList []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
} `json:"monitorList"`
} `json:"publicGroupList"`
MaintenanceList []struct {
ID int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
Active bool `json:"active"`
Status string `json:"status"`
} `json:"maintenanceList"`
}
type heartbeatResponse struct {
HeartbeatList map[string][]struct {
Status int `json:"status"`
Time string `json:"time"`
Msg string `json:"msg"`
Ping float64 `json:"ping"`
} `json:"heartbeatList"`
UptimeList map[string]float64 `json:"uptimeList"`
}
func (c *Client) FetchIssues(ctx context.Context, slugs []string, includeMaintenance bool, maxIssues int) ([]model.ServiceIssueContext, error) {
var out []model.ServiceIssueContext
var err error
switch c.mode {
case "metrics":
out, err = c.fetchMetricsIssues(ctx)
case "status_page":
for _, slug := range slugs {
var issues []model.ServiceIssueContext
issues, err = c.fetchPage(ctx, slug, includeMaintenance)
if err != nil {
return nil, fmt.Errorf("status page %q: %w", slug, err)
}
out = append(out, issues...)
}
default:
return nil, fmt.Errorf("unsupported Uptime Kuma mode %q", c.mode)
}
if err != nil {
return nil, err
}
sort.SliceStable(out, func(i, j int) bool { return issueRank(out[i]) > issueRank(out[j]) })
if maxIssues > 0 && len(out) > maxIssues {
out = out[:maxIssues]
}
return out, nil
}
func (c *Client) fetchMetricsIssues(ctx context.Context) ([]model.ServiceIssueContext, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/metrics", nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "text/plain")
// Uptime Kuma documents the API key as the Basic-Auth password; the username is ignored.
req.SetBasicAuth("glpi-ai-agent", c.apiKey)
resp, err := c.http.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
return nil, fmt.Errorf("metrics HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var out []model.ServiceIssueContext
sc := bufio.NewScanner(io.LimitReader(resp.Body, 8<<20))
buf := make([]byte, 64*1024)
sc.Buffer(buf, 1<<20)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if !strings.HasPrefix(line, "monitor_status{") {
continue
}
labels, value, ok := parsePromSample(line)
if !ok {
continue
}
status := heartbeatStatus(int(value))
if status == "up" {
continue
}
id, _ := strconv.ParseInt(labels["monitor_id"], 10, 64)
name := labels["monitor_name"]
if name == "" {
name = labels["monitor_url"]
}
out = append(out, model.ServiceIssueContext{Source: "uptime-kuma", StatusPage: "metrics", Kind: "monitor", MonitorID: id, MonitorName: name, Status: status, Message: labels["monitor_type"]})
}
if err := sc.Err(); err != nil {
return nil, err
}
return out, nil
}
func parsePromSample(line string) (map[string]string, float64, bool) {
close := strings.LastIndex(line, "}")
if close < 0 || close+1 >= len(line) {
return nil, 0, false
}
open := strings.Index(line, "{")
if open < 0 || open >= close {
return nil, 0, false
}
value, err := strconv.ParseFloat(strings.TrimSpace(line[close+1:]), 64)
if err != nil {
return nil, 0, false
}
labels := map[string]string{}
for _, part := range splitPromLabels(line[open+1 : close]) {
kv := strings.SplitN(part, "=", 2)
if len(kv) != 2 {
continue
}
v, err := strconv.Unquote(strings.TrimSpace(kv[1]))
if err != nil {
continue
}
labels[strings.TrimSpace(kv[0])] = v
}
return labels, value, true
}
func splitPromLabels(s string) []string {
var out []string
start := 0
quoted, escaped := false, false
for i, r := range s {
if escaped {
escaped = false
continue
}
if r == '\\' && quoted {
escaped = true
continue
}
if r == '"' {
quoted = !quoted
continue
}
if r == ',' && !quoted {
out = append(out, s[start:i])
start = i + 1
}
}
out = append(out, s[start:])
return out
}
func (c *Client) fetchPage(ctx context.Context, slug string, includeMaintenance bool) ([]model.ServiceIssueContext, error) {
var page statusPageResponse
if err := c.getJSON(ctx, "/api/status-page/"+url.PathEscape(slug), &page); err != nil {
return nil, err
}
var hb heartbeatResponse
if err := c.getJSON(ctx, "/api/status-page/heartbeat/"+url.PathEscape(slug), &hb); err != nil {
return nil, err
}
pageName := strings.TrimSpace(page.Config.Title)
if pageName == "" {
pageName = slug
}
var out []model.ServiceIssueContext
if page.Incident != nil && page.Incident.Pin {
out = append(out, model.ServiceIssueContext{Source: "uptime-kuma", StatusPage: pageName, Kind: "pinned_incident", Status: "incident", IncidentTitle: page.Incident.Title, IncidentContent: page.Incident.Content, LastHeartbeat: page.Incident.LastUpdatedDate})
}
monitorNames := map[int64]string{}
for _, group := range page.PublicGroupList {
for _, mon := range group.MonitorList {
name := strings.TrimSpace(mon.Name)
if group.Name != "" {
name = group.Name + " / " + name
}
monitorNames[mon.ID] = name
}
}
for idRaw, beats := range hb.HeartbeatList {
if len(beats) == 0 {
continue
}
id, _ := strconv.ParseInt(idRaw, 10, 64)
latest := beats[0]
latestAt, _ := time.Parse(time.RFC3339Nano, latest.Time)
for _, beat := range beats[1:] {
if bt, err := time.Parse(time.RFC3339Nano, beat.Time); err == nil && (latestAt.IsZero() || bt.After(latestAt)) {
latest = beat
latestAt = bt
}
}
status := heartbeatStatus(latest.Status)
if status == "up" {
continue
}
issue := model.ServiceIssueContext{Source: "uptime-kuma", StatusPage: pageName, Kind: "monitor", MonitorID: id, MonitorName: monitorNames[id], Status: status, Message: latest.Msg, LastHeartbeat: latest.Time}
for _, key := range []string{fmt.Sprintf("%d_24", id), fmt.Sprintf("%d_24h", id)} {
if v, ok := hb.UptimeList[key]; ok {
issue.Uptime24h = v
break
}
}
out = append(out, issue)
}
if includeMaintenance {
for _, m := range page.MaintenanceList {
if m.Status != "under-maintenance" {
continue
}
out = append(out, model.ServiceIssueContext{Source: "uptime-kuma", StatusPage: pageName, Kind: "maintenance", MonitorID: m.ID, MonitorName: m.Title, Status: "maintenance", Message: m.Description})
}
}
return out, nil
}
func heartbeatStatus(v int) string {
switch v {
case 0:
return "down"
case 1:
return "up"
case 2:
return "pending"
case 3:
return "maintenance"
default:
return "unknown"
}
}
func issueRank(i model.ServiceIssueContext) int {
if i.Kind == "pinned_incident" {
return 100
}
switch i.Status {
case "down":
return 80
case "pending":
return 60
case "maintenance":
return 40
default:
return 10
}
}
func (c *Client) getJSON(ctx context.Context, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
if err != nil {
return err
}
if resp.StatusCode/100 != 2 {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
if err := json.Unmarshal(body, out); err != nil {
return fmt.Errorf("decode JSON: %w", err)
}
return nil
}