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
+221 -14

No files matched your search

+17 -6
View File
@@ -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"
}
+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.'));