diff --git a/README.md b/README.md
index ef280ad..fa63b8d 100644
--- a/README.md
+++ b/README.md
@@ -5,7 +5,7 @@ page, and an admin panel for managing content.
## Why Go?
-- Compiles to a single static binary. Easy to deploy on OpenBSD with no runtime dependencies.
+- Compiles to a single static binary. Easy to deploy on FreeBSD with no runtime dependencies.
- Standard library handles HTTP, HTML templates, and file serving without pulling in a framework.
- Fast startup, low memory footprint. Fits comfortably behind relayd on the SuperMicro 1U.
- The tool I know best for this kind of thing.
@@ -168,7 +168,7 @@ EOF
mv "$TMPFILE" "$STATUS_FILE"
```
-## Deploying on OpenBSD
+## Deploying on FreeBSD
### 1. Build
diff --git a/cmd/server/main.go b/cmd/server/main.go
index d26957b..bc66d13 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -69,6 +69,7 @@ func main() {
mux.HandleFunc("POST /hire", h.HirePost)
mux.HandleFunc("GET /resume", h.Resume)
mux.HandleFunc("POST /newsletter", h.NewsletterPost)
+ mux.HandleFunc("GET /changelog", h.Changelog)
mux.HandleFunc("GET /sitemap.xml", h.Sitemap)
// Admin routes (auth handled per-handler)
diff --git a/content/posts/openbsd-to-opnsense.md b/content/posts/openbsd-to-opnsense.md
new file mode 100644
index 0000000..199d32e
--- /dev/null
+++ b/content/posts/openbsd-to-opnsense.md
@@ -0,0 +1,86 @@
+---
+title: "Why I Moved fw01 from OpenBSD to OPNsense"
+date: 2026-03-17
+tags: [openbsd, opnsense, networking, homelab]
+slug: openbsd-to-opnsense
+description: "I love OpenBSD. I still moved my firewall to OPNsense. Here's the honest account of why."
+draft: false
+---
+
+I wrote a post not long ago about why I chose FreeBSD for this homelab. I could write a
+nearly identical one about OpenBSD on the firewall. The man pages, the security posture, pf —
+OpenBSD is one of the most coherent operating systems I've ever used, and fw01 ran it well.
+
+So why did I replace it with OPNsense?
+
+I wrestled with this for longer than I probably should have. Changing your firewall feels like
+a statement. I've written about pf. I've defended the "just learn the config file" approach.
+Switching to a web UI felt like a betrayal of something.
+
+But pragmatism won.
+
+## The Actual Problem
+
+My ISP bumped me to a 2 Gb/s connection. Theoretically great. In practice, I needed my
+firewall to actually push that throughput across the NICs I had available.
+
+OPNsense gave me more flexibility in how those interfaces were handled — driver support,
+offloading options, tuning knobs exposed through the UI. Getting the same result on OpenBSD
+would have meant more digging, more testing, more time spent on the firewall instead of
+everything the firewall is supposed to protect.
+
+I didn't want to spend a weekend tuning network drivers. I wanted 2 Gb/s to work.
+
+## Why Not pfSense
+
+Before this homelab, I ran a Netgate 4200 with pfSense. It worked fine, but the UI felt
+like it hadn't been touched since 2012. Cluttered, inconsistent, hard to navigate. Every
+time I needed to do something non-obvious I was digging through three menus wondering if I
+was in the right place.
+
+OPNsense is a different experience. The interface is clean, the layout makes sense, and it
+moves at a pace that feels like a maintained project. It's also based on FreeBSD — so under
+the hood, it's still pf, still the networking stack I trust.
+
+Choosing OPNsense wasn't a hard call once pfSense was off the table.
+
+## The Migration
+
+I expected this to be painful. It wasn't. My pf rules translated cleanly. VLAN configuration
+that I'd built up over time moved over without drama. The concepts are identical because
+the underlying system is the same — OPNsense just wraps it.
+
+If you're coming from OpenBSD's pf, OPNsense's firewall rules section will feel familiar.
+The mental model is the same. You're still thinking in terms of interfaces, states, and
+explicit allows. The GUI is just a different way of expressing those rules.
+
+## The Part I Didn't Expect to Care About
+
+Here's the thing I didn't anticipate valuing: if something goes wrong with the firewall
+while I'm not home, someone in my family can actually do something about it.
+
+With a text config and an SSH session, the answer to "the internet is down" is "call me and
+I'll walk you through it." With OPNsense, it's "open a browser, log in, click here, click
+there." That's a meaningful difference in a home environment.
+
+I'm not designing a data center. I'm running a homelab that also happens to be the internet
+connection for my household. Resilience includes other humans being able to use it.
+
+## What I Gave Up
+
+I won't pretend there's no loss here. OpenBSD's simplicity is real. The config file is
+auditable in a way no web UI ever fully is. There's a directness to `pfctl -sr` that no
+amount of GUI polish replicates.
+
+But I still have pf. I still have the BSD networking stack. The firewall is still doing
+exactly what I'd configure it to do manually — I'm just configuring it differently.
+
+## The Honest Takeaway
+
+Sometimes the right tool isn't the purist choice.
+
+I still believe in OpenBSD. I still think pf is the best firewall I've used. None of that
+changed. What changed was an honest accounting of what I actually needed from this specific
+machine — throughput, flexibility, and something my household can survive without me.
+
+OPNsense delivered that. The ideology didn't need to.
diff --git a/content/posts/pf-vlans.md b/content/posts/pf-vlans.md
index 8858194..bbc2647 100644
--- a/content/posts/pf-vlans.md
+++ b/content/posts/pf-vlans.md
@@ -25,7 +25,7 @@ Five VLANs, each on a different subnet:
| game | 30 | 10.0.30.0/24 | Game clients and VMs |
| iot | 40 | 10.0.40.0/24 | Untrusted / IoT / Guest |
-The physical layout: one NIC on fw01 is trunked to the main switch. OpenBSD VLAN interfaces
+The physical layout: one NIC on fw01 is trunked to the main switch. OPNsense VLAN interfaces
(`vlan10`, `vlan20`, etc.) are configured on top of it. Each VLAN interface gets an IP address
in its respective subnet and acts as the default gateway for devices in that VLAN.
diff --git a/content/posts/site-is-live.md b/content/posts/site-is-live.md
index 6fa299b..847757d 100644
--- a/content/posts/site-is-live.md
+++ b/content/posts/site-is-live.md
@@ -1,20 +1,20 @@
---
title: "ridgwaysystems.org is live"
date: 2026-03-11
-tags: [meta, go, openbsd]
+tags: [meta, go, freebsd]
slug: site-is-live
-description: "The site is up. A single Go binary on OpenBSD, serving blog posts, a status page, a hire page, and an admin panel — no database, no Docker, no external dependencies."
+description: "The site is up. A single Go binary on FreeBSD, serving blog posts, a status page, a hire page, and an admin panel — no database, no Docker, no external dependencies."
draft: false
---
It's up.
-ridgwaysystems.org is now running on a Vultr VPS — OpenBSD, relayd for TLS termination, a single Go binary handling everything behind it. No database. No Docker. No framework. Flat Markdown files on disk, templates compiled into the binary at startup, HMAC-signed sessions, and a background goroutine that checks service health every few minutes.
+ridgwaysystems.org is now running on a Vultr VPS — FreeBSD, nginx for TLS termination, a single Go binary handling everything behind it. No database. No Docker. No framework. Flat Markdown files on disk, templates compiled into the binary at startup, HMAC-signed sessions, and a background goroutine that checks service health every few minutes.
The stack:
- **Go** — stdlib `net/http` with 1.22 pattern routing. One binary, one deploy, done.
-- **OpenBSD** — relayd as the reverse proxy, acme-client for TLS certs, rc.d for service management.
+- **FreeBSD** — nginx as the reverse proxy, certbot for TLS certs, rc.d for service management.
- **Flat files** — posts are `.md` files in `content/posts/`. The status page reads from `data/status.json`. Newsletter subscribers live in `data/subscribers.json`.
- **No build step** — CSS is hand-written, no preprocessor. JS is a single file for the admin editor.
@@ -22,4 +22,4 @@ Features that made it in before launch: blog with next/prev navigation, a status
The source is at [git.ridgwaysystems.org](https://git.ridgwaysystems.org).
-More build posts to follow — the relayd config alone is worth documenting.
+More build posts to follow — the nginx config alone is worth documenting.
diff --git a/content/posts/the-hardware.md b/content/posts/the-hardware.md
index 01d8aa8..062a45d 100644
--- a/content/posts/the-hardware.md
+++ b/content/posts/the-hardware.md
@@ -13,10 +13,10 @@ actually in the rack, why those machines, and what each one does.
## The Firewall: SuperMicro 1U
The firewall is a SuperMicro 1U server with a Xeon E3-1230v2 and 16GB ECC RAM. This runs
-OpenBSD and handles everything at the network edge:
+OPNsense (FreeBSD-based) and handles everything at the network edge:
- **pf** — stateful packet filtering, VLAN routing
-- **relayd** — reverse proxy, TLS termination for external services
+- **nginx** — reverse proxy, TLS termination for external services
- **WireGuard** — VPN for remote access
- **unbound** — recursive DNS resolver for internal clients
- **dhcpd** — DHCP for all VLANs
@@ -32,7 +32,7 @@ network segment.
The R720 is the workhorse. Dual Xeon E5-2600 series processors, 64GB ECC RAM, a few SSDs
for the OS and data volumes.
-This runs OpenBSD and hosts:
+This runs FreeBSD and hosts:
- **httpd** — web server for this site
- **Gitea** — self-hosted git
@@ -51,18 +51,18 @@ on a home circuit without thinking about it.
## The Secondary Server: Dell R710
The R710 is older — Xeon 5500/5600 series — but has more RAM slots, currently at 48GB.
-It runs a mix of OpenBSD base with some Linux VMs managed by `vmm(4)`.
+It runs FreeBSD with Linux VMs managed by `bhyve(8)`.
Primary roles:
- **nsd** — authoritative DNS for ridgwaysystems.org zones
-- **Linux VMs** — game servers (Minecraft, Valheim, etc.), running in `vmm(4)`
+- **Linux VMs** — game servers (Minecraft, Valheim, etc.), running in `bhyve(8)`
- **Jellyfin** — media server
-- **Backup target** — receiving rsync backups from srv01
+- **Backup target** — receiving ZFS send/recv and rsync backups from srv01
The R710 is even louder than the R720 under load. Old server hardware wasn't designed with
-home environments in mind. ILO (Dell iDRAC) makes remote management workable — I rarely
-need to touch it physically.
+home environments in mind. iDRAC makes remote management workable — I rarely need to touch
+it physically.
## The Desktop: Daily Driver and Ansible Control Node
@@ -95,4 +95,4 @@ data. ECC doesn't eliminate all failure modes, but it eliminates the commonest o
Next up: the pf configuration and VLAN setup. This is where most of the interesting work
happens — separating untrusted IoT devices from servers, routing WireGuard traffic, and
-setting up relayd to proxy external services.
+setting up the reverse proxy to forward external services.
diff --git a/content/posts/why-openbsd.md b/content/posts/why-openbsd.md
index fc24e62..6da7b3c 100644
--- a/content/posts/why-openbsd.md
+++ b/content/posts/why-openbsd.md
@@ -1,35 +1,40 @@
---
-title: "Why OpenBSD for a Homelab"
+title: "Why FreeBSD for a Homelab"
date: 2026-03-01
-tags: [openbsd, homelab]
-slug: why-openbsd
-description: "The case for running OpenBSD as the foundation of a homelab — security model, pf, clean base system, and the value of good documentation."
+tags: [freebsd, homelab]
+slug: why-freebsd
+description: "The case for running FreeBSD as the foundation of a homelab — ZFS, jails, pf, clean base system, and the value of good documentation."
draft: false
---
-A few people have asked why I chose OpenBSD for this homelab instead of the usual suspects —
+A few people have asked why I chose FreeBSD for this homelab instead of the usual suspects —
Proxmox, TrueNAS, some flavor of Linux. The short answer: I wanted to actually understand what
-my infrastructure is doing, and OpenBSD forces that in a way nothing else does.
+my infrastructure is doing, and FreeBSD forces that in a way nothing else does.
-## The Security Model Is Different
+## ZFS Is the Right Filesystem
-Most operating systems bolt security on. OpenBSD builds it in.
+FreeBSD ships ZFS in the base system and it's a first-class citizen. Copy-on-write,
+checksumming, snapshots, send/receive for replication, compression, deduplication. For a
+homelab running storage workloads, ZFS means you actually trust your data is what you think it is.
-`pledge(2)` and `unveil(2)` are the clearest expression of this. Programs declare up front exactly
-what syscalls they'll use and which filesystem paths they'll touch. The kernel enforces it. If a
-daemon gets compromised, the blast radius is bounded by what it pledged. This isn't theoretical —
-the base system uses these everywhere.
+Compare that to the ext4/LVM dance on Linux — it works, but you're stitching together multiple
+tools to get what ZFS gives you out of the box.
-Compare that to a typical Linux daemon running as root (or even a non-root user) with access to
-the full filesystem. The attack surface is enormous by default.
+## Jails Are the Right Way to Isolate Services
+
+FreeBSD jails give you lightweight OS-level isolation without the overhead or complexity of VMs,
+and without the moving parts of Docker. A jail is just a restricted process tree with its own
+filesystem view. Predictable, auditable, and you control exactly what it can see.
+
+The security model is sound. A compromised service in a jail can't reach the host or other jails
+without explicit configuration. Compare this to a typical Linux container where the security
+boundary depends on which kernel namespaces you've set up correctly.
## pf Is the Best Firewall I've Used
-I've spent time with iptables, nftables, and a few others. `pf(4)` is in a different category.
-
-The rule syntax reads like English. State tracking is intelligent by default. `relayd(8)` handles
-reverse proxying and TLS termination in a way that integrates naturally with pf. The whole
-networking stack hangs together coherently.
+FreeBSD ships pf — the same packet filter that made BSD firewalls famous. The rule syntax reads
+like English. State tracking is intelligent by default. The whole networking stack hangs together
+coherently.
Here's a minimal pf.conf to give a sense of the syntax:
@@ -47,37 +52,32 @@ That's readable. You can audit that in two minutes.
## The Base System Is Clean
-OpenBSD ships a complete, minimal base system. No package manager drama, no systemd, no
-six-hundred-dependency init system. The base is coherent. Everything in `/usr/bin` and
-`/sbin` has been reviewed and fits together.
+FreeBSD ships a complete, coherent base system — separate from ports and packages. No systemd.
+Everything in the base has been considered deliberately and fits together.
When I install a service, I know exactly what I'm adding on top of a well-understood foundation.
On a typical Linux distro, I'm never quite sure what's lurking in the base.
## The Documentation Is Accurate
-This is underrated. OpenBSD man pages are written by the people who wrote the code. They are
-current. They are correct. When the man page says `pledge(2)` takes these promises in this
-order, that is exactly what happens.
+The FreeBSD Handbook is one of the best pieces of technical documentation in open source. The
+man pages are maintained by the people who wrote the code. When the Handbook describes how to
+set up ZFS or configure pf, it describes what actually happens.
-How many times have you followed a Linux man page only to find it describes behavior from four
-kernel versions ago? Or read documentation that's accurate for one distro but not another?
-
-With OpenBSD, the man page is the specification. This matters enormously when you're learning
-a new tool or debugging an obscure issue at 2am.
+With FreeBSD, the documentation and the system agree with each other. This matters enormously
+when you're learning a new tool or debugging an obscure issue at 2am.
## The Tradeoffs
-OpenBSD isn't the right choice for everything. ZFS isn't in the base (use FreeBSD for that).
-The package ecosystem is smaller than Linux. Some software just doesn't run on OpenBSD, or
-runs with limitations.
+FreeBSD isn't the right choice for everything. The package ecosystem is smaller than Linux.
+Some software just doesn't run on FreeBSD, or requires extra effort to get working.
For a homelab where the goal is to *understand* the infrastructure rather than just consume
services, these tradeoffs are worth it. The constraint of a smaller, more deliberate ecosystem
means you end up with a leaner, more auditable system.
-If you want to run Kubernetes on your homelab, OpenBSD is the wrong choice. If you want to
-actually know what your firewall is doing and why, it's worth the investment.
+If you want a point-and-click hypervisor, use Proxmox. If you want to actually know what your
+firewall is doing and why, FreeBSD is worth the investment.
## What's Next
diff --git a/data/changelog.json b/data/changelog.json
new file mode 100644
index 0000000..6d749a9
--- /dev/null
+++ b/data/changelog.json
@@ -0,0 +1 @@
+{"entries": []}
diff --git a/data/status.json b/data/status.json
index c0cfd4d..64b0c9f 100644
--- a/data/status.json
+++ b/data/status.json
@@ -1,5 +1,5 @@
{
- "last_checked": "2026-03-11T21:01:46.808088132Z",
+ "last_checked": "2026-03-27T12:53:37.268148673Z",
"services": [
{
"name": "Web (httpd)",
@@ -47,8 +47,7 @@
{
"name": "Matrix (Conduit)",
"description": "Self-hosted chat",
- "status": "degraded",
- "note": "Migrating to new server — intermittent"
+ "status": "up"
},
{
"name": "Jellyfin",
@@ -58,8 +57,7 @@
{
"name": "Game Servers",
"description": "Minecraft, Valheim VMs on srv02",
- "status": "down",
- "note": "Offline for maintenance"
+ "status": "up"
}
]
}
\ No newline at end of file
diff --git a/data/uptime.json b/data/uptime.json
new file mode 100644
index 0000000..e168145
--- /dev/null
+++ b/data/uptime.json
@@ -0,0 +1,114 @@
+[
+ {
+ "time": "2026-03-19T21:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "down",
+ "DNS (unbound)": "down",
+ "Email (OpenSMTPD)": "down",
+ "Game Servers": "down",
+ "Gitea": "down",
+ "Grafana": "unknown",
+ "Jellyfin": "down",
+ "Matrix (Conduit)": "down",
+ "Monitoring (Prometheus)": "down",
+ "VPN (WireGuard)": "down",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-19T22:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "Grafana": "up",
+ "Jellyfin": "up",
+ "Matrix (Conduit)": "up",
+ "Monitoring (Prometheus)": "up",
+ "VPN (WireGuard)": "up",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-19T23:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "Grafana": "up",
+ "Jellyfin": "up",
+ "Matrix (Conduit)": "up",
+ "Monitoring (Prometheus)": "up",
+ "VPN (WireGuard)": "up",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-20T02:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "Grafana": "up",
+ "Jellyfin": "up",
+ "Matrix (Conduit)": "up",
+ "Monitoring (Prometheus)": "up",
+ "VPN (WireGuard)": "up",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-24T02:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "Grafana": "up",
+ "Jellyfin": "up",
+ "Matrix (Conduit)": "up",
+ "Monitoring (Prometheus)": "up",
+ "VPN (WireGuard)": "up",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-24T03:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "Grafana": "up",
+ "Jellyfin": "up",
+ "Matrix (Conduit)": "up",
+ "Monitoring (Prometheus)": "up",
+ "VPN (WireGuard)": "up",
+ "Web (httpd)": "up"
+ }
+ },
+ {
+ "time": "2026-03-27T12:00:00Z",
+ "statuses": {
+ "DNS (nsd)": "up",
+ "DNS (unbound)": "up",
+ "Email (OpenSMTPD)": "up",
+ "Game Servers": "up",
+ "Gitea": "down",
+ "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/changelog/changelog.go b/internal/changelog/changelog.go
new file mode 100644
index 0000000..a76c0be
--- /dev/null
+++ b/internal/changelog/changelog.go
@@ -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)
+}
diff --git a/internal/checker/checker.go b/internal/checker/checker.go
index 6bfbdb4..5b34e9b 100644
--- a/internal/checker/checker.go
+++ b/internal/checker/checker.go
@@ -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"
}
}
diff --git a/internal/handler/admin.go b/internal/handler/admin.go
index 78408c6..43027fb 100644
--- a/internal/handler/admin.go
+++ b/internal/handler/admin.go
@@ -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))
diff --git a/internal/handler/handler.go b/internal/handler/handler.go
index 25b0ffe..cbbd231 100644
--- a/internal/handler/handler.go
+++ b/internal/handler/handler.go
@@ -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)
}
}
diff --git a/internal/handler/public.go b/internal/handler/public.go
index 0dd7d18..73b10a1 100644
--- a/internal/handler/public.go
+++ b/internal/handler/public.go
@@ -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) {
diff --git a/internal/uptime/uptime.go b/internal/uptime/uptime.go
new file mode 100644
index 0000000..05ec6bf
--- /dev/null
+++ b/internal/uptime/uptime.go
@@ -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)
+}
diff --git a/static/css/style.css b/static/css/style.css
index 1e49491..8c73a3e 100644
--- a/static/css/style.css
+++ b/static/css/style.css
@@ -228,6 +228,35 @@ blockquote {
.site-footer a { color: var(--text-muted); }
.site-footer a:hover { color: var(--accent); }
+/* === Status Banner === */
+
+.banner {
+ text-align: center;
+ padding: 0.6rem 1.25rem;
+ font-size: 0.88rem;
+ font-family: var(--font-mono);
+}
+
+.banner a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 600;
+}
+
+.banner a:hover { text-decoration: underline; }
+
+.banner-danger {
+ background: #a32200;
+ color: #fdecea;
+ border-bottom: 2px solid #7a1900;
+}
+
+.banner-warning {
+ background: #8a5c00;
+ color: #fff8e1;
+ border-bottom: 2px solid #6b4700;
+}
+
/* === Buttons === */
.btn {
@@ -520,9 +549,9 @@ blockquote {
.status-item {
display: flex;
- align-items: center;
- gap: 0.85rem;
- padding: 0.8rem 0;
+ flex-direction: column;
+ gap: 0.45rem;
+ padding: 0.9rem 0;
border-bottom: 1px solid var(--border);
}
@@ -533,6 +562,7 @@ blockquote {
height: 10px;
border-radius: 50%;
flex-shrink: 0;
+ justify-self: center;
}
.status-up .status-indicator { background: #2a9a5a; }
@@ -871,6 +901,64 @@ blockquote {
margin-bottom: 0.75rem;
}
+/* === Status Editor === */
+
+.status-editor-table td { vertical-align: middle; }
+
+.status-editor-name {
+ display: block;
+ font-weight: 600;
+ font-size: 0.9rem;
+}
+
+.status-select {
+ appearance: none;
+ background: var(--bg-alt);
+ border: 1px solid var(--border-dark);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ padding: 0.3rem 0.6rem;
+ cursor: pointer;
+ min-width: 7rem;
+}
+
+.status-select-up { border-color: #2a7a2a; color: #2a7a2a; }
+.status-select-degraded { border-color: #8a5c00; color: #8a5c00; }
+.status-select-down { border-color: #a32200; color: #a32200; }
+.status-select-unknown { border-color: var(--border-dark); }
+
+@media (prefers-color-scheme: dark) {
+ .status-select-up { border-color: #4caf50; color: #4caf50; }
+ .status-select-degraded { border-color: #ffb74d; color: #ffb74d; }
+ .status-select-down { border-color: #ef5350; color: #ef5350; }
+}
+
+.status-note-input {
+ width: 100%;
+ background: var(--bg-alt);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ padding: 0.3rem 0.6rem;
+}
+
+.status-note-input:focus,
+.status-select:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.status-last-checked {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ margin-left: 1rem;
+}
+
/* === Blog Controls (search + tag filter) === */
.blog-controls {
@@ -1307,6 +1395,20 @@ blockquote {
line-height: 1.55;
}
+.resume-reference blockquote {
+ margin: 0 0 0.5em 0;
+ font-size: 0.87rem;
+ line-height: 1.6;
+}
+
+.resume-reference-cite {
+ display: block;
+ font-size: 0.82rem;
+ font-style: normal;
+ color: var(--text-muted);
+ padding-left: 1em;
+}
+
@media (max-width: 500px) {
.resume-skills dl { grid-template-columns: 1fr; gap: 0.15em; }
.resume-skills dt { margin-top: 0.5em; }
@@ -1499,3 +1601,251 @@ blockquote {
@media (max-width: 420px) {
.infra-grid { grid-template-columns: 1fr; }
}
+
+/* === Changelog === */
+
+.changelog-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.changelog-entry {
+ display: grid;
+ grid-template-columns: 180px 1fr;
+ gap: 0 1.5rem;
+ padding: 1.1rem 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.changelog-entry:last-child { border-bottom: none; }
+
+.changelog-meta {
+ display: flex;
+ flex-direction: column;
+ gap: 0.4rem;
+ padding-top: 0.1rem;
+}
+
+.changelog-date {
+ font-family: var(--font-mono);
+ font-size: 0.82rem;
+ color: var(--text-muted);
+}
+
+.changelog-category {
+ display: inline-block;
+ font-family: var(--font-mono);
+ font-size: 0.72rem;
+ padding: 0.15rem 0.45rem;
+ border-radius: var(--radius);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ font-weight: 600;
+ width: fit-content;
+}
+
+.changelog-category-hardware { background: #2a4a7a22; color: #3a6aaa; border: 1px solid #3a6aaa44; }
+.changelog-category-network { background: #2a6a4a22; color: #2a8a5a; border: 1px solid #2a8a5a44; }
+.changelog-category-software { background: #6a4a2a22; color: #9a6a3a; border: 1px solid #9a6a3a44; }
+.changelog-category-migration { background: #6a2a6a22; color: #8a4a8a; border: 1px solid #8a4a8a44; }
+
+@media (prefers-color-scheme: dark) {
+ .changelog-category-hardware { background: #3a6aaa22; color: #7aaaee; border-color: #7aaaee44; }
+ .changelog-category-network { background: #2a8a5a22; color: #5acea8; border-color: #5acea844; }
+ .changelog-category-software { background: #9a6a3a22; color: #ddaa66; border-color: #ddaa6644; }
+ .changelog-category-migration { background: #8a4a8a22; color: #cc88cc; border-color: #cc88cc44; }
+}
+
+.changelog-title {
+ font-size: 0.97rem;
+ font-weight: 600;
+ margin: 0 0 0.3rem;
+}
+
+.changelog-desc {
+ font-size: 0.88rem;
+ color: var(--text-muted);
+ margin: 0;
+ line-height: 1.55;
+}
+
+@media (max-width: 600px) {
+ .changelog-entry { grid-template-columns: 1fr; gap: 0.4rem; }
+}
+
+/* === Uptime Bars === */
+
+.status-top {
+ display: grid;
+ grid-template-columns: 14px 1fr auto;
+ align-items: center;
+ gap: 0 0.75rem;
+}
+
+.uptime-bar-row {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 0 0.6rem;
+ padding-left: calc(14px + 0.75rem);
+}
+
+.uptime-bar {
+ display: flex;
+ gap: 2px;
+}
+
+.uptime-block {
+ display: inline-block;
+ height: 20px;
+ flex: 1;
+ border-radius: 2px;
+ min-width: 4px;
+}
+
+.uptime-block-up { background: #2ecc71; }
+.uptime-block-degraded { background: #e8870a; }
+.uptime-block-down { background: #e74c3c; }
+.uptime-block-none { background: var(--border-dark); }
+
+.uptime-pct {
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--text-muted);
+ min-width: 3.5rem;
+ text-align: right;
+}
+
+.uptime-pct-none { font-style: italic; }
+
+/* === Network Map (SVG) === */
+
+.netmap-wrap {
+ position: relative;
+ overflow: visible;
+}
+
+.netmap {
+ width: 100%;
+ max-width: 720px;
+ height: auto;
+ display: block;
+ margin: 0 auto;
+}
+
+.netmap-box {
+ fill: var(--bg-alt);
+ stroke: var(--border-dark);
+ stroke-width: 1.5;
+}
+
+.netmap-box-internet { stroke: var(--accent); }
+.netmap-box-fw { stroke: var(--accent); fill: var(--bg-code); }
+.netmap-box-vlan { stroke: var(--border-dark); }
+.netmap-box-host { stroke: var(--border); }
+
+.netmap-line {
+ stroke: var(--border-dark);
+ stroke-width: 1.5;
+}
+
+.netmap-label {
+ fill: var(--text);
+ font-family: var(--font-mono);
+ font-size: 12px;
+ font-weight: 600;
+ text-anchor: middle;
+ dominant-baseline: auto;
+}
+
+.netmap-label-sm { font-size: 11px; }
+
+.netmap-sublabel {
+ fill: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 9px;
+ text-anchor: middle;
+}
+
+.netmap-node { cursor: default; }
+
+.netmap-node:hover .netmap-box {
+ stroke: var(--accent);
+ fill: var(--bg-code);
+}
+
+.netmap-tooltip {
+ position: fixed;
+ background: var(--bg-alt);
+ border: 1px solid var(--border-dark);
+ border-radius: var(--radius);
+ padding: 0.45rem 0.7rem;
+ font-family: var(--font-mono);
+ font-size: 0.78rem;
+ color: var(--text);
+ white-space: pre;
+ line-height: 1.6;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.1s;
+ z-index: 100;
+ max-width: 260px;
+}
+
+.netmap-tooltip.visible { opacity: 1; }
+
+/* === Admin Changelog === */
+
+.admin-row-actions {
+ display: flex;
+ gap: 0.4rem;
+ align-items: center;
+}
+
+.btn-sm {
+ padding: 0.2rem 0.6rem;
+ font-size: 0.78rem;
+}
+
+.btn-danger {
+ background: #a32200;
+ color: #fdecea;
+ border-color: #a32200;
+}
+
+.btn-danger:hover { background: #7a1900; border-color: #7a1900; }
+
+.form-row {
+ display: flex;
+ flex-direction: column;
+ gap: 0.3rem;
+ margin-bottom: 1rem;
+}
+
+.form-row label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.label-optional {
+ font-weight: 400;
+ color: var(--text-muted);
+}
+
+.form-input {
+ background: var(--bg-alt);
+ border: 1px solid var(--border-dark);
+ border-radius: var(--radius);
+ color: var(--text);
+ font-family: var(--font-sans);
+ font-size: 0.9rem;
+ padding: 0.45rem 0.65rem;
+ width: 100%;
+}
+
+.form-input:focus {
+ outline: none;
+ border-color: var(--accent);
+}
diff --git a/static/img/wallpaper.svg b/static/img/wallpaper.svg
index e395b9e..69f7c50 100644
--- a/static/img/wallpaper.svg
+++ b/static/img/wallpaper.svg
@@ -43,7 +43,7 @@
@@ -74,7 +74,7 @@
# ridgwaysystems.org -- network
internet
|
- fw01 10.0.1.1 OpenBSD
+ fw01 10.0.1.1 OPNsense
pf relayd unbound wireguard
|
+-- vlan10 10.0.10.0/24 servers
@@ -108,13 +108,13 @@
# hardware inventory
- fw01 SuperMicro 1U OpenBSD 7.6
+ fw01 SuperMicro 1U OPNsense 26.1
Xeon E3-1230v2 / 16 GB ECC
pf · relayd · wireguard
- srv01 Dell R720 OpenBSD 7.6
+ srv01 Dell R720 FreeBSD 15.0
2x Xeon E5-2600 / 64 GB ECC
httpd · gitea · smtpd · matrix
- srv02 Dell R710 OpenBSD 7.6
+ srv02 Dell R710 FreeBSD 15.0
Xeon X5650 / 48 GB ECC
nsd · vmm · jellyfin
ws01 desktop Linux
@@ -128,7 +128,7 @@
-
+
OPENBSD HOMELAB
+ opacity="0.55">FREEBSD HOMELAB
diff --git a/templates/about.html b/templates/about.html
index 4c4e8e7..242c5a4 100644
--- a/templates/about.html
+++ b/templates/about.html
@@ -1,5 +1,5 @@
{{define "title"}}About — Ridgway Systems{{end}}
-{{define "meta-desc"}}About Ridgway Systems — a personal OpenBSD homelab project.{{end}}
+{{define "meta-desc"}}About Ridgway Systems — a personal FreeBSD homelab project.{{end}}
{{define "content"}}