Lots of changes to the website
This commit is contained in:
111
internal/changelog/changelog.go
Normal file
111
internal/changelog/changelog.go
Normal file
@@ -0,0 +1,111 @@
|
||||
// Package changelog manages the infrastructure changelog log.
|
||||
package changelog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Categories available for changelog entries.
|
||||
var Categories = []string{"hardware", "network", "software", "migration"}
|
||||
|
||||
// Entry is a single changelog entry.
|
||||
type Entry struct {
|
||||
ID string `json:"id"`
|
||||
Date string `json:"date"` // YYYY-MM-DD
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"` // hardware, network, software, migration
|
||||
}
|
||||
|
||||
// Log holds all changelog entries.
|
||||
type Log struct {
|
||||
Entries []Entry `json:"entries"`
|
||||
}
|
||||
|
||||
// Load reads the changelog from path. Returns an empty log if the file does not exist.
|
||||
func Load(path string) (*Log, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &Log{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var l Log
|
||||
if err := json.Unmarshal(raw, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(l.Entries, func(i, j int) bool {
|
||||
return l.Entries[i].Date > l.Entries[j].Date
|
||||
})
|
||||
return &l, nil
|
||||
}
|
||||
|
||||
// save writes the log to path without sorting (internal use).
|
||||
func save(path string, l *Log) error {
|
||||
raw, err := json.MarshalIndent(l, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
}
|
||||
|
||||
// Add appends a new entry and saves.
|
||||
func Add(path string, e Entry) error {
|
||||
l, err := Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.ID = fmt.Sprintf("%d", time.Now().UnixNano())
|
||||
l.Entries = append(l.Entries, e)
|
||||
return save(path, l)
|
||||
}
|
||||
|
||||
// Update replaces an entry by ID and saves.
|
||||
func Update(path string, e Entry) error {
|
||||
l, err := Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, entry := range l.Entries {
|
||||
if entry.ID == e.ID {
|
||||
l.Entries[i] = e
|
||||
return save(path, l)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("entry %s not found", e.ID)
|
||||
}
|
||||
|
||||
// Delete removes an entry by ID and saves.
|
||||
func Delete(path string, id string) error {
|
||||
l, err := Load(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kept := l.Entries[:0]
|
||||
for _, e := range l.Entries {
|
||||
if e.ID != id {
|
||||
kept = append(kept, e)
|
||||
}
|
||||
}
|
||||
l.Entries = kept
|
||||
return save(path, l)
|
||||
}
|
||||
|
||||
// Get returns a single entry by ID.
|
||||
func Get(path string, id string) (*Entry, error) {
|
||||
l, err := Load(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range l.Entries {
|
||||
if e.ID == id {
|
||||
return &e, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("entry %s not found", id)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
"ridgwaysystems.org/website/internal/uptime"
|
||||
)
|
||||
|
||||
// Start launches the background checker. It checks services every interval
|
||||
@@ -26,18 +27,19 @@ func run(dataDir string, interval time.Duration) {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
path := filepath.Join(dataDir, "status.json")
|
||||
statusPath := filepath.Join(dataDir, "status.json")
|
||||
uptimePath := filepath.Join(dataDir, "uptime.json")
|
||||
|
||||
for {
|
||||
check(client, path)
|
||||
check(client, statusPath, uptimePath)
|
||||
time.Sleep(interval)
|
||||
}
|
||||
}
|
||||
|
||||
func check(client *http.Client, path string) {
|
||||
page, err := status.Load(path)
|
||||
func check(client *http.Client, statusPath, uptimePath string) {
|
||||
page, err := status.Load(statusPath)
|
||||
if err != nil {
|
||||
log.Printf("checker: load %s: %v", path, err)
|
||||
log.Printf("checker: load %s: %v", statusPath, err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -57,16 +59,24 @@ func check(client *http.Client, path string) {
|
||||
}
|
||||
|
||||
if changed {
|
||||
if err := status.Save(path, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", path, err)
|
||||
if err := status.Save(statusPath, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", statusPath, err)
|
||||
}
|
||||
} else {
|
||||
// Still update last_checked timestamp so the status page shows freshness
|
||||
page.LastChecked = time.Now().UTC()
|
||||
if err := status.Save(path, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", path, err)
|
||||
if err := status.Save(statusPath, page); err != nil {
|
||||
log.Printf("checker: save %s: %v", statusPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Record hourly uptime snapshot.
|
||||
statuses := make(map[string]string, len(page.Services))
|
||||
for _, svc := range page.Services {
|
||||
statuses[svc.Name] = svc.Status
|
||||
}
|
||||
if err := uptime.Record(uptimePath, statuses); err != nil {
|
||||
log.Printf("checker: uptime record: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func probe(client *http.Client, url string) string {
|
||||
@@ -82,7 +92,6 @@ func probe(client *http.Client, url string) string {
|
||||
case resp.StatusCode >= 500:
|
||||
return "down"
|
||||
default:
|
||||
// 4xx could mean the service is up but the URL is wrong; treat as degraded
|
||||
return "degraded"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
@@ -12,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/changelog"
|
||||
"ridgwaysystems.org/website/internal/newsletter"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
@@ -55,6 +55,26 @@ func (h *Handler) AdminRouter(w http.ResponseWriter, r *http.Request) {
|
||||
h.requireAuth(h.adminStatusGet)(w, r)
|
||||
}
|
||||
|
||||
case path == "/admin/changelog":
|
||||
h.requireAuth(h.adminChangelogList)(w, r)
|
||||
|
||||
case path == "/admin/changelog/new":
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminChangelogNewPost)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminChangelogNewGet)(w, r)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(path, "/admin/changelog/edit/"):
|
||||
if r.Method == http.MethodPost {
|
||||
h.requireAuth(h.adminChangelogEditPost)(w, r)
|
||||
} else {
|
||||
h.requireAuth(h.adminChangelogEditGet)(w, r)
|
||||
}
|
||||
|
||||
case strings.HasPrefix(path, "/admin/changelog/delete/"):
|
||||
h.requireAuth(h.adminChangelogDelete)(w, r)
|
||||
|
||||
case path == "/admin/preview":
|
||||
h.requireAuth(h.adminPreview)(w, r)
|
||||
|
||||
@@ -208,7 +228,7 @@ func (h *Handler) adminDeletePost(w http.ResponseWriter, r *http.Request) {
|
||||
// --- Status editor ---
|
||||
|
||||
type adminStatusData struct {
|
||||
JSON string
|
||||
Page *status.Page
|
||||
Error string
|
||||
Flash string
|
||||
}
|
||||
@@ -219,9 +239,7 @@ func (h *Handler) adminStatusGet(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "admin-status", adminStatusData{Error: "Could not load status.json: " + err.Error()})
|
||||
return
|
||||
}
|
||||
raw, _ := json.MarshalIndent(p, "", " ")
|
||||
flash := r.URL.Query().Get("flash")
|
||||
h.render(w, "admin-status", adminStatusData{JSON: string(raw), Flash: flash})
|
||||
h.render(w, "admin-status", adminStatusData{Page: p, Flash: r.URL.Query().Get("flash")})
|
||||
}
|
||||
|
||||
func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -229,15 +247,19 @@ func (h *Handler) adminStatusPost(w http.ResponseWriter, r *http.Request) {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
raw := r.FormValue("json")
|
||||
var p status.Page
|
||||
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Invalid JSON: " + err.Error()})
|
||||
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
|
||||
if err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{Error: "Could not load status: " + err.Error()})
|
||||
return
|
||||
}
|
||||
p.LastChecked = time.Now().UTC()
|
||||
if err := status.Save(filepath.Join(h.dataDir, "status.json"), &p); err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{JSON: raw, Error: "Save failed: " + err.Error()})
|
||||
for i := range p.Services {
|
||||
if s := r.FormValue(fmt.Sprintf("status_%d", i)); s != "" {
|
||||
p.Services[i].Status = s
|
||||
}
|
||||
p.Services[i].Note = strings.TrimSpace(r.FormValue(fmt.Sprintf("note_%d", i)))
|
||||
}
|
||||
if err := status.Save(filepath.Join(h.dataDir, "status.json"), p); err != nil {
|
||||
h.render(w, "admin-status", adminStatusData{Page: p, Error: "Save failed: " + err.Error()})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/status?flash=Saved", http.StatusSeeOther)
|
||||
@@ -416,6 +438,121 @@ func (h *Handler) adminNewsletterDelete(w http.ResponseWriter, r *http.Request)
|
||||
http.Redirect(w, r, "/admin/newsletter?flash=Removed", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// --- Changelog admin ---
|
||||
|
||||
type adminChangelogData struct {
|
||||
Log *changelog.Log
|
||||
Entry *changelog.Entry
|
||||
Categories []string
|
||||
Error string
|
||||
Flash string
|
||||
IsNew bool
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogList(w http.ResponseWriter, r *http.Request) {
|
||||
l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json"))
|
||||
if err != nil {
|
||||
l = &changelog.Log{}
|
||||
}
|
||||
h.render(w, "admin-changelog", adminChangelogData{
|
||||
Log: l,
|
||||
Flash: r.URL.Query().Get("flash"),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogNewGet(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Categories: changelog.Categories,
|
||||
IsNew: true,
|
||||
Entry: &changelog.Entry{Date: time.Now().Format("2006-01-02")},
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogNewPost(w http.ResponseWriter, r *http.Request) {
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
e := changelog.Entry{
|
||||
Date: r.FormValue("date"),
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
Category: r.FormValue("category"),
|
||||
}
|
||||
if e.Title == "" || e.Date == "" {
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Entry: &e, Categories: changelog.Categories, IsNew: true,
|
||||
Error: "Date and title are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := changelog.Add(filepath.Join(h.dataDir, "changelog.json"), e); err != nil {
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Entry: &e, Categories: changelog.Categories, IsNew: true,
|
||||
Error: "Failed to save: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/changelog?flash=Entry+created", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogEditGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/")
|
||||
e, err := changelog.Get(filepath.Join(h.dataDir, "changelog.json"), id)
|
||||
if err != nil {
|
||||
h.renderErr(w, http.StatusNotFound, "Entry not found.")
|
||||
return
|
||||
}
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Entry: e,
|
||||
Categories: changelog.Categories,
|
||||
IsNew: false,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogEditPost(w http.ResponseWriter, r *http.Request) {
|
||||
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/edit/")
|
||||
if err := r.ParseForm(); err != nil {
|
||||
h.renderErr(w, http.StatusBadRequest, "Bad form data.")
|
||||
return
|
||||
}
|
||||
e := changelog.Entry{
|
||||
ID: id,
|
||||
Date: r.FormValue("date"),
|
||||
Title: strings.TrimSpace(r.FormValue("title")),
|
||||
Description: strings.TrimSpace(r.FormValue("description")),
|
||||
Category: r.FormValue("category"),
|
||||
}
|
||||
if e.Title == "" || e.Date == "" {
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Entry: &e, Categories: changelog.Categories,
|
||||
Error: "Date and title are required.",
|
||||
})
|
||||
return
|
||||
}
|
||||
if err := changelog.Update(filepath.Join(h.dataDir, "changelog.json"), e); err != nil {
|
||||
h.render(w, "admin-changelog-editor", adminChangelogData{
|
||||
Entry: &e, Categories: changelog.Categories,
|
||||
Error: "Failed to save: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/changelog?flash=Entry+saved", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
func (h *Handler) adminChangelogDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
h.renderErr(w, http.StatusMethodNotAllowed, "POST required.")
|
||||
return
|
||||
}
|
||||
id := strings.TrimPrefix(r.URL.Path, "/admin/changelog/delete/")
|
||||
if err := changelog.Delete(filepath.Join(h.dataDir, "changelog.json"), id); err != nil {
|
||||
h.renderErr(w, http.StatusInternalServerError, "Delete failed: "+err.Error())
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, "/admin/changelog?flash=Entry+deleted", http.StatusSeeOther)
|
||||
}
|
||||
|
||||
// sanitizeSlug ensures a slug is filesystem-safe.
|
||||
func sanitizeSlug(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
|
||||
@@ -8,11 +8,13 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/newsletter"
|
||||
"ridgwaysystems.org/website/internal/ratelimit"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
)
|
||||
|
||||
// Handler holds shared dependencies for all HTTP handlers.
|
||||
@@ -84,12 +86,15 @@ func mustLoadTemplates() map[string]*template.Template {
|
||||
{"uses", "templates/uses.html"},
|
||||
{"projects", "templates/projects.html"},
|
||||
{"error", "templates/error.html"},
|
||||
{"changelog", "templates/changelog.html"},
|
||||
{"admin-login", "templates/admin/login.html"},
|
||||
{"admin-dashboard", "templates/admin/dashboard.html"},
|
||||
{"admin-editor", "templates/admin/editor.html"},
|
||||
{"admin-status", "templates/admin/status.html"},
|
||||
{"admin-uploads", "templates/admin/uploads.html"},
|
||||
{"admin-newsletter", "templates/admin/newsletter.html"},
|
||||
{"admin-changelog", "templates/admin/changelog.html"},
|
||||
{"admin-changelog-editor", "templates/admin/changelog-editor.html"},
|
||||
}
|
||||
|
||||
for _, p := range pages {
|
||||
@@ -103,6 +108,56 @@ func mustLoadTemplates() map[string]*template.Template {
|
||||
return m
|
||||
}
|
||||
|
||||
// baseEnvelope wraps page-specific data with shared layout data for the base template.
|
||||
type baseEnvelope struct {
|
||||
Banner *siteBanner
|
||||
Inner any
|
||||
}
|
||||
|
||||
// siteBanner holds the data for the site-wide status banner.
|
||||
type siteBanner struct {
|
||||
Level string // "danger" | "warning"
|
||||
Message string
|
||||
}
|
||||
|
||||
// computeBanner loads status.json and returns a banner if any services are down or degraded.
|
||||
func (h *Handler) computeBanner() *siteBanner {
|
||||
p, err := status.Load(filepath.Join(h.dataDir, "status.json"))
|
||||
if err != nil || p == nil {
|
||||
return nil
|
||||
}
|
||||
var down, degraded []string
|
||||
for _, s := range p.Services {
|
||||
switch s.Status {
|
||||
case "down":
|
||||
down = append(down, s.Name)
|
||||
case "degraded":
|
||||
degraded = append(degraded, s.Name)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(down) > 0:
|
||||
noun := "are unavailable"
|
||||
if len(down) == 1 {
|
||||
noun = "is unavailable"
|
||||
}
|
||||
return &siteBanner{
|
||||
Level: "danger",
|
||||
Message: "Major Outage \u2014 " + strings.Join(down, ", ") + " " + noun + ".",
|
||||
}
|
||||
case len(degraded) > 0:
|
||||
noun := "are experiencing issues"
|
||||
if len(degraded) == 1 {
|
||||
noun = "is experiencing issues"
|
||||
}
|
||||
return &siteBanner{
|
||||
Level: "warning",
|
||||
Message: "Partial Outage \u2014 " + strings.Join(degraded, ", ") + " " + noun + ".",
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *Handler) render(w http.ResponseWriter, name string, data any) {
|
||||
t := h.tmpl(name)
|
||||
if t == nil {
|
||||
@@ -110,7 +165,7 @@ func (h *Handler) render(w http.ResponseWriter, name string, data any) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil {
|
||||
log.Printf("render %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
@@ -132,7 +187,7 @@ func (h *Handler) renderErr(w http.ResponseWriter, code int, msg string) {
|
||||
code, http.StatusText(code), msg)
|
||||
return
|
||||
}
|
||||
if err := t.ExecuteTemplate(w, "base", data); err != nil {
|
||||
if err := t.ExecuteTemplate(w, "base", baseEnvelope{Banner: h.computeBanner(), Inner: data}); err != nil {
|
||||
log.Printf("renderErr %d: %v", code, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"time"
|
||||
|
||||
"ridgwaysystems.org/website/internal/blog"
|
||||
"ridgwaysystems.org/website/internal/changelog"
|
||||
"ridgwaysystems.org/website/internal/feed"
|
||||
"ridgwaysystems.org/website/internal/mailer"
|
||||
"ridgwaysystems.org/website/internal/status"
|
||||
"ridgwaysystems.org/website/internal/uptime"
|
||||
)
|
||||
|
||||
const postsPerPage = 10
|
||||
@@ -143,7 +145,7 @@ func (h *Handler) Feed(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "feed unavailable", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on OpenBSD.", posts)
|
||||
rss, err := feed.RSS(h.siteURL, "Ridgway Systems", "A homelab built on FreeBSD.", posts)
|
||||
if err != nil {
|
||||
http.Error(w, "feed error", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -156,10 +158,17 @@ func (h *Handler) Infrastructure(w http.ResponseWriter, r *http.Request) {
|
||||
h.render(w, "infrastructure", nil)
|
||||
}
|
||||
|
||||
// serviceHistory bundles per-service uptime data for the status template.
|
||||
type serviceHistory struct {
|
||||
Blocks []uptime.DayBlock
|
||||
UptimePct float64
|
||||
}
|
||||
|
||||
// statusData is passed to the status template.
|
||||
type statusData struct {
|
||||
Page *status.Page
|
||||
LastChecked string
|
||||
History map[string]serviceHistory // keyed by service name
|
||||
}
|
||||
|
||||
func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -171,7 +180,28 @@ func (h *Handler) Status(w http.ResponseWriter, r *http.Request) {
|
||||
if !p.LastChecked.IsZero() {
|
||||
lastChecked = p.LastChecked.UTC().Format("2006-01-02 15:04 UTC")
|
||||
}
|
||||
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked})
|
||||
uptimePath := filepath.Join(h.dataDir, "uptime.json")
|
||||
history := make(map[string]serviceHistory, len(p.Services))
|
||||
for _, svc := range p.Services {
|
||||
history[svc.Name] = serviceHistory{
|
||||
Blocks: uptime.ServiceHistory(uptimePath, svc.Name),
|
||||
UptimePct: uptime.UptimePct(uptimePath, svc.Name),
|
||||
}
|
||||
}
|
||||
h.render(w, "status", statusData{Page: p, LastChecked: lastChecked, History: history})
|
||||
}
|
||||
|
||||
// changelogData is passed to the changelog template.
|
||||
type changelogData struct {
|
||||
Log *changelog.Log
|
||||
}
|
||||
|
||||
func (h *Handler) Changelog(w http.ResponseWriter, r *http.Request) {
|
||||
l, err := changelog.Load(filepath.Join(h.dataDir, "changelog.json"))
|
||||
if err != nil {
|
||||
l = &changelog.Log{}
|
||||
}
|
||||
h.render(w, "changelog", changelogData{Log: l})
|
||||
}
|
||||
|
||||
func (h *Handler) About(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
142
internal/uptime/uptime.go
Normal file
142
internal/uptime/uptime.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package uptime stores hourly service status snapshots and computes uptime history.
|
||||
package uptime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Snapshot records the status of all services at a point in time.
|
||||
type Snapshot struct {
|
||||
Time time.Time `json:"time"`
|
||||
Statuses map[string]string `json:"statuses"` // service name → status
|
||||
}
|
||||
|
||||
// DayBlock represents one day's aggregated status for display.
|
||||
type DayBlock struct {
|
||||
Date string // YYYY-MM-DD
|
||||
Status string // worst status seen that day: up, degraded, down, or none
|
||||
}
|
||||
|
||||
const maxDays = 30
|
||||
|
||||
// Record appends a snapshot to the history file, pruning entries older than 30 days.
|
||||
// It is safe to call on every checker run; it deduplicates by hour.
|
||||
func Record(path string, statuses map[string]string) error {
|
||||
snapshots, _ := load(path)
|
||||
|
||||
now := time.Now().UTC()
|
||||
currentHour := now.Truncate(time.Hour)
|
||||
|
||||
// Skip if we already have a snapshot for this hour.
|
||||
if len(snapshots) > 0 {
|
||||
last := snapshots[len(snapshots)-1]
|
||||
if last.Time.Truncate(time.Hour).Equal(currentHour) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, Snapshot{
|
||||
Time: currentHour,
|
||||
Statuses: statuses,
|
||||
})
|
||||
|
||||
// Prune entries older than 30 days.
|
||||
cutoff := now.AddDate(0, 0, -maxDays)
|
||||
kept := snapshots[:0]
|
||||
for _, s := range snapshots {
|
||||
if s.Time.After(cutoff) {
|
||||
kept = append(kept, s)
|
||||
}
|
||||
}
|
||||
|
||||
return save(path, kept)
|
||||
}
|
||||
|
||||
// ServiceHistory returns the last 30 daily blocks for a named service, oldest first.
|
||||
func ServiceHistory(path string, serviceName string) []DayBlock {
|
||||
snapshots, _ := load(path)
|
||||
|
||||
// Build a map of date → worst status.
|
||||
dayStatus := make(map[string]string)
|
||||
for _, s := range snapshots {
|
||||
date := s.Time.UTC().Format("2006-01-02")
|
||||
st, ok := s.Statuses[serviceName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
existing := dayStatus[date]
|
||||
dayStatus[date] = worst(existing, st)
|
||||
}
|
||||
|
||||
// Build the last 30 days in order.
|
||||
now := time.Now().UTC()
|
||||
blocks := make([]DayBlock, maxDays)
|
||||
for i := range blocks {
|
||||
day := now.AddDate(0, 0, -(maxDays - 1 - i))
|
||||
date := day.Format("2006-01-02")
|
||||
status := dayStatus[date]
|
||||
if status == "" {
|
||||
status = "none"
|
||||
}
|
||||
blocks[i] = DayBlock{Date: date, Status: status}
|
||||
}
|
||||
return blocks
|
||||
}
|
||||
|
||||
// UptimePct returns the percentage of hourly snapshots where the service was "up"
|
||||
// over the last 30 days. Returns -1 if there is no data.
|
||||
func UptimePct(path string, serviceName string) float64 {
|
||||
snapshots, _ := load(path)
|
||||
if len(snapshots) == 0 {
|
||||
return -1
|
||||
}
|
||||
total, up := 0, 0
|
||||
for _, s := range snapshots {
|
||||
st, ok := s.Statuses[serviceName]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if st == "up" {
|
||||
up++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return -1
|
||||
}
|
||||
return float64(up) / float64(total) * 100
|
||||
}
|
||||
|
||||
// worst returns the more severe of two status strings.
|
||||
func worst(a, b string) string {
|
||||
rank := map[string]int{"up": 1, "degraded": 2, "down": 3}
|
||||
if rank[b] > rank[a] {
|
||||
return b
|
||||
}
|
||||
if a == "" {
|
||||
return b
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func load(path string) ([]Snapshot, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s []Snapshot
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func save(path string, snapshots []Snapshot) error {
|
||||
raw, err := json.MarshalIndent(snapshots, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, raw, 0644)
|
||||
}
|
||||
Reference in New Issue
Block a user