diff --git a/README.md b/README.md index 66d5fd6..1e897c0 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,12 @@ A monitor goes down after the set number of failures in a row (default 2), raise escalation policy, and recovers on the first success. Monitors marked public appear on `/status`, together with incidents marked public. The status page never shows staff names or private monitors. +The status page shows each public monitor as 90 daily uptime bars, like most hosted status pages. A day is green with +no failed checks, amber when at least 99% of checks passed, red below that, and grey with no data. Hovering a bar +(or tapping it on a phone) gives the date, the uptime and the number of failed checks. Days are UTC. Counts are kept +in a small per-day table, so the bars go back 90 days even though raw check results are kept for +`SANDBAR_CHECK_DAYS` only. + Monitors cannot probe private or internal addresses unless you tick **Allow targets on private networks** on that monitor, so a user who can add a monitor cannot use Sandbar to scan your network. diff --git a/docs/design.md b/docs/design.md index a2396cd..933a5a9 100644 --- a/docs/design.md +++ b/docs/design.md @@ -28,6 +28,10 @@ The helpers at the top of the script in `index.html` are the same as Hammerhead' | `rowList(addLabel, make, initial)` | A list the person adds to and removes from, such as people in a rotation or steps in a policy | | `timeline(events)` | Dated entries for an alert or an incident | +## Status page bars + +* **Uptime bars** on the public page are one bar per UTC day, 90 of them. Green is no failures, amber is at least 99% passing, red is below that, grey is no data. Color is never the only signal: the row carries a text status badge and the uptime percentage, each bar's tooltip says the date and the numbers in words, and the whole strip has an `aria-label` summary. + ## Icons Lucide style, outline only, stroke 1.75, round caps, `currentColor`. Sizes are 16, 18 and 22 pixels. They are inline in the `ICONS` table. An icon goes with a text label, and icon-only buttons carry an `aria-label`. diff --git a/internal/sandbar/api.go b/internal/sandbar/api.go index bd576df..7d5a612 100644 --- a/internal/sandbar/api.go +++ b/internal/sandbar/api.go @@ -802,10 +802,11 @@ func (s *Server) heartbeat(w http.ResponseWriter, r *http.Request) { // statusJSON feeds the public status page: only monitors and incidents marked public. func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) { type mon struct { - Name string `json:"name"` - Status string `json:"status"` - Uptime float64 `json:"uptime"` - Since int64 `json:"since"` + Name string `json:"name"` + Status string `json:"status"` + Uptime float64 `json:"uptime"` + Since int64 `json:"since"` + Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today } type inc struct { Title string `json:"title"` @@ -819,14 +820,24 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) { Monitors []mon `json:"monitors"` Incidents []inc `json:"incidents"` Overall string `json:"overall"` - }{[]mon{}, []inc{}, "operational"} + Today string `json:"today"` // the UTC date of the last bar + }{[]mon{}, []inc{}, "operational", s.now().UTC().Format("2006-01-02")} mons, _ := listOf[Monitor](s.st, "monitor") for _, m := range mons { if !m.Public || !m.Enabled { continue } st := s.monitorState(m.ID) - out.Monitors = append(out.Monitors, mon{m.Name, st.Status, st.Uptime30, st.Since}) + days := s.dailyUptime(m.ID, uptimeDays, s.now()) + var checks, fails int + for _, d := range days { + checks, fails = checks+d[0], fails+d[1] + } + uptime := -1.0 // no checks yet + if checks > 0 { + uptime = float64(checks-fails) * 100 / float64(checks) + } + out.Monitors = append(out.Monitors, mon{m.Name, st.Status, uptime, st.Since, days}) if st.Status == "down" { out.Overall = "outage" } diff --git a/internal/sandbar/bars_test.go b/internal/sandbar/bars_test.go new file mode 100644 index 0000000..9876fd4 --- /dev/null +++ b/internal/sandbar/bars_test.go @@ -0,0 +1,88 @@ +package sandbar + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// Every check lands in a per-day count that outlives the raw checks, and the status page +// gets one [checks, failed] pair per day, oldest first and ending today. +func TestUptimeBars(t *testing.T) { + s, clk, _ := newTestServer(t) + m := &Monitor{ID: "m", Name: "Site", Kind: "http", Target: "http://x", Enabled: true, Public: true, Retries: 1} + s.st.put("monitor", m.ID, m) + + // Two days ago: 4 checks, 1 failed. Yesterday: nothing. Today: 2 checks, both fine. + clk.Advance(-48 * time.Hour) + for i := 0; i < 4; i++ { + var err error + if i == 3 { + err = errors.New("timeout") + } + s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, err) + } + clk.Advance(48 * time.Hour) + s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil) + s.recordResult(m, s.monitorState(m.ID), clk.Now(), 10*time.Millisecond, nil) + + days := s.dailyUptime(m.ID, 5, clk.Now()) + want := [][2]int{{0, 0}, {0, 0}, {4, 1}, {0, 0}, {2, 0}} + for i := range want { + if days[i] != want[i] { + t.Fatalf("days = %v, want %v", days, want) + } + } + + // The rollup survives pruning the raw checks. + s.st.prune(clk.Now().AddDate(0, 0, 60), 30, 1) + if got := s.dailyUptime(m.ID, 5, clk.Now()); got[2] != [2]int{4, 1} { + t.Fatalf("rollup lost after pruning: %v", got) + } + + ts := httptest.NewServer(s.Handler()) + defer ts.Close() + resp, err := http.Get(ts.URL + "/status.json") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + var out struct { + Today string + Monitors []struct { + Uptime float64 + Days [][2]int + } + } + json.NewDecoder(resp.Body).Decode(&out) + if out.Today != "2026-01-05" || len(out.Monitors) != 1 || len(out.Monitors[0].Days) != uptimeDays { + t.Fatalf("status.json: %+v", out) + } + m0 := out.Monitors[0] + if last := m0.Days[uptimeDays-1]; last != [2]int{2, 0} { + t.Fatalf("today's bar: %v", last) + } + if want := 100 * 5.0 / 6.0; m0.Uptime < want-0.01 || m0.Uptime > want+0.01 { + t.Fatalf("uptime %.2f, want %.2f", m0.Uptime, want) + } +} + +// A database from before the daily table gets it built from the checks it has. +func TestDailyBackfill(t *testing.T) { + s, clk, _ := newTestServer(t) + now := clk.Now().Unix() + s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES('m',?,1,5,''),('m',?,0,5,'x')`, now, now+1) + s.st.db.Exec(`DELETE FROM daily`) + var have int + s.st.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have) + if have != 0 { + t.Fatal("setup") + } + s.st.backfillDaily() + if got := s.dailyUptime("m", 1, clk.Now()); got[0] != [2]int{2, 1} { + t.Fatalf("backfill: %v", got) + } +} diff --git a/internal/sandbar/monitor.go b/internal/sandbar/monitor.go index 1b6a725..7fe7489 100644 --- a/internal/sandbar/monitor.go +++ b/internal/sandbar/monitor.go @@ -233,6 +233,37 @@ func (s *Server) monitorState(id string) MonitorState { return st } +// uptimeDays is how many days of bars the status page shows. +const uptimeDays = 90 + +// dailyUptime returns [checks, failed] for each of the last n UTC days, oldest first, so the +// last entry is today. A day with no checks is [0, 0]. +func (s *Server) dailyUptime(monitorID string, n int, now time.Time) [][2]int { + out := make([][2]int, n) + today := now.UTC().Truncate(24 * time.Hour) + rows, err := s.st.db.Query(`SELECT day,checks,fails FROM daily WHERE monitor_id=? AND day>=?`, + monitorID, today.AddDate(0, 0, -(n-1)).Format("2006-01-02")) + if err != nil { + return out + } + defer rows.Close() + for rows.Next() { + var day string + var c, f int + if rows.Scan(&day, &c, &f) != nil { + continue + } + d, err := time.Parse("2006-01-02", day) + if err != nil { + continue + } + if i := n - 1 - int(today.Sub(d).Hours()/24); i >= 0 && i < n { + out[i] = [2]int{c, f} + } + } + return out +} + func (s *Server) saveState(st MonitorState) { s.st.db.Exec(`INSERT INTO mstate(monitor_id,status,since,last_check,last_ms,fails,last_ping,detail) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(monitor_id) DO UPDATE SET status=excluded.status, since=excluded.since, last_check=excluded.last_check, @@ -309,6 +340,8 @@ func (s *Server) recordResult(m *Monitor, st MonitorState, now time.Time, dur ti okInt = 1 } s.st.db.Exec(`INSERT INTO checks(monitor_id,time,ok,ms,detail) VALUES(?,?,?,?,?)`, m.ID, now.Unix(), okInt, dur.Milliseconds(), errText(err)) + s.st.db.Exec(`INSERT INTO daily(monitor_id,day,checks,fails) VALUES(?,?,1,?) + ON CONFLICT(monitor_id,day) DO UPDATE SET checks=checks+1, fails=fails+excluded.fails`, m.ID, now.UTC().Format("2006-01-02"), 1-okInt) st.LastCheck, st.LastMS, st.Detail = now.Unix(), int(dur.Milliseconds()), errText(err) key := "monitor:" + m.ID if ok { @@ -369,6 +402,7 @@ func (s *Server) Heartbeat(token string) bool { func (s *Server) deleteMonitorData(id string) { s.st.db.Exec(`DELETE FROM mstate WHERE monitor_id=?`, id) s.st.db.Exec(`DELETE FROM checks WHERE monitor_id=?`, id) + s.st.db.Exec(`DELETE FROM daily WHERE monitor_id=?`, id) s.ResolveAlertKey("monitor:"+id, "monitor deleted") } diff --git a/internal/sandbar/store.go b/internal/sandbar/store.go index 5fb75c5..b55ec62 100644 --- a/internal/sandbar/store.go +++ b/internal/sandbar/store.go @@ -45,6 +45,9 @@ CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time); CREATE TABLE IF NOT EXISTS checks ( monitor_id TEXT NOT NULL, time INTEGER NOT NULL, ok INTEGER NOT NULL, ms INTEGER NOT NULL, detail TEXT NOT NULL); CREATE INDEX IF NOT EXISTS checks_monitor ON checks(monitor_id, time); +-- One row per monitor per UTC day, kept far longer than raw checks: it feeds the status page's uptime bars. +CREATE TABLE IF NOT EXISTS daily ( + monitor_id TEXT NOT NULL, day TEXT NOT NULL, checks INTEGER NOT NULL, fails INTEGER NOT NULL, PRIMARY KEY (monitor_id, day)); CREATE TABLE IF NOT EXISTS mstate ( monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL, last_ms INTEGER NOT NULL, fails INTEGER NOT NULL, last_ping INTEGER NOT NULL, detail TEXT NOT NULL); @@ -78,7 +81,19 @@ func OpenStore(dir string, box *secretbox.Box) (*Store, error) { db.Close() return nil, err } - return &Store{db: db, box: box}, nil + st := &Store{db: db, box: box} + st.backfillDaily() + return st, nil +} + +// backfillDaily fills the daily table from the raw checks, but only when it is empty: a database +// from before the table existed. After that every check is counted as it is recorded. +func (s *Store) backfillDaily() { + var have int + if s.db.QueryRow(`SELECT count(*) FROM daily`).Scan(&have) == nil && have == 0 { + s.db.Exec(`INSERT OR IGNORE INTO daily(monitor_id,day,checks,fails) + SELECT monitor_id, date(time,'unixepoch'), count(*), count(*)-sum(ok) FROM checks GROUP BY monitor_id, date(time,'unixepoch')`) + } } func (s *Store) Close() error { return s.db.Close() } @@ -234,6 +249,7 @@ func (s *Store) events(scope, ref string) []Event { func (s *Store) prune(now time.Time, messageDays, checkDays int) { s.db.Exec(`DELETE FROM messages WHERE time < ?`, now.AddDate(0, 0, -messageDays).Unix()) 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 alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix()) } diff --git a/internal/sandbar/web/status.html b/internal/sandbar/web/status.html index 6c7aca3..7795ba3 100644 --- a/internal/sandbar/web/status.html +++ b/internal/sandbar/web/status.html @@ -45,6 +45,17 @@ .card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; } .row { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; padding:var(--s3) var(--s4); border-bottom:var(--border-w) solid var(--border); } .row:last-child { border-bottom:0; } + .svc { padding:var(--s3) var(--s4) var(--s4); border-bottom:var(--border-w) solid var(--border); } + .svc:last-child { border-bottom:0; } + .svc .top { display:flex; align-items:center; justify-content:space-between; gap:var(--s3); flex-wrap:wrap; } + .bars { display:flex; gap:2px; height:32px; margin-top:var(--s3); } + .bar { flex:1; min-width:0; border-radius:2px; background:var(--neutral-bg); } + .bar.ok { background:var(--success); } .bar.warn { background:var(--warning); } .bar.bad { background:var(--danger); } + .bar:hover { opacity:.7; } + .axis { display:flex; justify-content:space-between; margin-top:var(--s1); font-size:var(--text-xs); color:var(--text-faint); } + .tip { position:fixed; z-index:10; pointer-events:none; max-width:240px; padding:var(--s2) var(--s3); border-radius:var(--radius); background:var(--surface); color:var(--text); border:var(--border-w) solid var(--border-strong); font-size:var(--text-sm); } + .tip strong { display:block; } + @media (max-width:480px) { .bars { gap:1px; } } .badge { display:inline-flex; align-items:center; padding:2px var(--s2); border-radius:999px; font-size:var(--text-xs); font-weight:600; background:var(--neutral-bg); color:var(--text-muted); white-space:nowrap; } .badge-success { background:var(--success-bg); color:var(--success); } .badge-warning { background:var(--warning-bg); color:var(--warning); } @@ -56,6 +67,7 @@ +

Status

@@ -72,6 +84,48 @@ const label = { operational: 'All systems are operational', degraded: 'Some syst const statusBadge = s => s === 'up' ? el('span', 'badge badge-success', 'Operational') : s === 'down' ? el('span', 'badge badge-danger', 'Down') : el('span', 'badge', 'Waiting for first check'); const incStatus = { investigating: 'Investigating', identified: 'Identified', monitoring: 'Monitoring', resolved: 'Resolved' }; +// One bar per UTC day, oldest first. A day is green with no failed checks, amber when at least 99% passed, +// and red below that. Color is never the only signal: the tooltip and the label say it in words. +const dayFmt = new Intl.DateTimeFormat(undefined, {month:'short', day:'numeric', year:'numeric', timeZone:'UTC'}); +const dayClass = ([c, f]) => !c ? '' : !f ? 'ok' : (c - f) / c >= 0.99 ? 'warn' : 'bad'; +function dayText([c, f], i, n, today) { + const d = new Date(today + 'T00:00:00Z'); d.setUTCDate(d.getUTCDate() - (n - 1 - i)); + if (!c) return [dayFmt.format(d), 'No data']; + const up = ((c - f) * 100 / c).toFixed(2) + '% uptime'; + return [dayFmt.format(d), f ? up + ', ' + f.toLocaleString() + ' failed of ' + c.toLocaleString() + ' checks' : up]; +} +const tip = $('tip'); +function showTip(e, lines) { + tip.replaceChildren(el('strong', '', lines[0]), document.createTextNode(lines[1])); + tip.hidden = false; + const w = tip.offsetWidth, x = Math.min(Math.max(8, e.clientX - w / 2), innerWidth - w - 8); + tip.style.left = x + 'px'; tip.style.top = Math.max(8, e.clientY - tip.offsetHeight - 14) + 'px'; +} +function service(m, today) { + const box = el('div', 'svc'), top = el('div', 'top'); + top.append(el('span', '', m.name)); + const right = el('span', 'small dim num'); + right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : '')); + top.append(right); box.append(top); + const days = m.days || [], bars = el('div', 'bars'); + const bad = days.filter(d => dayClass(d) === 'bad').length, warn = days.filter(d => dayClass(d) === 'warn').length; + bars.setAttribute('role', 'img'); + bars.setAttribute('aria-label', m.name + ', the last ' + days.length + ' days: ' + (m.uptime >= 0 ? m.uptime.toFixed(2) + '% uptime' : 'no checks yet') + + (bad || warn ? ', ' + bad + ' days with an outage and ' + warn + ' with short failures' : ', no failures')); + days.forEach((d, i) => { + const b = el('span', 'bar ' + dayClass(d)); + const lines = dayText(d, i, days.length, today); + b.addEventListener('pointerenter', e => showTip(e, lines)); + b.addEventListener('pointermove', e => showTip(e, lines)); + b.addEventListener('pointerleave', () => { tip.hidden = true; }); + bars.append(b); + }); + const axis = el('div', 'axis'); axis.setAttribute('aria-hidden', 'true'); + axis.append(el('span', '', days.length + ' days ago'), el('span', '', 'Today')); + if (days.length) box.append(bars, axis); + return box; +} + async function load() { let d; try { d = await (await fetch('/status.json')).json(); } catch (e) { $('banner').textContent = 'The status could not be loaded. Try again in a moment.'; return; } @@ -80,13 +134,7 @@ async function load() { const sv = $('services'); sv.replaceChildren(); if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.')); - for (const m of d.monitors) { - const r = el('div', 'row'); - r.append(el('span', '', m.name)); - const right = el('span', 'small dim num'); - right.append(statusBadge(m.status), document.createTextNode(m.uptime >= 0 ? ' ' + m.uptime.toFixed(2) + '% uptime' : '')); - r.append(right); sv.append(r); - } + for (const m of d.monitors) sv.append(service(m, d.today)); const ic = $('incidents'); ic.replaceChildren(); if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));