mirror of
https://github.com/aziis98/statuspage.git
synced 2026-10-06 22:55:21 +00:00
- 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)
486 lines
15 KiB
Markdown
486 lines
15 KiB
Markdown
# statuspage
|
|
|
|
A status page that monitors the machines in your lab (or home). It probes
|
|
machines over ICMP and TCP, runs a script over SSH to collect checks and
|
|
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.
|
|
|
|

|
|
|
|
## Features
|
|
|
|
- ICMP + TCP probes per machine, combined into a single status (`up`,
|
|
`degraded`, `down`, `unknown`).
|
|
- Optional SSH check that runs a script and parses:
|
|
- check lines like `name:status:info` (e.g. `vulkan:on:AMD Radeon`);
|
|
- metric lines like `name:metric:value` (e.g. `cpu_pct:metric:42.5`) that get
|
|
accumulated into a SQLite time series;
|
|
- free-form text after a `---` separator (shown raw in the modal).
|
|
- Per-machine history (status ticks + incidents) and per-metric line plots with
|
|
hover tooltips.
|
|
- Interactive refresh button (opt-in) to re-probe a machine on demand.
|
|
- A neo-brutalist web UI with no runtime JS framework bloat (Preact + Vite).
|
|
|
|
## Layout
|
|
|
|
```
|
|
statuspage
|
|
├── server/ the Go backend
|
|
│ ├── 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
|
|
├── go.mod module definition (statuspage)
|
|
├── src/ the frontend (Preact + TS + CSS)
|
|
├── example.config.yaml documented, fully commented-out example config
|
|
├── config.local.yaml local config (gitignored, contains ssh keys)
|
|
└── data.volume/ created at runtime, holds history.db
|
|
```
|
|
|
|
## Requirements
|
|
|
|
- Go 1.26+ (see the `go` directive in `go.mod`; the backend uses `http.ServeMux`
|
|
method patterns, added in 1.22).
|
|
- A C compiler for `mattn/go-sqlite3` (the `CGO_ENABLED=1` build).
|
|
- `bun` (or `npm`) for the frontend build.
|
|
|
|
## Building
|
|
|
|
```sh
|
|
bun install
|
|
bun run build
|
|
CGO_ENABLED=1 go build -o bin/statuspage ./server
|
|
```
|
|
|
|
Run it:
|
|
|
|
```sh
|
|
./bin/statuspage -c config.local.yaml
|
|
```
|
|
|
|
Then open `http://localhost:5000`.
|
|
|
|
### Docker
|
|
|
|
Clone the repo and set up your config inside it:
|
|
|
|
```sh
|
|
git clone https://git.phc.dm.unipi.it/aziis98/statuspage
|
|
cd statuspage
|
|
cp example.config.yaml config.local.yaml # your config goes here
|
|
docker compose up -d --build
|
|
```
|
|
|
|
The container mounts `./data.volume` over `/app/data.volume` (the default
|
|
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
|
|
|
|
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
|
|
bun dev # terminal 2, http://localhost:5173
|
|
```
|
|
|
|
`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 in both light and
|
|
dark, and frames each with a transparent background, border, and shadow. It
|
|
needs `chromium` and `python3-PIL` on PATH:
|
|
|
|
```sh
|
|
./scripts/generate-screenshot
|
|
```
|
|
|
|
This writes three PNGs to `assets/screenshots/`:
|
|
|
|
| file | contents |
|
|
| --------------------------- | ------------------------------------------------- |
|
|
| `status.png` | light capture |
|
|
| `status-dark.png` | dark capture |
|
|
| `status-collage.png` | dark behind, offset top-left, light on top |
|
|
|
|
Runs are incremental: a capture is skipped when no frontend/server source is
|
|
newer than the existing PNG, and the collage is rebuilt only when the mtime of a
|
|
light/dark variant is newer than the collage. `status-collage.png` is the image
|
|
at the top of this README.
|
|
|
|
### Flags
|
|
|
|
| flag | default | description |
|
|
| ------------------- | -------------- | -------------------------------------------------- |
|
|
| `-c, --config` | `config.local.yaml` | path to the yaml config file |
|
|
| `--addr` | `:5000` | listen address |
|
|
| `--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) |
|
|
| `--check` | `false` | load + expand the config, print a machine summary, then exit |
|
|
|
|
## Configuration
|
|
|
|
Everything lives in a single yaml file. A fully documented, commented-out
|
|
example with every option is in `example.config.yaml` — copy it to
|
|
`config.local.yaml` and fill it in. The top-level layout:
|
|
|
|
```yaml
|
|
title: "My machines"
|
|
interactive: true
|
|
shared_metric_window: false
|
|
|
|
ping: { ... }
|
|
ssh: { ... }
|
|
|
|
metrics:
|
|
cpu_pct: { min: 0, max: 100 }
|
|
|
|
groups:
|
|
- name: "Lab"
|
|
machines:
|
|
- host: a1.example.net
|
|
```
|
|
|
|
### `title`
|
|
|
|
```yaml
|
|
title: "Laboratori Dipartimento di Matematica"
|
|
```
|
|
|
|
Shown in the header and the browser tab. Defaults to `Status Page`.
|
|
|
|
### `interactive`
|
|
|
|
```yaml
|
|
interactive: true
|
|
```
|
|
|
|
Whether clients can trigger an on-demand probe via the refresh button on each
|
|
machine card. When `false`, the endpoint refuses refresh requests.
|
|
|
|
### `shared_metric_window`
|
|
|
|
```yaml
|
|
shared_metric_window: false
|
|
```
|
|
|
|
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), so you can
|
|
compare shapes directly.
|
|
|
|
### `ping`
|
|
|
|
```yaml
|
|
ping:
|
|
interval: 10s
|
|
timeout: 2s
|
|
tcp_port: 22
|
|
```
|
|
|
|
| key | default | description |
|
|
| ---------- | ------- | ----------------------------------------- |
|
|
| `interval` | `10s` | how often to probe |
|
|
| `timeout` | `2s` | per-probe timeout |
|
|
| `tcp_port` | `22` | port to TCP-connect for the tcp check |
|
|
|
|
Only IPv4 addresses are probed (IPv6 is ignored).
|
|
|
|
### `ssh`
|
|
|
|
```yaml
|
|
ssh:
|
|
interval: 5m
|
|
user: labuser
|
|
port: 22
|
|
key: |
|
|
-----BEGIN OPENSSH PRIVATE KEY-----
|
|
...
|
|
-----END OPENSSH PRIVATE KEY-----
|
|
script: |
|
|
echo "tmp:on:$(df -h /tmp 2>/dev/null | awk 'NR==2 {print $2}')"
|
|
echo "cpu_pct:metric:$(top -bn1 2>/dev/null | awk '/%Cpu/ {printf "%.1f", 100-$8}')"
|
|
echo "---"
|
|
echo "kernel: $(uname -sr)"
|
|
```
|
|
|
|
| key | default | description |
|
|
| ---------- | --------- | ---------------------------------------- |
|
|
| `interval` | `10m` | how often to run the script over ssh |
|
|
| `user` | `root` | ssh user |
|
|
| `port` | `22` | ssh port |
|
|
| `key` | — | PEM private key (the only supported auth)|
|
|
| `script` | — | shell script to run; see script format |
|
|
|
|
If `key` or `script` is empty, the ssh check is skipped for that machine.
|
|
|
|
#### Script format
|
|
|
|
One line per check, `name:status:info`:
|
|
|
|
```
|
|
vulkan:on:AMD Radeon RX 570
|
|
nvidia:off:no-nvidia-gpus
|
|
```
|
|
|
|
`status` can be `on`, `off`, `down`, or anything else (shown as `unknown`).
|
|
|
|
A line with `name:metric:value` is treated as a numeric metric, accumulated in
|
|
SQLite and drawn as a line plot:
|
|
|
|
```
|
|
cpu_pct:metric:42.5
|
|
ram_pct:metric:61.3
|
|
disk_free:metric:198.2GB
|
|
```
|
|
|
|
The value is parsed as a leading number with an optional unit suffix.
|
|
|
|
Everything after the first line containing `---` is treated as free-form output
|
|
and shown raw in the machine modal:
|
|
|
|
```
|
|
---
|
|
hostname: a1
|
|
kernel: Linux 6.8.0
|
|
uptime: up 3 days
|
|
```
|
|
|
|
#### `metrics`
|
|
|
|
Suggested `min`/`max` ranges for metric plots, keyed by metric name:
|
|
|
|
```yaml
|
|
metrics:
|
|
cpu_pct:
|
|
min: 0
|
|
max: 100
|
|
ram_pct:
|
|
min: 0
|
|
max: 100
|
|
disk_free:
|
|
min: 0
|
|
```
|
|
|
|
These are soft bounds: the plot scales to fit the data if a value goes outside
|
|
the range, and the current value is highlighted (with a wavy underline) when
|
|
out of range.
|
|
|
|
#### Copy-paste: Linux examples
|
|
|
|
Small, ready-to-paste snippets for a Linux box. Wrap commands that may not
|
|
exist on every machine (like `sensors` or `nvidia-smi`) in `2>/dev/null`; a
|
|
line that produces no output is skipped.
|
|
|
|
Status chips — `name:status:info`:
|
|
|
|
```yaml
|
|
ssh:
|
|
script: |
|
|
echo "hostname:on:$(hostname)"
|
|
echo "kernel:on:$(uname -sr)"
|
|
echo "vulkan:on:$(vulkaninfo --summary 2>/dev/null | sed -n '/deviceName/p' | head -1 | cut -d: -f2-)"
|
|
echo "docker:$(systemctl is-active docker 2>/dev/null | sed 's/active/on/; s/inactive/off/; s/failed/down/'):docker daemon"
|
|
```
|
|
|
|
CPU & memory metrics:
|
|
|
|
```yaml
|
|
ssh:
|
|
script: |
|
|
echo "cpu_pct:metric:$(top -bn1 2>/dev/null | awk '/%Cpu/ {printf "%.1f", 100-$8}')"
|
|
echo "load1:metric:$(cut -d' ' -f1 /proc/loadavg)"
|
|
echo "ram_pct:metric:$(free -m 2>/dev/null | awk '/^Mem:/ {printf "%.1f", $3/$2*100}')"
|
|
echo "ram_used:metric:$(free -m 2>/dev/null | awk '/^Mem:/ {print $3}')MB"
|
|
```
|
|
|
|
Disk usage:
|
|
|
|
```yaml
|
|
ssh:
|
|
script: |
|
|
echo "disk_root:metric:$(df -h / 2>/dev/null | awk 'NR==2 {print $5}' | tr -d '%')"
|
|
echo "disk_root_free:metric:$(df -h / 2>/dev/null | awk 'NR==2 {print $4}' | tr -d 'G')"
|
|
```
|
|
|
|
Temperatures & GPU:
|
|
|
|
```yaml
|
|
ssh:
|
|
script: |
|
|
echo "cpu_temp:metric:$(sensors 2>/dev/null | awk '/Package id 0:/ {print $4; exit}' | tr -d '+°C')"
|
|
echo "gpu_temp:metric:$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader 2>/dev/null | tr -d ' ')"
|
|
echo "gpu_util:metric:$(nvidia-smi --query-gpu=utilization.gpu --format=csv,noheader 2>/dev/null | tr -d ' %')"
|
|
```
|
|
|
|
Free-form output (everything after `---` is shown raw in the machine modal):
|
|
|
|
```yaml
|
|
ssh:
|
|
script: |
|
|
echo "---"
|
|
echo "uptime: $(uptime -p)"
|
|
echo "load: $(cat /proc/loadavg)"
|
|
echo "mem: $(free -h | awk '/^Mem:/ {print $3 "/" $2}')"
|
|
echo "top: $(ps --no-headers -eo %cpu,comm --sort=-%cpu | head -4 | tr '\n' ';')"
|
|
```
|
|
|
|
Suggested bounds for the metrics above:
|
|
|
|
```yaml
|
|
metrics:
|
|
cpu_pct: { min: 0, max: 100 }
|
|
ram_pct: { min: 0, max: 100 }
|
|
disk_root: { min: 0, max: 100 }
|
|
gpu_util: { min: 0, max: 100 }
|
|
gpu_temp: { min: 0, max: 105 }
|
|
```
|
|
|
|
A per-machine `script:` overrides the global one, e.g. for the box that
|
|
actually has the NVIDIA GPU:
|
|
|
|
```yaml
|
|
groups:
|
|
- name: "Lab"
|
|
machines:
|
|
- host: a1.example.net
|
|
- host: gpu.example.net
|
|
ssh:
|
|
script: |
|
|
echo "gpu_temp:metric:$(nvidia-smi --query-gpu=temperature.gpu --format=csv,noheader 2>/dev/null | tr -d ' ')"
|
|
```
|
|
|
|
### `groups`
|
|
|
|
```yaml
|
|
groups:
|
|
- name: "Aula 3"
|
|
machines:
|
|
- host: a3-dott1.example.net
|
|
- name: "front door pi"
|
|
host: 192.168.1.10
|
|
```
|
|
|
|
A `machines` entry only needs `host`; `name` overrides the display name.
|
|
Machines listed at the top level (outside any group) go into a `Machines`
|
|
section instead.
|
|
|
|
`host` accepts brace patterns that are expanded at load time:
|
|
|
|
- `{1,2,3}` — enumeration
|
|
- `{2..5,8..10}` — inclusive numeric ranges
|
|
- `{a3,a4}` — labels (any non-range item is kept literally)
|
|
- several groups multiply in cartesian fashion; leading zeros are preserved
|
|
|
|
```yaml
|
|
groups:
|
|
- name: "Server room"
|
|
machines:
|
|
- host: server-{a3,a4}-{1..10}.example.org
|
|
```
|
|
|
|
expands to `server-a3-1.example.org`, `server-a3-2.example.org`, ...,
|
|
`server-a4-10.example.org`. A `name:` applies to every expanded machine;
|
|
without one, each machine is named after its host. Ranges must go from a
|
|
smaller to a larger number, and their endpoints must be integers.
|
|
|
|
Malformed patterns make startup fail with an error instead of expanding
|
|
silently: unmatched or nested `{...}` groups, ranges with non-integer
|
|
endpoints, or ranges with extra dots (e.g. `{1..5..10}`). A single pattern may
|
|
expand to at most 10 000 machines; anything larger is rejected too.
|
|
|
|
Per-machine overrides for `ping` and `ssh` are supported, and inherit
|
|
unset values from the top level:
|
|
|
|
```yaml
|
|
groups:
|
|
- name: "Lab"
|
|
machines:
|
|
- host: special.example.net
|
|
ping:
|
|
interval: 30s
|
|
ssh:
|
|
interval: 1m
|
|
```
|
|
|
|
## HTTP API
|
|
|
|
| method | path | description |
|
|
| ------ | ------------------------ | -------------------------------------------- |
|
|
| `GET` | `/api/status` | current status, stats, metric ranges |
|
|
| `GET` | `/api/history/{machine}` | status tick history (`?limit=`, max 500) |
|
|
| `GET` | `/api/metrics/{machine}` | metric time series (see below) |
|
|
| `POST` | `/api/refresh/{machine}` | schedule an on-demand probe (interactive) |
|
|
|
|
`/api/metrics/{machine}` query params:
|
|
|
|
| param | description |
|
|
| ------------ | ------------------------------------------------ |
|
|
| `name` | restrict to one metric name |
|
|
| `min`, `max` | unix seconds window to fetch |
|
|
|
|
The frontend asks for the last ~24h per metric.
|
|
|
|
## Storage
|
|
|
|
A single SQLite file holds everything:
|
|
|
|
- `history` — status ticks per machine.
|
|
- `metrics` — accumulated metric samples per machine/name.
|
|
|
|
It's capped at 200k history rows (5000 per machine) and 5000 samples per
|
|
metric name, pruned every 10 minutes. The footer shows the database size and a
|
|
rough "size in a month" estimate.
|
|
|
|
## Notes
|
|
|
|
- The SSH host key check is disabled, since lab machines change frequently. Be
|
|
aware of the implications if you expose this outside a trusted network.
|
|
- SSH key authentication only; password auth is not supported.
|
|
- ICMP requires a raw socket or `CAP_NET_RAW`. When unavailable, the app falls
|
|
back to TCP-only probes.
|
|
- The frontend polls `/api/status` every 3 seconds.
|
|
- The database grows while the server runs; a prune loop keeps it bounded, but
|
|
it is not a production-grade time-series store.
|
|
|
|
## License
|
|
|
|
GNU Affero General Public License v3.0 (AGPL-3.0). See [LICENSE](LICENSE).
|