mirror of
https://github.com/aziis98/statuspage.git
synced 2026-10-06 22:55:21 +00:00
server: add --mock mode with generated data; scripts/generate-screenshot for README image
This commit is contained in:
@@ -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
|
The backend is a single Go binary, the frontend is a Preact + Vite page, and
|
||||||
everything is configured with one YAML file.
|
everything is configured with one YAML file.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- ICMP + TCP probes per machine, combined into a single status (`up`,
|
- ICMP + TCP probes per machine, combined into a single status (`up`,
|
||||||
@@ -29,6 +31,7 @@ statuspage
|
|||||||
│ ├── main.go entrypoint, flags, routes
|
│ ├── main.go entrypoint, flags, routes
|
||||||
│ ├── config.go yaml config loading + defaults
|
│ ├── config.go yaml config loading + defaults
|
||||||
│ ├── monitor.go probes, ssh runs, status/history/metrics handlers
|
│ ├── 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 and downsampling
|
||||||
├── go.mod module definition (statuspage)
|
├── go.mod module definition (statuspage)
|
||||||
├── src/ the frontend (Preact + TS + CSS)
|
├── 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
|
`/app/config.local.yaml`. `NET_RAW` is added so ICMP probes work; without it
|
||||||
the app falls back to TCP-only.
|
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
|
```sh
|
||||||
./bin/statuspage -c config.local.yaml --dev-server # terminal 1
|
./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
|
`vite.config.ts` proxies `/api` to `http://localhost:5000`, so the backend must
|
||||||
be running too.
|
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
|
### Flags
|
||||||
|
|
||||||
| flag | default | description |
|
| flag | default | description |
|
||||||
@@ -92,6 +135,7 @@ be running too.
|
|||||||
| `--db` | `data.volume/history.db` | sqlite database file |
|
| `--db` | `data.volume/history.db` | sqlite database file |
|
||||||
| `--dev` | `false` | run `bun run build` before serving `dist/` |
|
| `--dev` | `false` | run `bun run build` before serving `dist/` |
|
||||||
| `--dev-server` | `false` | don't serve `dist/` (Vite dev server handles it) |
|
| `--dev-server` | `false` | don't serve `dist/` (Vite dev server handles it) |
|
||||||
|
| `--mock` | `false` | serve generated mock data (no real probes) |
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 532 KiB |
Executable
+93
@@ -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"
|
||||||
+27
-7
@@ -55,23 +55,44 @@ func main() {
|
|||||||
|
|
||||||
dev := pflag.Bool("dev", false, "run `npm run build` before serving dist/")
|
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/")
|
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")
|
addr := pflag.String("addr", envOr("ADDR", ":5000"), "listen address")
|
||||||
configPath := pflag.StringP("config", "c", envOr("CONFIG", "config.local.yaml"), "yaml config file")
|
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")
|
dbPath := pflag.String("db", envOr("DB", "data.volume/history.db"), "sqlite history database file")
|
||||||
pflag.Parse()
|
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 *dev {
|
||||||
if err := buildFrontend(); err != nil {
|
if err := buildFrontend(); err != nil {
|
||||||
log.Fatalf("build failed: %v", err)
|
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)
|
cfg, err := LoadConfig(*configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("loading config: %v", err)
|
log.Fatalf("loading config: %v", err)
|
||||||
@@ -91,7 +112,6 @@ func main() {
|
|||||||
mon := NewMonitor(cfg, history)
|
mon := NewMonitor(cfg, history)
|
||||||
mon.Start(ctx)
|
mon.Start(ctx)
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
|
||||||
mux.HandleFunc("GET /api/status", mon.statusHandler)
|
mux.HandleFunc("GET /api/status", mon.statusHandler)
|
||||||
mux.HandleFunc("GET /api/history/{machine}", mon.historyHandler)
|
mux.HandleFunc("GET /api/history/{machine}", mon.historyHandler)
|
||||||
mux.HandleFunc("GET /api/metrics/aggregate", mon.metricsAggregateHandler)
|
mux.HandleFunc("GET /api/metrics/aggregate", mon.metricsAggregateHandler)
|
||||||
|
|||||||
+354
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user