Show 90-day uptime bars on the status page

Every check is also counted in a per-day table that outlives the raw checks, so the public page
can draw one bar per UTC day with a tooltip, as most hosted status pages do. Existing databases
are backfilled once from the checks they still have.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
VeillaxandClaude Sonnet 5.5 committed 2026-10-27 04:22:54 -06:00
1 parent 73f45fb3ae
commit 4ea4fe5289
7 files changed
+217 -10

No files matched your search

+6
View File
@@ -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.
+4
View File
@@ -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`.
+13 -2
View File
@@ -806,6 +806,7 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
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"
}
+88
View File
@@ -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)
}
}
+34
View File
@@ -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")
}
+17 -1
View File
@@ -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())
}
+55 -7
View File
@@ -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 @@
</style>
</head>
<body>
<div class="tip" id="tip" role="presentation" hidden></div>
<main>
<h1>Status</h1>
<div id="banner" class="banner" role="status">Loading</div>
@@ -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.'));