diff --git a/README.md b/README.md
index 410ada6..94914d7 100644
--- a/README.md
+++ b/README.md
@@ -32,7 +32,7 @@ statuspage
│ ├── config.go yaml config loading + defaults
│ ├── monitor.go probes, ssh runs, status/history/metrics handlers
│ ├── mock.go deterministic mock data for --mock / screenshots
-│ └── history.go sqlite history + metrics storage and downsampling
+│ └── history.go sqlite history + metrics storage
├── go.mod module definition (statuspage)
├── src/ the frontend (Preact + TS + CSS)
├── example.config.yaml documented, fully commented-out example config
@@ -203,8 +203,8 @@ Controls how metric plots share their x (time) axis:
- `false` (default): each metric plot scales to its own recorded time span, so
a brand new metric isn't drowned out by an older one.
-- `true`: all plots are aligned to the same window (the last ~24h) and are
- downsampled together, so you can compare shapes directly.
+- `true`: all plots are aligned to the same window (the last ~24h), so you can
+ compare shapes directly.
### `ping`
@@ -359,7 +359,7 @@ ssh:
echo "uptime: $(uptime -p)"
echo "load: $(cat /proc/loadavg)"
echo "mem: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
- echo "top: $(ps -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
+ echo "top: $(ps --no-headers -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
```
Suggested bounds for the metrics above:
@@ -455,10 +455,8 @@ groups:
| ------------ | ------------------------------------------------ |
| `name` | restrict to one metric name |
| `min`, `max` | unix seconds window to fetch |
-| `max_points` | max returned points (downsampled, default 100) |
-| `shared` | align downsampling to the `min`/`max` window |
-The frontend asks for the last ~24h at `max_points=100` per metric.
+The frontend asks for the last ~24h per metric.
## Storage
diff --git a/example.config.yaml b/example.config.yaml
index 740b05f..525dbe2 100644
--- a/example.config.yaml
+++ b/example.config.yaml
@@ -62,7 +62,7 @@
# echo "load: $(cat /proc/loadavg)"
# echo "mem: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
# echo "disk: $(df -h / | awk 'NR==2 {print $4 " free"}')"
-# echo "top: $(ps -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
+# echo "top: $(ps --no-headers -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
#
# Script format, one line per item:
# name:status:info -> a check chip (status: on / off / down / anything)
diff --git a/index.html b/index.html
index 7e8502d..ca374a4 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
Status Page
+
0.001 {
+ t.Errorf("uptime %v, want %v", *ms.Uptime, want)
+ }
+}
diff --git a/server/history.go b/server/history.go
index adee1fa..d321cba 100644
--- a/server/history.go
+++ b/server/history.go
@@ -108,6 +108,16 @@ func (h *History) RecordMetric(machine, name string, ts int64, value float64, un
}
}
+// Uptime counts the number of 'up' and 'down' history ticks for a machine.
+// A machine with no history returns zeroes.
+func (h *History) Uptime(machine string) (up, down int) {
+ h.db.QueryRow(`SELECT
+ COALESCE(SUM(CASE WHEN status = 'up' THEN 1 ELSE 0 END), 0),
+ COALESCE(SUM(CASE WHEN status = 'down' THEN 1 ELSE 0 END), 0)
+ FROM history WHERE machine = ?`, machine).Scan(&up, &down)
+ return
+}
+
func (h *History) Query(machine string, limit int) ([]HistoryEntry, error) {
if limit <= 0 || limit > historyPerMachine {
limit = historyPerMachine
@@ -132,10 +142,7 @@ func (h *History) Query(machine string, limit int) ([]HistoryEntry, error) {
return entries, rows.Err()
}
-func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64, maxPoints int, shared bool) ([]MetricEntry, error) {
- if maxPoints <= 0 || maxPoints > metricsPerName*5 {
- maxPoints = 100
- }
+func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64) ([]MetricEntry, error) {
q := `SELECT ts, name, value, unit FROM metrics WHERE machine = ?`
args := []any{machine}
if name != "" {
@@ -166,90 +173,7 @@ func (h *History) QueryMetrics(machine, name string, minTS, maxTS int64, maxPoin
}
entries = append(entries, e)
}
- if err := rows.Err(); err != nil {
- return nil, err
- }
- var lo, hi int64
- if shared {
- lo = minTS
- hi = maxTS
- }
- return downsampleMetrics(entries, lo, hi, maxPoints), nil
-}
-
-func downsampleMetrics(entries []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
- if maxPoints < 1 {
- return entries
- }
- groups := map[string][]MetricEntry{}
- order := []string{}
- for _, e := range entries {
- if _, ok := groups[e.Name]; !ok {
- order = append(order, e.Name)
- }
- groups[e.Name] = append(groups[e.Name], e)
- }
- out := []MetricEntry{}
- for _, name := range order {
- out = append(out, downsampleGroup(groups[name], lo, hi, maxPoints)...)
- }
- sort.Slice(out, func(i, j int) bool { return out[i].TS < out[j].TS })
- return out
-}
-
-func downsampleGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
- if len(g) <= maxPoints {
- return g
- }
- return alignGroup(g, lo, hi, maxPoints)
-}
-
-// alignGroup downsamples g to at most maxPoints buckets whose boundaries are
-// aligned to the [lo, hi] window, so all machines share the same bucket centers.
-func alignGroup(g []MetricEntry, lo, hi int64, maxPoints int) []MetricEntry {
- if len(g) == 0 {
- return nil
- }
- if lo <= 0 {
- lo = g[0].TS
- }
- if hi <= 0 {
- hi = g[len(g)-1].TS
- }
- span := hi - lo
- if span <= 0 {
- span = 1
- }
- bucketSize := float64(span) / float64(maxPoints)
- buckets := make([][]MetricEntry, maxPoints)
- for _, e := range g {
- idx := int(float64(e.TS-lo) / bucketSize)
- if idx >= maxPoints {
- idx = maxPoints - 1
- }
- if idx < 0 {
- idx = 0
- }
- buckets[idx] = append(buckets[idx], e)
- }
- res := make([]MetricEntry, 0, maxPoints)
- for i, b := range buckets {
- if len(b) == 0 {
- continue
- }
- var sum float64
- for _, e := range b {
- sum += e.Value
- }
- last := b[len(b)-1]
- res = append(res, MetricEntry{
- TS: lo + int64((float64(i)+0.5)*bucketSize),
- Name: last.Name,
- Value: sum / float64(len(b)),
- Unit: last.Unit,
- })
- }
- return res
+ return entries, rows.Err()
}
func (h *History) QueryMetricsAll(minTS, maxTS int64) ([]MetricAggregate, [2]int64, error) {
diff --git a/server/index.go b/server/index.go
new file mode 100644
index 0000000..e694518
--- /dev/null
+++ b/server/index.go
@@ -0,0 +1,103 @@
+package main
+
+import (
+ "fmt"
+ "html"
+ "io"
+ "log"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// indexPlaceholder marks the spot in index.html where the server injects the
+// title/description meta tags derived from the loaded config.
+const indexPlaceholder = ""
+
+// maxSummaryNames caps how many host patterns a single group line shows before
+// collapsing into "+N more".
+const maxSummaryNames = 4
+
+// machineSummary returns one line per configured group with its machine count
+// and the distinct host patterns it was expanded from (falling back to machine
+// names for top-level machines), mirroring the per-group recap printed by
+// --check.
+func machineSummary(cfg *Config) []string {
+ byGroup := map[string]int{}
+ patterns := map[string][]string{}
+ seen := map[string]map[string]bool{}
+ for _, m := range cfg.Machines {
+ byGroup[m.Group]++
+ }
+ for _, g := range cfg.Groups {
+ seen[g.Name] = map[string]bool{}
+ for _, mc := range g.Machines {
+ if !seen[g.Name][mc.Host] {
+ seen[g.Name][mc.Host] = true
+ patterns[g.Name] = append(patterns[g.Name], mc.Host)
+ }
+ }
+ }
+ lines := make([]string, 0, len(cfg.Groups)+1)
+ for _, g := range cfg.Groups {
+ lines = append(lines, formatGroupSummary(g.Name, byGroup[g.Name], patterns[g.Name]))
+ }
+ if n := byGroup[""]; n > 0 {
+ names := []string{}
+ s := map[string]bool{}
+ for _, m := range cfg.Machines {
+ if m.Group == "" && !s[m.Name] {
+ s[m.Name] = true
+ names = append(names, m.Name)
+ }
+ }
+ lines = append(lines, formatGroupSummary("(top-level)", n, names))
+ }
+ return lines
+}
+
+func formatGroupSummary(name string, n int, items []string) string {
+ line := fmt.Sprintf("%s: %d", name, n)
+ if len(items) == 0 {
+ return line
+ }
+ show := items
+ extra := ""
+ if len(items) > maxSummaryNames {
+ show = items[:maxSummaryNames]
+ extra = fmt.Sprintf(" +%d more", len(items)-maxSummaryNames)
+ }
+ return fmt.Sprintf("%s (%s%s)", line, strings.Join(show, ", "), extra)
+}
+
+func metaTags(title, description string) string {
+ if description == "" {
+ description = "status page"
+ }
+ return fmt.Sprintf(
+ "\n ",
+ html.EscapeString(title), html.EscapeString(description),
+ )
+}
+
+// indexHandler serves the built SPA, replacing indexPlaceholder in index.html
+// with config-derived meta tags. All other paths are delegated to the static
+// file server.
+func indexHandler(dist, title, description string) http.Handler {
+ indexPath := filepath.Join(dist, "index.html")
+ index, err := os.ReadFile(indexPath)
+ if err != nil {
+ log.Fatalf("reading %s: %v", indexPath, err)
+ }
+ body := strings.Replace(string(index), indexPlaceholder, metaTags(title, description), 1)
+ fs := http.FileServer(http.Dir(dist))
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/" && r.URL.Path != "/index.html" {
+ fs.ServeHTTP(w, r)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ io.WriteString(w, body)
+ })
+}
diff --git a/server/main.go b/server/main.go
index af10d3e..3fe55dd 100644
--- a/server/main.go
+++ b/server/main.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
+ "strings"
"github.com/joho/godotenv"
"github.com/spf13/pflag"
@@ -20,16 +21,9 @@ func envOr(key, def string) string {
}
func printConfigSummary(path string, cfg *Config) {
- byGroup := map[string]int{}
- for _, m := range cfg.Machines {
- byGroup[m.Group]++
- }
log.Printf("config %s ok: %d machines", path, len(cfg.Machines))
- for _, g := range cfg.Groups {
- log.Printf(" %s: %d", g.Name, byGroup[g.Name])
- }
- if n := byGroup[""]; n > 0 {
- log.Printf(" (top-level): %d", n)
+ for _, line := range machineSummary(cfg) {
+ log.Printf(" %s", line)
}
}
@@ -45,10 +39,6 @@ func buildFrontend() error {
return c.Run()
}
-func spaHandler() http.Handler {
- return http.FileServer(http.Dir("dist"))
-}
-
func main() {
_ = godotenv.Load()
@@ -76,6 +66,14 @@ func main() {
}
}
+ cfg, err := LoadConfig(*configPath)
+ if err != nil {
+ log.Fatalf("loading config: %v", err)
+ }
+ log.Printf("loaded %d machines from %s", len(cfg.Machines), *configPath)
+ title := cfg.Title
+ desc := strings.Join(machineSummary(cfg), ", ")
+
mux := http.NewServeMux()
if *mock {
@@ -87,7 +85,7 @@ func main() {
mux.HandleFunc("GET /api/metrics/{machine}", mm.metricsHandler)
mux.HandleFunc("POST /api/refresh/{machine}", mm.refreshHandler)
if !*devServer {
- mux.Handle("GET /", spaHandler())
+ mux.Handle("GET /", indexHandler("dist", title, desc))
}
log.Printf("listening on %s (mock)", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil {
@@ -102,12 +100,6 @@ func main() {
}
}
- cfg, err := LoadConfig(*configPath)
- if err != nil {
- log.Fatalf("loading config: %v", err)
- }
- log.Printf("loaded %d machines from %s", len(cfg.Machines), *configPath)
-
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -127,7 +119,7 @@ func main() {
mux.HandleFunc("GET /api/metrics/{machine}", mon.metricsHandler)
mux.HandleFunc("POST /api/refresh/{machine}", mon.refreshHandler)
if !*devServer {
- mux.Handle("GET /", spaHandler())
+ mux.Handle("GET /", indexHandler("dist", title, desc))
}
log.Printf("listening on %s", *addr)
diff --git a/server/mock.go b/server/mock.go
index 85d797d..4508f57 100644
--- a/server/mock.go
+++ b/server/mock.go
@@ -136,10 +136,25 @@ func (mm *MockMonitor) sshResult(m mockMachine, now time.Time) *sshResultState {
return &sshResultState{Ok: true, Result: sb.String(), ExitCode: nil, LastRun: now}
}
+func mockUptime(m mockMachine) *float64 {
+ switch m.Status {
+ case statusDown:
+ u := 55.0 + float64(mockSeed(m.ID)%20)
+ return &u
+ case statusDegraded:
+ u := 92.0 + float64(mockSeed(m.ID)%7)
+ return &u
+ default:
+ u := 99.0 + float64(mockSeed(m.ID)%10)/10
+ return &u
+ }
+}
+
func (mm *MockMonitor) machineStatus(m mockMachine, now time.Time) machineStatus {
ms := machineStatus{
ID: m.ID, Name: m.Name, Host: m.Host, Status: m.Status, LastPing: &now,
IP: m.IP, IPs: []string{m.IP},
+ Uptime: mockUptime(m),
}
switch m.Status {
case statusDown:
diff --git a/server/monitor.go b/server/monitor.go
index 00d439b..b152379 100644
--- a/server/monitor.go
+++ b/server/monitor.go
@@ -77,6 +77,7 @@ type machineStatus struct {
ICMP string `json:"icmp"`
TCP string `json:"tcp"`
LastPing *time.Time `json:"lastPing"`
+ Uptime *float64 `json:"uptime,omitempty"`
SSHConfigured bool `json:"sshConfigured"`
SSH *sshResultState `json:"ssh"`
Metrics []metricStatus `json:"metrics"`
@@ -648,6 +649,13 @@ func (m *Monitor) StatusPayload() statusPayload {
ms.ICMP = checkNA
ms.TCP = checkNA
}
+ if m.history != nil {
+ up, down := m.history.Uptime(mc.ID)
+ if up+down > 0 {
+ u := float64(up) / float64(up+down) * 100
+ ms.Uptime = &u
+ }
+ }
if group == "" {
p.Machines = append(p.Machines, ms)
return
@@ -703,16 +711,6 @@ func (m *Monitor) metricsHandler(w http.ResponseWriter, r *http.Request) {
return
}
q := r.URL.Query()
- maxPoints := 100
- if v := q.Get("max_points"); v != "" {
- if n, err := strconv.Atoi(v); err == nil {
- maxPoints = n
- }
- } else if v := q.Get("limit"); v != "" {
- if n, err := strconv.Atoi(v); err == nil {
- maxPoints = n
- }
- }
var minTS, maxTS int64
if v := q.Get("min"); v != "" {
minTS, _ = strconv.ParseInt(v, 10, 64)
@@ -720,7 +718,7 @@ func (m *Monitor) metricsHandler(w http.ResponseWriter, r *http.Request) {
if v := q.Get("max"); v != "" {
maxTS, _ = strconv.ParseInt(v, 10, 64)
}
- entries, err := m.history.QueryMetrics(machine, q.Get("name"), minTS, maxTS, maxPoints, q.Get("shared") != "")
+ entries, err := m.history.QueryMetrics(machine, q.Get("name"), minTS, maxTS)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
diff --git a/src/components/Grid.tsx b/src/components/Grid.tsx
index e8eccb1..eb74cc6 100644
--- a/src/components/Grid.tsx
+++ b/src/components/Grid.tsx
@@ -51,6 +51,12 @@ export function MachineCard({
{m.sshConfigured && }
+ {m.uptime !== undefined && (
+ = 99 ? 'ok' : 'fail'}
+ />
+ )}
e.stopPropagation()}>
@@ -64,6 +70,12 @@ export function MachineCard({
last ping
{formatTime(m.lastPing)}
+
+ uptime
+
+ {m.uptime !== undefined ? `${m.uptime.toFixed(1)}%` : '—'}
+
+
diff --git a/src/components/StatusModal.tsx b/src/components/StatusModal.tsx
index 05889a3..8df1c08 100644
--- a/src/components/StatusModal.tsx
+++ b/src/components/StatusModal.tsx
@@ -57,11 +57,10 @@ export function StatusModal({
setMetricEntries(null)
const nowS = Math.floor(Date.now() / 1000)
const min = nowS - 86400
- const shared = sharedMetricWindow ? '&shared=1' : ''
Promise.all(
metricNames.map((name) =>
fetch(
- `/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}&max_points=100${shared}`
+ `/api/metrics/${encodeURIComponent(m.id)}?name=${encodeURIComponent(name)}&min=${min}&max=${nowS}`
).then((r) => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
)
)
@@ -78,7 +77,7 @@ export function StatusModal({
return () => {
cancelled = true
}
- }, [m.id, metricNames.join(','), sharedMetricWindow])
+ }, [m.id, metricNames.join(',')])
const metricPlots = (() => {
if (!metricEntries) return null
@@ -247,24 +246,23 @@ export function StatusModal({
{hist && hist.length > 0 && (
- {ticks.map((e, i) => {
- const step = Math.max(1, Math.round(ticks.length / 4))
- const mark =
- i === 0 ||
- i === ticks.length - 1 ||
- i % step === 0
- return (
-
-
- {mark && (
- {formatTickShort(e.ts)}
- )}
-
- )
- })}
+ {(() => {
+ const last = ticks.length - 1
+ return ticks.map((e, i) => {
+ const mark = (last - i) % 20 === 0
+ return (
+
+
+ {mark && (
+ {formatTickShort(e.ts)}
+ )}
+
+ )
+ })
+ })()}
)}
{hist && hist.length > 0 && (
diff --git a/src/dark.css b/src/dark.css
index 25b374d..9d17718 100644
--- a/src/dark.css
+++ b/src/dark.css
@@ -41,7 +41,7 @@
--border-ink: #3a403a;
--border: 2px solid var(--border-ink);
--border-muted: #3a403a;
- --shadow-color: #000;
+ --shadow-color: #0004;
--shadow: 0.35rem 0.35rem 0 var(--shadow-color);
--shadow-lg: 0.55rem 0.55rem 0 var(--shadow-color);
--overlay: rgba(0, 0, 0, 0.6);
diff --git a/src/light.css b/src/light.css
index 20fbdd3..0b31099 100644
--- a/src/light.css
+++ b/src/light.css
@@ -45,7 +45,7 @@
--border-ink: #111;
--border: 2px solid var(--border-ink);
--border-muted: #bbb;
- --shadow-color: #888;
+ --shadow-color: #0004;
--shadow: 0.35rem 0.35rem 0 var(--shadow-color);
--shadow-lg: 0.55rem 0.55rem 0 var(--shadow-color);
--overlay: rgba(0, 0, 0, 0.35);
diff --git a/src/types.ts b/src/types.ts
index 8e51b16..cad7dc2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -33,6 +33,7 @@ export interface Machine {
icmp: Check
tcp: Check
lastPing: string | null
+ uptime?: number
sshConfigured: boolean
ssh: SshResult | null
metrics: MetricStatus[]