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:
1 parent
73f45fb3ae
commit
4ea4fe5289
7 files changed
+217
-10
No files matched your search
@@ -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
|
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.
|
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
|
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.
|
monitor, so a user who can add a monitor cannot use Sandbar to scan your network.
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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
|
## 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`.
|
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
@@ -806,6 +806,7 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
|||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Uptime float64 `json:"uptime"`
|
Uptime float64 `json:"uptime"`
|
||||||
Since int64 `json:"since"`
|
Since int64 `json:"since"`
|
||||||
|
Days [][2]int `json:"days"` // [checks, failed] per UTC day, oldest first, ending today
|
||||||
}
|
}
|
||||||
type inc struct {
|
type inc struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
@@ -819,14 +820,24 @@ func (s *Server) statusJSON(w http.ResponseWriter, r *http.Request) {
|
|||||||
Monitors []mon `json:"monitors"`
|
Monitors []mon `json:"monitors"`
|
||||||
Incidents []inc `json:"incidents"`
|
Incidents []inc `json:"incidents"`
|
||||||
Overall string `json:"overall"`
|
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")
|
mons, _ := listOf[Monitor](s.st, "monitor")
|
||||||
for _, m := range mons {
|
for _, m := range mons {
|
||||||
if !m.Public || !m.Enabled {
|
if !m.Public || !m.Enabled {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
st := s.monitorState(m.ID)
|
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" {
|
if st.Status == "down" {
|
||||||
out.Overall = "outage"
|
out.Overall = "outage"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,6 +233,37 @@ func (s *Server) monitorState(id string) MonitorState {
|
|||||||
return st
|
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) {
|
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(?,?,?,?,?,?,?,?)
|
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,
|
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
|
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 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)
|
st.LastCheck, st.LastMS, st.Detail = now.Unix(), int(dur.Milliseconds()), errText(err)
|
||||||
key := "monitor:" + m.ID
|
key := "monitor:" + m.ID
|
||||||
if ok {
|
if ok {
|
||||||
@@ -369,6 +402,7 @@ func (s *Server) Heartbeat(token string) bool {
|
|||||||
func (s *Server) deleteMonitorData(id string) {
|
func (s *Server) deleteMonitorData(id string) {
|
||||||
s.st.db.Exec(`DELETE FROM mstate WHERE monitor_id=?`, id)
|
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 checks WHERE monitor_id=?`, id)
|
||||||
|
s.st.db.Exec(`DELETE FROM daily WHERE monitor_id=?`, id)
|
||||||
s.ResolveAlertKey("monitor:"+id, "monitor deleted")
|
s.ResolveAlertKey("monitor:"+id, "monitor deleted")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ CREATE INDEX IF NOT EXISTS messages_topic ON messages(topic, time);
|
|||||||
CREATE TABLE IF NOT EXISTS checks (
|
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);
|
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);
|
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 (
|
CREATE TABLE IF NOT EXISTS mstate (
|
||||||
monitor_id TEXT PRIMARY KEY, status TEXT NOT NULL, since INTEGER NOT NULL, last_check INTEGER NOT NULL,
|
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);
|
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()
|
db.Close()
|
||||||
return nil, err
|
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() }
|
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) {
|
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 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 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 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())
|
s.db.Exec(`DELETE FROM alerts WHERE status='resolved' AND resolved_at < ? AND incident_id=''`, now.AddDate(0, 0, -90).Unix())
|
||||||
}
|
}
|
||||||
@@ -45,6 +45,17 @@
|
|||||||
.card { background:var(--surface); border:var(--border-w) solid var(--border); border-radius:var(--radius-lg); overflow:hidden; }
|
.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 { 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; }
|
.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 { 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-success { background:var(--success-bg); color:var(--success); }
|
||||||
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
.badge-warning { background:var(--warning-bg); color:var(--warning); }
|
||||||
@@ -56,6 +67,7 @@
|
|||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
<div class="tip" id="tip" role="presentation" hidden></div>
|
||||||
<main>
|
<main>
|
||||||
<h1>Status</h1>
|
<h1>Status</h1>
|
||||||
<div id="banner" class="banner" role="status">Loading</div>
|
<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 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' };
|
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() {
|
async function load() {
|
||||||
let d;
|
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; }
|
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();
|
const sv = $('services'); sv.replaceChildren();
|
||||||
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
if (!d.monitors.length) sv.append(el('div', 'row dim', 'No services are published yet.'));
|
||||||
for (const m of d.monitors) {
|
for (const m of d.monitors) sv.append(service(m, d.today));
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ic = $('incidents'); ic.replaceChildren();
|
const ic = $('incidents'); ic.replaceChildren();
|
||||||
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
if (!d.incidents.length) ic.append(el('div', 'row dim', 'No incidents in the last 14 days.'));
|
||||||
|
|||||||
Reference in new issue
Block a user