Files
homelab/ci/images/kuma-sync/internal/kumaclient/statuspage.go
T
claude-botandClaude Opus 5.5 37c83ed6c2 feat(kuma-sync): declare the public status page as a KumaStatusPage
New namespaced CRD KumaStatusPage (uptime.renner.uno/v1alpha1) and a
reconciler that owns the Kuma status page completely: config, domain
names and the full group/monitor list. Sections select KumaMonitors in
any namespace by label; monitors are ordered by displayName, then
namespace/name, a monitor goes to the first matching section only, and
monitors without a Kuma ID are left out and counted in status.

The reconciler adopts an existing page with the slug, writes only when
the managed content differs (no write per resync), reuses Kuma group IDs
by position so saves do not churn them, reverts UI edits on resync,
deletes the page on resource deletion, and reports Ready=False/Conflict
for a younger resource claiming the same slug. Every KumaMonitor change
to labels, displayName, monitor ID or deletion enqueues all pages.

kumaclient gains a StatusPages interface (Breml + Fake). The socket API
returns no group list, so groups are read from Kuma's public
GET /api/status-page/<slug>, as the bootstrap did. Fields kuma-sync does
not manage (logo, custom CSS, analytics, RSS title, auto refresh
interval) are written back unchanged. autoRefreshInterval is not in the
spec: go-uptime-kuma-client v0.6.2 (latest) never sends it on save.

The Ansible bootstrap no longer creates or edits status pages; the
existing page is left in place for kuma-sync to adopt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 21:03:50 +02:00

110 lines
3.3 KiB
Go

package kumaclient
import (
"context"
"slices"
"strings"
)
// StatusPage is the domain view of a Kuma status page. Fields kuma-sync does
// not manage (logo, custom CSS, analytics, RSS title, auto refresh interval)
// are kept as they are in Kuma.
type StatusPage struct {
// ID is read only.
ID int64
Slug string
Title string
Description string
FooterText string
Theme string
// DomainNames is a set; Kuma returns it in no defined order.
DomainNames []string
ShowTags bool
ShowPoweredBy bool
ShowCertificateExpiry bool
// Groups are in display order.
Groups []StatusGroup
}
// StatusGroup is one section of a status page.
type StatusGroup struct {
// ID is Kuma's group ID; 0 creates a new group on save.
ID int64
Name string
Monitors []PageMonitor
}
// PageMonitor is one monitor of a group, in display order.
type PageMonitor struct {
ID int64
// ShowURL is Kuma's per-page "show clickable link" setting.
ShowURL bool
}
// Normalized returns p with DomainNames trimmed, sorted and deduplicated,
// blank names dropped, and empty slices nil. It is idempotent.
func (p StatusPage) Normalized() StatusPage {
var domains []string
for _, d := range p.DomainNames {
if d = strings.TrimSpace(d); d != "" {
domains = append(domains, d)
}
}
slices.Sort(domains)
p.DomainNames = slices.Compact(domains)
var groups []StatusGroup
for _, g := range p.Groups {
g.Monitors = slices.Clone(g.Monitors)
if len(g.Monitors) == 0 {
g.Monitors = nil
}
groups = append(groups, g)
}
p.Groups = groups
return p
}
// SameContent reports whether p and o hold the same managed content. IDs are
// ignored.
func (p StatusPage) SameContent(o StatusPage) bool {
a, b := p.Normalized(), o.Normalized()
if a.Slug != b.Slug || a.Title != b.Title || a.Description != b.Description ||
a.FooterText != b.FooterText || a.Theme != b.Theme ||
a.ShowTags != b.ShowTags || a.ShowPoweredBy != b.ShowPoweredBy ||
a.ShowCertificateExpiry != b.ShowCertificateExpiry ||
!slices.Equal(a.DomainNames, b.DomainNames) || len(a.Groups) != len(b.Groups) {
return false
}
for i := range a.Groups {
if a.Groups[i].Name != b.Groups[i].Name || !slices.Equal(a.Groups[i].Monitors, b.Groups[i].Monitors) {
return false
}
}
return true
}
func (p StatusPage) clone() StatusPage {
p.DomainNames = slices.Clone(p.DomainNames)
p.Groups = slices.Clone(p.Groups)
for i := range p.Groups {
p.Groups[i].Monitors = slices.Clone(p.Groups[i].Monitors)
}
return p
}
// StatusPages manages Kuma status pages by slug.
type StatusPages interface {
// GetStatusPage returns the page with its groups. It wraps
// ErrStatusPageNotFound when no page has this slug.
GetStatusPage(ctx context.Context, slug string) (StatusPage, error)
// AddStatusPage creates an empty page and returns its ID.
AddStatusPage(ctx context.Context, slug, title string) (int64, error)
// SaveStatusPage writes the managed fields and replaces the group list.
// Every non-zero group ID must belong to the page; groups not listed are
// deleted. It wraps ErrStatusPageNotFound when no page has this slug.
SaveStatusPage(ctx context.Context, p StatusPage) error
// DeleteStatusPage removes the page with its groups and incidents. It
// wraps ErrStatusPageNotFound when no page has this slug.
DeleteStatusPage(ctx context.Context, slug string) error
}