diff --git a/README.md b/README.md index 33df30d..f862fe5 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,8 @@ numeric metrics, and keeps recent history in SQLite. The backend is a single Go binary, the frontend is a Preact + Vite page, and everything is configured with one YAML file. +![statuspage](assets/screenshots/status.png) + ## Features - ICMP + TCP probes per machine, combined into a single status (`up`, @@ -29,6 +31,7 @@ statuspage │ ├── main.go entrypoint, flags, routes │ ├── 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 ├── go.mod module definition (statuspage) ├── src/ the frontend (Preact + TS + CSS) @@ -71,9 +74,24 @@ database location) and `./config.local.yaml` read-only at `/app/config.local.yaml`. `NET_RAW` is added so ICMP probes work; without it the app falls back to TCP-only. -### Development mode +## Development -The backend can skip serving the built frontend while you work on it with Vite: +Three flags change how the app runs while developing — they control what the +backend serves and whether it talks to real machines. + +### `--dev` + +Builds the frontend (`bun run build`) before serving `dist/`, so one command +gets you a running page without a separate build step: + +```sh +./bin/statuspage -c config.local.yaml --dev +``` + +### `--dev-server` + +Skips serving `dist/` entirely and lets the Vite dev server handle the +frontend, with hot reloading as you edit `src/`: ```sh ./bin/statuspage -c config.local.yaml --dev-server # terminal 1 @@ -83,6 +101,31 @@ bun dev # terminal 2, http://localhos `vite.config.ts` proxies `/api` to `http://localhost:5000`, so the backend must be running too. +### `--mock` + +Serves fully generated data (six `server-N` machines with noisy metric series) +instead of probing real machines — no config, SSH keys, or network access +needed. It's primarily used by `scripts/generate-screenshot` to produce the +README image, but is handy for previewing the UI quickly: + +```sh +./bin/statuspage --mock --dev +``` + +### Screenshot + +`scripts/generate-screenshot` builds the frontend and server, starts the app in +mock mode, captures the top of the page with headless Chromium, and frames it +with a transparent background, border, and shadow. It needs `chromium` and +`python3-PIL` on PATH: + +```sh +./scripts/generate-screenshot +``` + +The PNG is written to `assets/screenshots/status.png` — the image at the top of +this README. + ### Flags | flag | default | description | @@ -92,6 +135,7 @@ be running too. | `--db` | `data.volume/history.db` | sqlite database file | | `--dev` | `false` | run `bun run build` before serving `dist/` | | `--dev-server` | `false` | don't serve `dist/` (Vite dev server handles it) | +| `--mock` | `false` | serve generated mock data (no real probes) | ## Configuration diff --git a/assets/screenshots/status.png b/assets/screenshots/status.png new file mode 100644 index 0000000..435f3f8 Binary files /dev/null and b/assets/screenshots/status.png differ diff --git a/scripts/generate-screenshot b/scripts/generate-screenshot new file mode 100755 index 0000000..f41d4b2 --- /dev/null +++ b/scripts/generate-screenshot @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname "$0")/.." + +OUT="assets/screenshots/status.png" +PORT="${PORT:-17645}" +URL="http://127.0.0.1:$PORT" + +CHROMIUM="$(command -v chromium || command -v chromium-browser || true)" +if [ -z "$CHROMIUM" ]; then + echo "error: chromium not found (install chromium or chromium-browser)" >&2 + exit 1 +fi + +echo "building frontend..." +bun run build + +echo "building server..." +CGO_ENABLED=1 go build -o bin/statuspage ./server + +cleanup() { + if [ -n "${SERVER_PID:-}" ]; then + kill "$SERVER_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +echo "starting mock server on $URL" +./bin/statuspage --mock --addr "127.0.0.1:$PORT" & +SERVER_PID=$! + +for _ in $(seq 1 50); do + if curl -sf "$URL/api/status" >/dev/null 2>&1; then + break + fi + sleep 0.1 +done +if ! curl -sf "$URL/api/status" >/dev/null 2>&1; then + echo "error: mock server did not come up" >&2 + exit 1 +fi + +mkdir -p "$(dirname "$OUT")" + +"$CHROMIUM" --headless=new --disable-gpu --hide-scrollbars \ + --window-size=1280,720 \ + --force-device-scale-factor=2 \ + --screenshot="$OUT" \ + --virtual-time-budget=8000 \ + "$URL/" 2>/dev/null + +# frame the capture: rounded corners, 2px border, hard offset shadow (2x for device scale) +if ! python3 -c 'import PIL' 2>/dev/null; then + echo "note: python3-PIL not found, skipping frame" >&2 + echo "screenshot written to $OUT" + exit 0 +fi + +python3 - "$OUT" <<'PY' +import sys +from PIL import Image, ImageDraw + +path = sys.argv[1] +img = Image.open(path).convert("RGB") +W, H = img.size +r = 16 # 0.5rem radius +b = 4 # 2px border +s = 12 # 0.35rem shadow offset +m = 24 # margin around the frame +BORDER = (17, 17, 17) +SHADOW = (136, 136, 136) + +mask = Image.new("L", (W, H), 0) +ImageDraw.Draw(mask).rounded_rectangle([0, 0, W - 1, H - 1], radius=r, fill=255) + +img_r = img.copy() +img_r.putalpha(mask) + +bw, bh = W + 2 * b, H + 2 * b +canvas = Image.new("RGBA", (bw + 2 * m + s, bh + 2 * m + s), (0, 0, 0, 0)) +d = ImageDraw.Draw(canvas) +d.rounded_rectangle( + [m + s, m + s, m + s + bw - 1, m + s + bh - 1], radius=r, fill=SHADOW + (255,)) +d.rounded_rectangle( + [m, m, m + bw - 1, m + bh - 1], radius=r, fill=BORDER + (255,)) +canvas.alpha_composite(img_r, (m + b, m + b)) + +canvas.save(path) +print(f"framed {path}") +PY + +echo "screenshot written to $OUT" diff --git a/server/main.go b/server/main.go index f1023d6..a9d2b98 100644 --- a/server/main.go +++ b/server/main.go @@ -55,23 +55,44 @@ func main() { dev := pflag.Bool("dev", false, "run `npm run build` before serving dist/") devServer := pflag.Bool("dev-server", false, "frontend served by the vite dev server, do not serve dist/") + mock := pflag.Bool("mock", false, "serve generated mock data instead of probing real machines") addr := pflag.String("addr", envOr("ADDR", ":5000"), "listen address") configPath := pflag.StringP("config", "c", envOr("CONFIG", "config.local.yaml"), "yaml config file") dbPath := pflag.String("db", envOr("DB", "data.volume/history.db"), "sqlite history database file") pflag.Parse() - if dir := filepath.Dir(*dbPath); dir != "." { - if err := os.MkdirAll(dir, 0o755); err != nil { - log.Fatalf("creating db dir: %v", err) - } - } - if *dev { if err := buildFrontend(); err != nil { log.Fatalf("build failed: %v", err) } } + mux := http.NewServeMux() + + if *mock { + log.Printf("mock mode: serving generated data (no real probes)") + mm := NewMockMonitor() + mux.HandleFunc("GET /api/status", mm.statusHandler) + mux.HandleFunc("GET /api/history/{machine}", mm.historyHandler) + mux.HandleFunc("GET /api/metrics/aggregate", mm.metricsAggregateHandler) + mux.HandleFunc("GET /api/metrics/{machine}", mm.metricsHandler) + mux.HandleFunc("POST /api/refresh/{machine}", mm.refreshHandler) + if !*devServer { + mux.HandleFunc("GET /", spaHandler()) + } + log.Printf("listening on %s (mock)", *addr) + if err := http.ListenAndServe(*addr, mux); err != nil { + log.Fatal(err) + } + return + } + + if dir := filepath.Dir(*dbPath); dir != "." { + if err := os.MkdirAll(dir, 0o755); err != nil { + log.Fatalf("creating db dir: %v", err) + } + } + cfg, err := LoadConfig(*configPath) if err != nil { log.Fatalf("loading config: %v", err) @@ -91,7 +112,6 @@ func main() { mon := NewMonitor(cfg, history) mon.Start(ctx) - mux := http.NewServeMux() mux.HandleFunc("GET /api/status", mon.statusHandler) mux.HandleFunc("GET /api/history/{machine}", mon.historyHandler) mux.HandleFunc("GET /api/metrics/aggregate", mon.metricsAggregateHandler) diff --git a/server/mock.go b/server/mock.go new file mode 100644 index 0000000..85d797d --- /dev/null +++ b/server/mock.go @@ -0,0 +1,354 @@ +package main + +import ( + "encoding/json" + "fmt" + "math" + "math/rand/v2" + "net/http" + "strconv" + "strings" + "time" +) + +type mockMetric struct { + Name, Unit string + Base, Amp float64 + PeriodSecs float64 + Phase float64 + Min, Max float64 +} + +type mockService struct { + Name string + Status string // on | off | down +} + +type mockMachine struct { + ID, Name, Host, IP, Group, Status string + Metrics []mockMetric + Services []mockService +} + +type mockGroup struct { + name string + machines []mockMachine +} + +type MockMonitor struct { + title string + interactive bool + groups []mockGroup + all []mockMachine + ranges map[string]metricRange +} + +func mockSeed(s string) uint64 { + var h uint64 = 14695981039346656037 + for i := 0; i < len(s); i++ { + h ^= uint64(s[i]) + h *= 1099511628211 + } + return h +} + +func mockPtr(v float64) *float64 { return &v } + +func NewMockMonitor() *MockMonitor { + mm := &MockMonitor{ + title: "statuspage", + interactive: true, + ranges: map[string]metricRange{ + "cpu": {Min: mockPtr(0), Max: mockPtr(100)}, + }, + } + + for i := 1; i <= 6; i++ { + st := statusUp + if i == 3 { + st = statusDegraded + } + if i == 6 { + st = statusDown + } + m := mockMachine{ + ID: fmt.Sprintf("server-%d", i), + Name: fmt.Sprintf("server-%d", i), + Host: fmt.Sprintf("server-%d.lan", i), + IP: fmt.Sprintf("192.168.1.%d", 10+i), + Group: "Servers", + Status: st, + Metrics: []mockMetric{{ + Name: "cpu", + Unit: "%", + Base: 20 + float64(i*6), + Amp: float64(14 + i*3), + PeriodSecs: 1200 + float64(i*360), + Phase: float64(i) * 0.7, + Min: 0, + Max: 100, + }}, + } + mm.all = append(mm.all, m) + } + mm.groups = []mockGroup{{name: "Servers", machines: mm.all}} + return mm +} + +func (mm *MockMonitor) find(id string) *mockMachine { + for i := range mm.all { + if mm.all[i].ID == id { + return &mm.all[i] + } + } + return nil +} + +func (mm *MockMonitor) metricValue(m mockMetric, ts int64) float64 { + t := float64(ts) + v := m.Base + m.Amp*math.Sin(2*math.Pi*t/m.PeriodSecs+m.Phase) + v += m.Amp * 0.35 * math.Sin(2*math.Pi*t/300+m.Phase*2.7) + rng := rand.New(rand.NewPCG(mockSeed(m.Name), uint64(ts/45))) + v += (rng.Float64()*2 - 1) * m.Amp * 0.55 + if m.Max > m.Min { + v = math.Max(m.Min, math.Min(m.Max, v)) + } + return math.Round(v*10) / 10 +} + +func (mm *MockMonitor) sshResult(m mockMachine, now time.Time) *sshResultState { + var sb strings.Builder + for _, s := range m.Services { + info := "running" + switch s.Status { + case "off": + info = "stopped" + case "down": + info = "not responding" + } + fmt.Fprintf(&sb, "%s: %s: %s\n", s.Name, s.Status, info) + } + for _, met := range m.Metrics { + fmt.Fprintf(&sb, "%s: metric: %.1f %s\n", met.Name, mm.metricValue(met, now.Unix()), met.Unit) + } + sb.WriteString("---\n") + sb.WriteString("uptime 6 days\nkernel 6.8.0-45-generic\n") + return &sshResultState{Ok: true, Result: sb.String(), ExitCode: nil, LastRun: now} +} + +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}, + } + switch m.Status { + case statusDown: + ms.ICMP, ms.TCP = checkFail, checkFail + case statusDegraded: + ms.ICMP, ms.TCP = checkOK, checkFail + default: + ms.ICMP, ms.TCP = checkOK, checkOK + } + if len(m.Services) > 0 || len(m.Metrics) > 0 { + ms.SSHConfigured = true + ms.SSH = mm.sshResult(m, now) + } + for _, met := range m.Metrics { + ms.Metrics = append(ms.Metrics, metricStatus{ + Name: met.Name, Value: mm.metricValue(met, now.Unix()), Unit: met.Unit, Updated: &now, + }) + } + return ms +} + +func (mm *MockMonitor) StatusPayload() statusPayload { + now := time.Now() + p := statusPayload{ + Title: mm.title, + Interactive: mm.interactive, + SharedMetricWindow: true, + Stats: statsPayload{ + PingInterval: "15s", + PingTimeout: "2s", + TCPPort: 22, + SSHInterval: "5m", + SSHEnabled: true, + DBSize: 1400000, + DBRows: 2300, + DBSizeMonth: 6200000, + }, + MetricRanges: mm.ranges, + Groups: []groupStatus{}, + Machines: []machineStatus{}, + } + for _, g := range mm.groups { + gs := groupStatus{Name: g.name, Machines: []machineStatus{}} + for _, m := range g.machines { + gs.Machines = append(gs.Machines, mm.machineStatus(m, now)) + } + p.Groups = append(p.Groups, gs) + } + return p +} + +func (mm *MockMonitor) historyFor(m mockMachine, n int) []HistoryEntry { + rng := rand.New(rand.NewPCG(mockSeed(m.ID), 0x9e3779b97f4a7c15)) + now := time.Now().Unix() + const interval = int64(15) + status := m.Status + out := make([]HistoryEntry, 0, n) + for i := n - 1; i >= 0; i-- { + ts := now - int64(n-1-i)*interval + if status != m.Status { + if rng.Float64() < 0.22 { + status = m.Status + } + } else if rng.Float64() < 0.018 { + if rng.Float64() < 0.5 { + status = statusDown + } else { + status = statusDegraded + } + } + out = append(out, HistoryEntry{TS: ts, Status: status, IP: m.IP}) + } + return out +} + +func (mm *MockMonitor) metricsInWindow(m mockMachine, minTS, maxTS int64, points int) []MetricEntry { + if points < 1 { + points = 100 + } + if maxTS <= minTS { + return nil + } + out := []MetricEntry{} + for i := 0; i < points; i++ { + f := float64(i) / float64(points-1) + ts := minTS + int64(f*float64(maxTS-minTS)) + for _, met := range m.Metrics { + out = append(out, MetricEntry{ + TS: ts, Name: met.Name, Value: mm.metricValue(met, ts), Unit: met.Unit, + }) + } + } + return out +} + +func (mm *MockMonitor) statusHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mm.StatusPayload()) +} + +func (mm *MockMonitor) historyHandler(w http.ResponseWriter, r *http.Request) { + m := mm.find(r.PathValue("machine")) + if m == nil { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + limit := 500 + if v := r.URL.Query().Get("limit"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + limit = n + } + } + if limit < 1 { + limit = 1 + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mm.historyFor(*m, limit)) +} + +func (mm *MockMonitor) metricsHandler(w http.ResponseWriter, r *http.Request) { + m := mm.find(r.PathValue("machine")) + if m == nil { + http.Error(w, "machine not found", http.StatusNotFound) + return + } + q := r.URL.Query() + now := time.Now().Unix() + maxTS := now + if v := q.Get("max"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + maxTS = n + } + } + minTS := maxTS - 86400 + if v := q.Get("min"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + minTS = n + } + } + points := 100 + if v := q.Get("max_points"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + points = n + } + } + name := q.Get("name") + entries := mm.metricsInWindow(*m, minTS, maxTS, points) + if name != "" { + filtered := entries[:0] + for _, e := range entries { + if e.Name == name { + filtered = append(filtered, e) + } + } + entries = filtered + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(entries) +} + +func (mm *MockMonitor) metricsAggregateHandler(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + now := time.Now().Unix() + maxTS := now + if v := q.Get("max"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + maxTS = n + } + } + minTS := maxTS - 86400 + if v := q.Get("min"); v != "" { + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + minTS = n + } + } + const points = 200 + type nameGroup struct { + unit string + series []MetricSeries + } + byName := map[string]*nameGroup{} + order := []string{} + for _, m := range mm.all { + for _, e := range mm.metricsInWindow(m, minTS, maxTS, points) { + ng, ok := byName[e.Name] + if !ok { + ng = &nameGroup{unit: e.Unit} + byName[e.Name] = ng + order = append(order, e.Name) + } + if len(ng.series) == 0 || ng.series[len(ng.series)-1].Machine != m.Name { + ng.series = append(ng.series, MetricSeries{Machine: m.Name, Samples: []MetricEntry{}}) + } + s := &ng.series[len(ng.series)-1] + s.Samples = append(s.Samples, e) + } + } + out := make([]MetricAggregate, 0, len(order)) + for _, name := range order { + ng := byName[name] + out = append(out, MetricAggregate{Name: name, Unit: ng.unit, Series: ng.series}) + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(struct { + Window [2]int64 `json:"window"` + Metrics []MetricAggregate `json:"metrics"` + }{[2]int64{minTS, maxTS}, out}) +} + +func (mm *MockMonitor) refreshHandler(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusAccepted) +}