Turn the status page into a full status page generator

Components can be put in groups and given public names. Public incidents carry
posts written for customers (the internal timeline stays internal), the
components they affect, and a page for each incident plus paged history.
Scheduled maintenance has its own window, shows affected components as under
maintenance, and is announced when scheduled, started, ended or canceled.

Branding (title, logo, accent checked for contrast, contact link, footer) and a
custom domain that serves only the status page. Visitors follow by RSS, by
confirmed email, or by webhook that must answer a test request. Admin UI for
all of it, tests, README and design notes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 17:05:38 -06:00
1 parent b4fd31a182
commit cf4d5b3ce2
12 files changed
+2403 -147

No files matched your search

+16 -2
View File
@@ -69,6 +69,10 @@ CREATE TABLE IF NOT EXISTS events (
id INTEGER PRIMARY KEY AUTOINCREMENT, scope TEXT NOT NULL, ref TEXT NOT NULL, time INTEGER NOT NULL,
kind TEXT NOT NULL, actor TEXT NOT NULL, text TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS events_ref ON events(scope, ref, time);
-- People who follow the public status page by email or webhook. The address is sealed; key is a hash of it, for lookups.
CREATE TABLE IF NOT EXISTS subscribers (
id TEXT PRIMARY KEY, kind TEXT NOT NULL, target TEXT NOT NULL, key TEXT NOT NULL UNIQUE,
confirmed INTEGER NOT NULL, created INTEGER NOT NULL);
`
// OpenStore opens (creating if needed) the database in dir.
@@ -87,6 +91,8 @@ func OpenStore(dir string, box *secretbox.Box) (*Store, error) {
db.Close()
return nil, err
}
// Older databases have no status column on events: public incident posts carry the status they announce.
db.Exec(`ALTER TABLE events ADD COLUMN status TEXT NOT NULL DEFAULT ''`)
st := &Store{db: db, box: box}
st.backfillDaily()
return st, nil
@@ -233,24 +239,31 @@ type Event struct {
Kind string `json:"kind"`
Actor string `json:"actor"`
Text string `json:"text"`
// Status is set on posts only: the incident status the post announces.
Status string `json:"status,omitempty"`
}
func (s *Store) events(scope, ref string) []Event {
out := []Event{}
rows, err := s.db.Query(`SELECT time,kind,actor,text FROM events WHERE scope=? AND ref=? ORDER BY time, id`, scope, ref)
rows, err := s.db.Query(`SELECT time,kind,actor,text,status FROM events WHERE scope=? AND ref=? ORDER BY time, id`, scope, ref)
if err != nil {
return out
}
defer rows.Close()
for rows.Next() {
var e Event
if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text) == nil {
if rows.Scan(&e.Time, &e.Kind, &e.Actor, &e.Text, &e.Status) == nil {
out = append(out, e)
}
}
return out
}
// addPost records a public incident update: the text and status the status page shows.
func (s *Store) addPost(incidentID, status, text string, at time.Time) {
s.db.Exec(`INSERT INTO events(scope,ref,time,kind,actor,text,status) VALUES('incident',?,?,'post','',?,?)`, incidentID, at.Unix(), text, status)
}
// prune deletes data past its retention.
func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
s.db.Exec(`DELETE FROM hits WHERE time < ?`, now.AddDate(0, 0, -hitDays).Unix())
@@ -258,5 +271,6 @@ func (s *Store) prune(now time.Time, messageDays, checkDays, hitDays int) {
s.db.Exec(`DELETE FROM checks WHERE time < ?`, now.AddDate(0, 0, -checkDays).Unix())
s.db.Exec(`DELETE FROM daily WHERE day < ?`, now.UTC().AddDate(0, 0, -400).Format("2006-01-02"))
s.db.Exec(`DELETE FROM sessions WHERE expires < ?`, now.Unix())
s.db.Exec(`DELETE FROM subscribers WHERE confirmed=0 AND created < ?`, now.AddDate(0, 0, -7).Unix())
s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
}