mirror of
https://github.com/aziis98/statuspage.git
synced 2026-10-06 06:35:03 +00:00
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:
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user