Lots of changes to the website

This commit is contained in:
Blake Ridgway
2026-03-27 07:57:13 -05:00
parent 617624c179
commit 7e7480ecf9
33 changed files with 1539 additions and 184 deletions

View 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)
}

View File

@@ -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"
}
}

View File

@@ -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))

View File

@@ -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)
}
}

View File

@@ -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
View 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)
}