From 0ba2f7af4e683546cedfcfd61e94d48a0f3923ce Mon Sep 17 00:00:00 2001 From: Blake Ridgway Date: Sat, 11 Apr 2026 13:15:22 -0500 Subject: [PATCH] add a page for tickets from gitea --- .env.example | 19 ++++ data/status.json | 4 +- data/uptime.json | 16 +++ internal/gitea/gitea.go | 191 +++++++++++++++++++++++++++++++++ internal/handler/admin.go | 77 +++++++++++++ internal/handler/handler.go | 12 +++ internal/handler/public.go | 20 +++- static/css/style.css | 59 ++++++++++ templates/admin/dashboard.html | 1 + templates/admin/outages.html | 63 +++++++++++ templates/status.html | 15 +++ 11 files changed, 471 insertions(+), 6 deletions(-) create mode 100644 internal/gitea/gitea.go create mode 100644 templates/admin/outages.html diff --git a/.env.example b/.env.example index 7d6119b..924de57 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,22 @@ CONTACT_EMAIL=hire@ridgwaysystems.org # How often (in minutes) to HTTP-check services with a check_url in status.json. # Default: 5 CHECK_INTERVAL=5 + +# ------------------------------------------------------------------ +# Gitea integration (planned outage tagger) +# ------------------------------------------------------------------ + +# Base URL of your Gitea instance (no trailing slash). +GITEA_URL=https://git.ridgwaysystems.org + +# Gitea personal access token with "issues" write scope. +# Generate at: /user/settings/applications +GITEA_TOKEN=your-gitea-token-here + +# Owner (org or user) and repo to pull tickets from. +GITEA_OWNER=ridgway-infra +GITEA_REPO=tickets + +# Label name applied to planned-outage tickets (default: planned-outage). +# The label is created automatically if it does not exist. +GITEA_LABEL=planned-outage diff --git a/data/status.json b/data/status.json index 998e31d..603d853 100644 --- a/data/status.json +++ b/data/status.json @@ -1,5 +1,5 @@ { - "last_checked": "2026-03-27T13:17:04.05294829Z", + "last_checked": "2026-04-11T14:36:31.836073877Z", "services": [ { "name": "Web (httpd)", @@ -12,7 +12,7 @@ "description": "git.ridgwaysystems.org", "url": "https://git.ridgwaysystems.org", "check_url": "https://git.ridgwaysystems.org", - "status": "down" + "status": "up" }, { "name": "DNS (unbound)", diff --git a/data/uptime.json b/data/uptime.json index 8a71d6e..a73d929 100644 --- a/data/uptime.json +++ b/data/uptime.json @@ -126,5 +126,21 @@ "VPN (WireGuard)": "up", "Web (httpd)": "up" } + }, + { + "time": "2026-04-11T14:00:00Z", + "statuses": { + "DNS (nsd)": "up", + "DNS (unbound)": "up", + "Email (OpenSMTPD)": "up", + "Game Servers": "up", + "Gitea": "up", + "Grafana": "up", + "Jellyfin": "up", + "Matrix (Conduit)": "up", + "Monitoring (Prometheus)": "up", + "VPN (WireGuard)": "up", + "Web (httpd)": "up" + } } ] \ No newline at end of file diff --git a/internal/gitea/gitea.go b/internal/gitea/gitea.go new file mode 100644 index 0000000..950f1db --- /dev/null +++ b/internal/gitea/gitea.go @@ -0,0 +1,191 @@ +// Package gitea provides a minimal Gitea API client for label management. +package gitea + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "time" +) + +// Client is a minimal Gitea API client. +type Client struct { + BaseURL string + Token string + http *http.Client +} + +// New creates a new Gitea client. +func New(baseURL, token string) *Client { + return &Client{ + BaseURL: baseURL, + Token: token, + http: &http.Client{Timeout: 10 * time.Second}, + } +} + +// Issue represents a Gitea issue. +type Issue struct { + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` + Labels []Label `json:"labels"` +} + +// HasLabel reports whether the issue carries a label with the given name. +func (i Issue) HasLabel(name string) bool { + for _, l := range i.Labels { + if l.Name == name { + return true + } + } + return false +} + +// LabelID returns the ID of the first label matching name, or 0. +func (i Issue) LabelID(name string) int64 { + for _, l := range i.Labels { + if l.Name == name { + return l.ID + } + } + return 0 +} + +// Label represents a Gitea label. +type Label struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +func (c *Client) newRequest(method, path string, body []byte) (*http.Request, error) { + var req *http.Request + var err error + if body != nil { + req, err = http.NewRequest(method, c.BaseURL+path, bytes.NewReader(body)) + } else { + req, err = http.NewRequest(method, c.BaseURL+path, nil) + } + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "token "+c.Token) + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + return req, nil +} + +// ListOpenIssues returns open issues for the given owner/repo (up to 50). +func (c *Client) ListOpenIssues(owner, repo string) ([]Issue, error) { + return c.listIssues(owner, repo, "") +} + +// ListIssuesByLabel returns open issues that carry the named label (up to 50). +func (c *Client) ListIssuesByLabel(owner, repo, label string) ([]Issue, error) { + return c.listIssues(owner, repo, label) +} + +func (c *Client) listIssues(owner, repo, label string) ([]Issue, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?type=issues&state=open&limit=50", owner, repo) + if label != "" { + path += "&labels=" + label + } + req, err := c.newRequest("GET", path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("gitea: list issues: %s", resp.Status) + } + var issues []Issue + if err := json.NewDecoder(resp.Body).Decode(&issues); err != nil { + return nil, err + } + return issues, nil +} + +// GetOrCreateLabel finds a repo label by name, creating it if absent. +func (c *Client) GetOrCreateLabel(owner, repo, name, color string) (*Label, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/labels", owner, repo) + req, err := c.newRequest("GET", path, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var labels []Label + json.NewDecoder(resp.Body).Decode(&labels) //nolint:errcheck + for _, l := range labels { + if l.Name == name { + return &l, nil + } + } + + // Create the label. + body, _ := json.Marshal(map[string]string{"name": name, "color": color}) + req2, err := c.newRequest("POST", path, body) + if err != nil { + return nil, err + } + resp2, err := c.http.Do(req2) + if err != nil { + return nil, err + } + defer resp2.Body.Close() + if resp2.StatusCode >= 300 { + return nil, fmt.Errorf("gitea: create label: %s", resp2.Status) + } + var label Label + if err := json.NewDecoder(resp2.Body).Decode(&label); err != nil { + return nil, err + } + return &label, nil +} + +// AddLabel adds a label to an issue by label ID. +func (c *Client) AddLabel(owner, repo string, issueNum int, labelID int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels", owner, repo, issueNum) + body, _ := json.Marshal(map[string][]int64{"labels": {labelID}}) + req, err := c.newRequest("POST", path, body) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 { + return fmt.Errorf("gitea: add label: %s", resp.Status) + } + return nil +} + +// RemoveLabel removes a label from an issue by label ID. +func (c *Client) RemoveLabel(owner, repo string, issueNum int, labelID int64) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/labels/%d", owner, repo, issueNum, labelID) + req, err := c.newRequest("DELETE", path, nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 300 && resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("gitea: remove label: %s", resp.Status) + } + return nil +} diff --git a/internal/handler/admin.go b/internal/handler/admin.go index a2a69ac..445683f 100644 --- a/internal/handler/admin.go +++ b/internal/handler/admin.go @@ -7,11 +7,13 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "time" "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/changelog" + "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/status" ) @@ -91,6 +93,13 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) { h.requireAuth(h.adminNewsletter)(w, r) } + case path == "/admin/outages": + if r.Method == http.MethodPost { + h.requireAuth(h.adminOutagesPost)(w, r) + } else { + h.requireAuth(h.adminOutagesGet)(w, r) + } + default: h.renderErr(w, http.StatusNotFound, "Admin page not found.") } @@ -549,6 +558,74 @@ func (h *Handler) adminChangelogDelete(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/admin/changelog?flash=Entry+deleted", http.StatusSeeOther) } +// --- Outage tagger --- + +type adminOutagesData struct { + Issues []gitea.Issue + Label string + Flash string + Error string +} + +func (h *Handler) adminOutagesGet(w http.ResponseWriter, r *http.Request) { + if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { + h.render(w, "admin-outages", adminOutagesData{Error: "Gitea not configured. Set GITEA_URL, GITEA_TOKEN, GITEA_OWNER, and GITEA_REPO."}) + return + } + issues, err := h.gitea.ListOpenIssues(h.giteaOwner, h.giteaRepo) + if err != nil { + h.render(w, "admin-outages", adminOutagesData{Error: "Could not load issues: " + err.Error()}) + return + } + h.render(w, "admin-outages", adminOutagesData{ + Issues: issues, + Label: h.giteaLabel, + Flash: r.URL.Query().Get("flash"), + }) +} + +func (h *Handler) adminOutagesPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + h.renderErr(w, http.StatusBadRequest, "Bad form data.") + return + } + if h.gitea == nil || h.giteaOwner == "" || h.giteaRepo == "" { + h.renderErr(w, http.StatusServiceUnavailable, "Gitea not configured.") + return + } + + action := r.FormValue("action") // "add" or "remove" + issueNum, err := strconv.Atoi(r.FormValue("issue")) + if err != nil || issueNum <= 0 { + h.renderErr(w, http.StatusBadRequest, "Invalid issue number.") + return + } + + label, err := h.gitea.GetOrCreateLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel, "#e11d48") + if err != nil { + h.renderErr(w, http.StatusInternalServerError, "Label error: "+err.Error()) + return + } + + switch action { + case "add": + if err := h.gitea.AddLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not add label: "+err.Error()) + return + } + case "remove": + if err := h.gitea.RemoveLabel(h.giteaOwner, h.giteaRepo, issueNum, label.ID); err != nil { + h.renderErr(w, http.StatusInternalServerError, "Could not remove label: "+err.Error()) + return + } + default: + h.renderErr(w, http.StatusBadRequest, "Unknown action.") + return + } + + http.Redirect(w, r, "/admin/outages?flash=Updated+#"+strconv.Itoa(issueNum), http.StatusSeeOther) +} + // sanitizeSlug ensures a slug is filesystem-safe. func sanitizeSlug(s string) string { s = strings.ToLower(strings.TrimSpace(s)) diff --git a/internal/handler/handler.go b/internal/handler/handler.go index cbbd231..955651d 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -12,6 +12,7 @@ import ( "time" "ridgwaysystems.org/website/internal/blog" + "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/newsletter" "ridgwaysystems.org/website/internal/ratelimit" "ridgwaysystems.org/website/internal/status" @@ -27,6 +28,10 @@ type Handler struct { contactEmail string devMode bool postLimit *ratelimit.Limiter // rate-limits contact + newsletter POSTs + gitea *gitea.Client + giteaOwner string + giteaRepo string + giteaLabel string } // New creates a Handler. dataDir is the path to the data/ directory. @@ -39,6 +44,12 @@ func New(store *blog.Store, news *newsletter.Store, dataDir string) *Handler { contactEmail: getenv("CONTACT_EMAIL", "hire@ridgwaysystems.org"), devMode: os.Getenv("DEV") == "1", postLimit: ratelimit.New(10*time.Minute, 5), + giteaOwner: getenv("GITEA_OWNER", ""), + giteaRepo: getenv("GITEA_REPO", ""), + giteaLabel: getenv("GITEA_LABEL", "planned-outage"), + } + if url := os.Getenv("GITEA_URL"); url != "" { + h.gitea = gitea.New(url, os.Getenv("GITEA_TOKEN")) } if !h.devMode { h.templates = mustLoadTemplates() @@ -95,6 +106,7 @@ func mustLoadTemplates() map[string]*template.Template { {"admin-newsletter", "templates/admin/newsletter.html"}, {"admin-changelog", "templates/admin/changelog.html"}, {"admin-changelog-editor", "templates/admin/changelog-editor.html"}, + {"admin-outages", "templates/admin/outages.html"}, } for _, p := range pages { diff --git a/internal/handler/public.go b/internal/handler/public.go index 73b10a1..c389a28 100644 --- a/internal/handler/public.go +++ b/internal/handler/public.go @@ -13,6 +13,7 @@ import ( "ridgwaysystems.org/website/internal/blog" "ridgwaysystems.org/website/internal/changelog" "ridgwaysystems.org/website/internal/feed" + "ridgwaysystems.org/website/internal/gitea" "ridgwaysystems.org/website/internal/mailer" "ridgwaysystems.org/website/internal/status" "ridgwaysystems.org/website/internal/uptime" @@ -166,9 +167,10 @@ type serviceHistory struct { // statusData is passed to the status template. type statusData struct { - Page *status.Page - LastChecked string - History map[string]serviceHistory // keyed by service name + Page *status.Page + LastChecked string + History map[string]serviceHistory // keyed by service name + PlannedOutages []gitea.Issue } func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { @@ -188,7 +190,17 @@ func (h *Handler) Status(w http.ResponseWriter, r *http.Request) { UptimePct: uptime.UptimePct(uptimePath, svc.Name), } } - h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history}) + // Fetch planned outages from Gitea — best-effort, failure is non-fatal. + var plannedOutages []gitea.Issue + if h.gitea != nil && h.giteaOwner != "" && h.giteaRepo != "" { + if issues, err := h.gitea.ListIssuesByLabel(h.giteaOwner, h.giteaRepo, h.giteaLabel); err == nil { + plannedOutages = issues + } else { + log.Printf("status: gitea planned outages: %v", err) + } + } + + h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history, PlannedOutages: plannedOutages}) } // changelogData is passed to the changelog template. diff --git a/static/css/style.css b/static/css/style.css index 8c73a3e..1d1da93 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -616,6 +616,65 @@ blockquote { color: var(--text-muted); } +/* === Planned Maintenance === */ + +.planned-outages { + margin-bottom: 2rem; +} + +.planned-outages h2 { + font-size: 1rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-muted); + margin-bottom: 0.75rem; +} + +.outage-list { + list-style: none; + margin: 0; + padding: 0; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; +} + +.outage-item { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.65rem 1rem; + border-bottom: 1px solid var(--border); + font-size: 0.9rem; +} + +.outage-item:last-child { border-bottom: none; } + +.outage-indicator { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + background: #e0a000; + flex-shrink: 0; +} + +.outage-link { + flex: 1; + color: var(--text); + text-decoration: none; +} + +.outage-link:hover { text-decoration: underline; } + +.outage-num { + font-family: var(--font-mono); + font-size: 0.78rem; + color: var(--text-muted); + flex-shrink: 0; +} + /* === Infrastructure Page === */ .infra-section { margin-bottom: 3rem; } diff --git a/templates/admin/dashboard.html b/templates/admin/dashboard.html index 2d170c0..21a3282 100644 --- a/templates/admin/dashboard.html +++ b/templates/admin/dashboard.html @@ -10,6 +10,7 @@ Uploads Newsletter Changelog + Outages View Site
diff --git a/templates/admin/outages.html b/templates/admin/outages.html new file mode 100644 index 0000000..2c36087 --- /dev/null +++ b/templates/admin/outages.html @@ -0,0 +1,63 @@ +{{define "title"}}Planned Outages — Admin{{end}} + +{{define "content"}} +
+
+

Planned Outages

+
+ Back + Refresh +
+
+ + {{if .Flash}}

{{.Flash}}

{{end}} + {{if .Error}}

{{.Error}}

{{end}} + + {{if .Issues}} +

Tag open tickets with {{.Label}} to mark them as planned outages.

+ + + + + + + + + + + {{range .Issues}} + {{$tagged := .HasLabel $.Label}} + + + + + + + {{end}} + +
#TitleLabelsAction
+ #{{.Number}} + {{.Title}} + {{range .Labels}} + {{.Name}} + {{end}} + + {{if $tagged}} + + + + + + {{else}} +
+ + + +
+ {{end}} +
+ {{else if not .Error}} +

No open issues found.

+ {{end}} +
+{{end}} diff --git a/templates/status.html b/templates/status.html index 29083b8..d43cb8b 100644 --- a/templates/status.html +++ b/templates/status.html @@ -9,6 +9,21 @@ {{end}} +{{if .PlannedOutages}} +
+

Planned Maintenance

+
    + {{range .PlannedOutages}} +
  • + + {{.Title}} + #{{.Number}} +
  • + {{end}} +
+
+{{end}} + {{if .Page.Services}}
    {{range .Page.Services}}