ui: uptime chip, #0004 shadows, tick marks, raw metrics; server: config meta injection

- ui: add uptime% chip (green >=99, red below) after ssh; remove "up" word
- ui: all shadows use #0004 shadow-color; modal shadow-lg derives from it
- ui: status modal draws a datetime tick mark every 20 history entries from the newest
- server: drop max_points/downsampling/bucketing; metrics served raw, plots share a ~24h window
- docs: fix ps --no-headers in top example; document raw metrics API
- server: inject <meta name=title/description> into index.html from config title + per-group host patterns (shared with --check recap)
This commit is contained in:
2026-08-02 02:06:27 +02:00
parent 6ade221621
commit c988ddf22f
14 changed files with 238 additions and 151 deletions
+5 -7
View File
@@ -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
+1 -1
View File
@@ -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)
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Status Page</title>
<!-- statuspage:inject -->
<link
rel="icon"
type="image/svg+xml"
+45
View File
@@ -1,6 +1,7 @@
package main
import (
"math"
"os"
"path/filepath"
"reflect"
@@ -165,3 +166,47 @@ machines:
t.Errorf("top-level host: %q", p.Machines[0].Host)
}
}
func TestStatusPayloadUptime(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "config.yaml")
cfg := `
machines:
- host: a3-dott1.cs.dm.unipi.it
`
if err := os.WriteFile(cfgPath, []byte(cfg), 0o644); err != nil {
t.Fatal(err)
}
conf, err := LoadConfig(cfgPath)
if err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(dir, "h.db")
hist, err := NewHistory(dbPath)
if err != nil {
t.Fatal(err)
}
defer hist.Close()
id := conf.Machines[0].ID
for i := 0; i < 10; i++ {
hist.Record(id, statusUp, "")
}
for i := 0; i < 5; i++ {
hist.Record(id, statusDown, "")
}
hist.Record(id, statusDegraded, "")
mon := NewMonitor(conf, hist)
p := mon.StatusPayload()
if len(p.Machines) != 1 {
t.Fatalf("want 1 machine, got %d", len(p.Machines))
}
ms := p.Machines[0]
if ms.Uptime == nil {
t.Fatal("expected uptime")
}
want := float64(10) / float64(15) * 100
if math.Abs(*ms.Uptime-want) > 0.001 {
t.Errorf("uptime %v, want %v", *ms.Uptime, want)
}
}
+12 -88
View File
@@ -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) {
+103
View File
@@ -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 = "<!-- statuspage:inject -->"
// 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(
"<meta name=\"title\" content=\"%s\" />\n <meta name=\"description\" content=\"%s\" />",
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)
})
}
+13 -21
View File
@@ -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)
+15
View File
@@ -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:
+9 -11
View File
@@ -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
+12
View File
@@ -51,6 +51,12 @@ export function MachineCard({
<Chip label="ping" state={m.icmp} />
<Chip label="tcp" state={m.tcp} />
{m.sshConfigured && <Chip label="ssh" state={sshState} />}
{m.uptime !== undefined && (
<Chip
label={`${m.uptime.toFixed(1)}%`}
state={m.uptime >= 99 ? 'ok' : 'fail'}
/>
)}
</div>
<div class="popover" onClick={(e) => e.stopPropagation()}>
@@ -64,6 +70,12 @@ export function MachineCard({
<span class="popover-meta-key">last ping</span>
<span class="popover-meta-val">{formatTime(m.lastPing)}</span>
</span>
<span class="popover-meta-item">
<span class="popover-meta-key">uptime</span>
<span class="popover-meta-val">
{m.uptime !== undefined ? `${m.uptime.toFixed(1)}%` : '—'}
</span>
</span>
</div>
<span class="popover-status">
<Dot status={m.status} />
+19 -21
View File
@@ -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({
</div>
{hist && hist.length > 0 && (
<div class="ticks" ref={ticksRef}>
{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 (
<span key={i} class="tick-cell">
<span
class={`tick ${e.status}`}
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
/>
{mark && (
<span class="tick-mark">{formatTickShort(e.ts)}</span>
)}
</span>
)
})}
{(() => {
const last = ticks.length - 1
return ticks.map((e, i) => {
const mark = (last - i) % 20 === 0
return (
<span key={i} class="tick-cell">
<span
class={`tick ${e.status}`}
title={`${formatTick(e.ts)} · ${e.status}${e.ip ? ` · ${e.ip}` : ''}`}
/>
{mark && (
<span class="tick-mark">{formatTickShort(e.ts)}</span>
)}
</span>
)
})
})()}
</div>
)}
{hist && hist.length > 0 && (
+1 -1
View File
@@ -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);
+1 -1
View File
@@ -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);
+1
View File
@@ -33,6 +33,7 @@ export interface Machine {
icmp: Check
tcp: Check
lastPing: string | null
uptime?: number
sshConfigured: boolean
ssh: SshResult | null
metrics: MetricStatus[]