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