Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d966ba8f | |||
| 74cb42ea28 | |||
| 2382aebc10 | |||
| 9105cba380 | |||
| a8f69cd7cc | |||
| 1f1dd32741 | |||
| 231176ce41 | |||
| 5f7185e5b1 | |||
| a6283d9d43 | |||
| 7a1ea39f78 | |||
| e8656b09a7 | |||
| 5c54a1cd91 | |||
| 7b45c8fcf0 | |||
| cbb311d877 | |||
| d30dd5906a | |||
| 40c0d9e5a0 | |||
| d1913c4278 | |||
| eedd86e571 | |||
| c07079497b | |||
| 4cfd7159bf | |||
| 624d8d8e44 | |||
| ce5c089413 | |||
| 2d192f3a32 | |||
| 481839d348 | |||
| 1b14a3ed33 | |||
| d6f65c58f6 | |||
| 6d7c0ce58b |
@@ -0,0 +1,7 @@
|
||||
.git
|
||||
.gitea
|
||||
.claude
|
||||
.github
|
||||
dist
|
||||
*.md
|
||||
install.sh
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Container image
|
||||
|
||||
# Builds the multi-arch container image. On tag push (v*) it logs in
|
||||
# to the Gitea registry on this host and publishes the image as
|
||||
# git.cer.sh/<owner>/<repo>:<version> plus :latest. On pull requests
|
||||
# it builds without pushing — purely a smoke test that the Dockerfile
|
||||
# still works.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
image:
|
||||
runs-on: ubuntu-latest
|
||||
# The default `ubuntu-latest` label on aether-runner maps to
|
||||
# `node:16-bullseye`, which has no docker CLI — so the docker/*
|
||||
# actions fail. Override the job container to catthehacker's
|
||||
# act-compatible image (ships docker CLI + buildx) and mount the
|
||||
# host's docker socket through. The runner already has the socket
|
||||
# bind-mounted from the host (see docker.yml gitea-runner volume),
|
||||
# so this exposes that same daemon to the nested job container.
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
# github.repository is owner/name with the repo's original casing;
|
||||
# registries require lowercase, so normalise once here and reuse
|
||||
# the result in metadata-action below.
|
||||
- name: Resolve image name
|
||||
id: img
|
||||
run: |
|
||||
repo='${{ github.repository }}'
|
||||
echo "ref=git.cer.sh/${repo,,}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Gitea registry
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.cer.sh
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ steps.img.outputs.ref }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
|
||||
|
||||
- name: Build (and push on tag)
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.event_name == 'push' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
VERSION=${{ github.ref_name }}
|
||||
# Inline cache embeds layer metadata into the pushed image
|
||||
# itself — no external cache server needed, which keeps the
|
||||
# workflow self-contained on the Gitea runner.
|
||||
cache-from: type=inline
|
||||
cache-to: type=inline
|
||||
@@ -22,9 +22,9 @@ jobs:
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23'
|
||||
check-latest: true
|
||||
cache: true
|
||||
go-version: '1.24'
|
||||
check-latest: false
|
||||
cache: false
|
||||
|
||||
- name: Test
|
||||
run: go test -race ./...
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Build stage. Runs on the runner's native arch (BUILDPLATFORM) and
|
||||
# cross-compiles the Go binary for whichever target the manifest list
|
||||
# is being assembled for (TARGETOS/TARGETARCH). Keeps multi-arch
|
||||
# builds fast — only the final link is per-arch, the Go toolchain is
|
||||
# always native.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG VERSION=dev
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
# Module cache layer — re-uses unless go.mod/go.sum change.
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
|
||||
go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w -X main.version=${VERSION}" \
|
||||
-o /out/qu \
|
||||
./cmd/qu
|
||||
|
||||
# Runtime stage. distroless/static has CA roots for HTTPS probes and
|
||||
# nothing else — no shell, no package manager. Runs as root so the
|
||||
# daemon can open ICMP sockets and write under /etc/quptime; operators
|
||||
# can override at deploy time with `docker run --user`.
|
||||
FROM gcr.io/distroless/static-debian12:latest
|
||||
|
||||
COPY --from=builder /out/qu /usr/local/bin/qu
|
||||
|
||||
ENV QUPTIME_DIR=/etc/quptime
|
||||
VOLUME ["/etc/quptime"]
|
||||
EXPOSE 9901
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/qu"]
|
||||
CMD ["serve"]
|
||||
@@ -10,6 +10,16 @@ A single static binary contains the daemon, the CLI, and everything in
|
||||
between. Inter-node traffic is mutual TLS with SSH-style fingerprint
|
||||
trust — no central CA, no shared secret.
|
||||
|
||||
## Installation
|
||||
|
||||
### From pre-built binary
|
||||
|
||||
This can be done in one step, either by downloading the latest release from
|
||||
the [Gitea releases page](https://git.cer.sh/axodouble/quptime/releases) or by running the following script:
|
||||
```sh
|
||||
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
Most uptime monitors are either a SaaS or a single box that, by
|
||||
@@ -44,9 +54,15 @@ Master election is deterministic: among the live members of the quorum,
|
||||
the node with the lexicographically smallest NodeID wins. No
|
||||
negotiation, no split-brain window.
|
||||
|
||||
`cluster.yaml` is the single replicated source of truth (peers, checks,
|
||||
alerts). Mutations from the CLI route through the master, which bumps a
|
||||
monotonic version and broadcasts the result. The same file is also
|
||||
watched on disk, so an operator can `sudoedit cluster.yaml` on any node
|
||||
and the daemon will replicate the edit cluster-wide.
|
||||
|
||||
## Build
|
||||
|
||||
Requires Go 1.23 or newer.
|
||||
Requires Go 1.24.2 or newer.
|
||||
|
||||
```sh
|
||||
go build -o qu ./cmd/qu
|
||||
@@ -150,6 +166,223 @@ Mutations always route to the master, which bumps a monotonic version
|
||||
and pushes the new `cluster.yaml` to every peer. If quorum is lost,
|
||||
mutating commands fail loudly.
|
||||
|
||||
`qu status` shows the effective alert list for each check. Default
|
||||
alerts are suffixed with `*` so you can tell at a glance which alerts
|
||||
were attached automatically vs explicitly listed on the check:
|
||||
|
||||
```
|
||||
CHECKS
|
||||
ID NAME STATE OK/TOTAL ALERTS DETAIL
|
||||
ddbd... homepage up 3/3 oncall,ops*
|
||||
0006... db down 1/3 ops* dial timeout
|
||||
24f4... gateway up 3/3 -
|
||||
(alerts marked * are attached as defaults)
|
||||
```
|
||||
|
||||
## Default alerts (attach to every check)
|
||||
|
||||
Rather than listing the same `--alerts` on every `check add`, mark an
|
||||
alert as default and it fires for every check automatically:
|
||||
|
||||
```sh
|
||||
# at creation
|
||||
qu alert add discord oncall --webhook https://... --default
|
||||
|
||||
# or toggle later
|
||||
qu alert default oncall on
|
||||
qu alert default oncall off
|
||||
```
|
||||
|
||||
`qu alert list` shows a DEFAULT column. A check can opt out of a
|
||||
specific default by adding the alert's ID or name to its
|
||||
`suppress_alert_ids` list in `cluster.yaml` (see "Edit cluster.yaml
|
||||
directly" below).
|
||||
|
||||
## Interactive TUI
|
||||
|
||||
Prefer a dashboard over typing commands? `qu tui` opens a full-screen
|
||||
[bubbletea](https://github.com/charmbracelet/bubbletea) UI over the
|
||||
local daemon socket. The header shows quorum, master, term, and config
|
||||
version; three tabs hold peers, checks, and alerts with auto-refresh
|
||||
every two seconds.
|
||||
|
||||
```
|
||||
┌─ QUptime ── node: 88a00af9 master: 3438fd6f (follower) ● quorum 3/2 term 4 ver 10 ──┐
|
||||
│ Peers (3) [2] Checks (3) [3] Alerts (1) │
|
||||
├──────────────────────────────────────────────────────────────────────────────────────────────┤
|
||||
│ ID NAME STATE OK/TOTAL ALERTS DETAIL │
|
||||
│ ddbd... homepage ● up 3/3 oncall* │
|
||||
│ 0006... db ● down 1/3 oncall* dial timeout │
|
||||
│ 24f4... gateway ○ unknown 0/0 - │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────┘
|
||||
↑↓ navigate ⇥ next tab 1/2/3 jump r refresh a add check d remove check q quit
|
||||
```
|
||||
|
||||
Keybindings:
|
||||
|
||||
| Key | Action |
|
||||
| ------------------- | --------------------------------------------------------- |
|
||||
| `↑` / `↓` | move cursor within a tab |
|
||||
| `Tab` / `Shift+Tab` | next / previous tab |
|
||||
| `1` / `2` / `3` | jump to Peers / Checks / Alerts |
|
||||
| `r` | force-refresh |
|
||||
| `a` | add (opens a picker on Checks/Alerts; node form on Peers) |
|
||||
| `d` | remove the selected row (confirmation prompt) |
|
||||
| `t` | send a test message to the selected alert |
|
||||
| `D` | toggle the selected alert's `default` flag |
|
||||
| `q` / `Ctrl+C` | quit |
|
||||
|
||||
Forms run the same control-plane methods the CLI does, so any side
|
||||
effect (a mutation, a node add, an alert test) ends up routed through
|
||||
the master exactly like `qu …` from the shell.
|
||||
|
||||
## Custom alert messages
|
||||
|
||||
Each alert can carry its own `subject_template` and `body_template`
|
||||
(Go `text/template` syntax). When set, they override the built-in
|
||||
formatting for that one alert; the default renderer is used otherwise.
|
||||
Discord ignores the subject template (it has no subject line).
|
||||
|
||||
```sh
|
||||
qu alert add discord oncall --webhook https://... \
|
||||
--body ':rotating_light: **{{.Check.Name}}** is now {{.Verb}}
|
||||
target: `{{.Check.Target}}`
|
||||
detail: {{.Snapshot.Detail}}'
|
||||
|
||||
# multi-line templates are easier from a file
|
||||
qu alert add smtp ops --host ... --from ... --to ... \
|
||||
--subject-file /etc/quptime/templates/ops.subject \
|
||||
--body-file /etc/quptime/templates/ops.body
|
||||
```
|
||||
|
||||
Available template variables:
|
||||
|
||||
| Variable | Meaning |
|
||||
| ----------------------- | ------------------------------------------ |
|
||||
| `{{.Check.Name}}` | check name |
|
||||
| `{{.Check.Type}}` | `http` / `tcp` / `icmp` |
|
||||
| `{{.Check.Target}}` | URL or host:port being probed |
|
||||
| `{{.Check.ID}}` | UUID |
|
||||
| `{{.From}}` | previous state (`up` / `down` / `unknown`) |
|
||||
| `{{.To}}` | new state |
|
||||
| `{{.Verb}}` | `UP` / `DOWN` / `RECOVERED` |
|
||||
| `{{.VerbLower}}` | lowercase form (`up` / `down` / `recovered`) |
|
||||
| `{{.Snapshot.Reports}}` | total per-node reports counted |
|
||||
| `{{.Snapshot.OKCount}}` | how many reported OK |
|
||||
| `{{.Snapshot.NotOK}}` | how many reported failure |
|
||||
| `{{.Snapshot.Detail}}` | first failure detail string |
|
||||
| `{{.NodeID}}` | master that dispatched |
|
||||
| `{{.When}}` | RFC3339 timestamp |
|
||||
|
||||
The same variable list is surfaced in-app: `qu alert add smtp --help`,
|
||||
`qu alert add discord --help`, and `qu alert edit --help` each print
|
||||
it under their flag table, and `qu tui` shows a compact reminder of
|
||||
the supported variables as a hint when the cursor lands on a Subject
|
||||
or Body template field in the add/edit alert forms.
|
||||
|
||||
`qu alert test <name>` exercises the template against a synthetic
|
||||
"homepage going DOWN" transition, so you can verify rendering before
|
||||
production traffic depends on it. A template parse or execution error
|
||||
falls back to the built-in format and is logged.
|
||||
|
||||
### Conditionals, pipelines, and worked examples
|
||||
|
||||
Templates use Go's `text/template` syntax, so you have `if`/`else if`/
|
||||
`else`/`end`, comparison helpers (`eq`, `ne`, `lt`, `gt`), `printf`
|
||||
pipelines, and `with` blocks. The default rendering — the one used
|
||||
when no custom template is set — lives in `internal/alerts/message.go`
|
||||
inside the `Render` function; tweak it there if you want to change
|
||||
what every alert without an override produces.
|
||||
|
||||
A few progressively richer examples:
|
||||
|
||||
**1. State-specific Discord copy** — different tone for `DOWN`,
|
||||
`RECOVERED`, and first-time `UP`:
|
||||
|
||||
```yaml
|
||||
body_template: |
|
||||
{{if eq .Verb "DOWN"}}:rotating_light: **{{.Check.Name}}** is DOWN
|
||||
We're investigating. Last detail: `{{.Snapshot.Detail}}`
|
||||
{{else if eq .Verb "RECOVERED"}}:white_check_mark: **{{.Check.Name}}** is back UP after a {{.From}} blip.
|
||||
{{else}}:information_source: **{{.Check.Name}}** is online ({{.VerbLower}}).{{end}}
|
||||
```
|
||||
|
||||
**2. SMTP subject with severity prefix and run-length detail** —
|
||||
pipes `Verb` through `printf` for padding and only mentions the
|
||||
report count when it actually matters:
|
||||
|
||||
```yaml
|
||||
subject_template: '[{{printf "%-9s" .Verb}}] {{.Check.Name}} — {{.Check.Target}}'
|
||||
body_template: |
|
||||
Check: {{.Check.Name}} ({{.Check.Type}})
|
||||
Target: {{.Check.Target}}
|
||||
Status: {{.Verb}} (was {{.From}})
|
||||
Reporter: {{.NodeID}}
|
||||
At: {{.When}}
|
||||
{{if gt .Snapshot.Reports 1}}
|
||||
Quorum: {{.Snapshot.OKCount}} ok / {{.Snapshot.NotOK}} failing across {{.Snapshot.Reports}} reports.
|
||||
{{end}}{{with .Snapshot.Detail}}
|
||||
Detail: {{.}}
|
||||
{{end}}
|
||||
```
|
||||
|
||||
**3. PagerDuty-style severity routing** — nest `if`/`else if` so a
|
||||
single template can produce three different first lines without
|
||||
duplicating the rest of the body:
|
||||
|
||||
```yaml
|
||||
subject_template: >-
|
||||
{{if eq .Verb "DOWN"}}P1: {{.Check.Name}} hard down
|
||||
{{else if eq .Verb "RECOVERED"}}P3: {{.Check.Name}} recovered
|
||||
{{else}}P4: {{.Check.Name}} {{.VerbLower}}{{end}}
|
||||
body_template: |
|
||||
{{/* Header line — uses .VerbLower so the prose reads naturally */}}
|
||||
{{.Check.Name}} ({{.Check.Target}}) is now {{.VerbLower}}.
|
||||
|
||||
{{if eq .Verb "DOWN"-}}
|
||||
This is a real outage. Quorum: {{.Snapshot.NotOK}}/{{.Snapshot.Reports}} reporters see it failing.
|
||||
Detail from the first failing probe: {{.Snapshot.Detail}}
|
||||
Acknowledge in the runbook before paging on-call.
|
||||
{{- else if eq .Verb "RECOVERED" -}}
|
||||
Recovered after a {{.From}} period. No action needed; this is informational.
|
||||
{{- else -}}
|
||||
First successful probe after {{.From}}. Marking healthy.
|
||||
{{- end}}
|
||||
|
||||
— {{.NodeID}} at {{.When}}
|
||||
```
|
||||
|
||||
The `{{-` / `-}}` trim adjacent whitespace, which keeps the rendered
|
||||
output tidy even when the template itself is indented for readability.
|
||||
|
||||
If a template fails to parse or panics at execute time, the
|
||||
dispatcher falls back to the default `Render` output for that field
|
||||
and logs the error — your alert still ships, you just lose the
|
||||
custom formatting until you fix the template.
|
||||
|
||||
## Edit cluster.yaml directly
|
||||
|
||||
Anything you can do through the CLI you can also do by editing
|
||||
`$QUPTIME_DIR/cluster.yaml` on any node. The daemon polls the file every
|
||||
few seconds; when it sees a hash that differs from what it last wrote,
|
||||
it parses the YAML and forwards the change through the master, which
|
||||
bumps the version and broadcasts the result everywhere — so a hand-edit
|
||||
on `bravo` propagates to `alpha` and `charlie` automatically.
|
||||
|
||||
```sh
|
||||
sudoedit /etc/quptime/cluster.yaml
|
||||
# add `default: true` to an alert, or `suppress_alert_ids: [oncall]`
|
||||
# on a check, then save and quit
|
||||
```
|
||||
|
||||
You'll see a `manual-edit: cluster.yaml changed externally —
|
||||
replicating via master` line in the daemon log when it picks the change
|
||||
up. Invalid YAML is logged and ignored until you save a valid file.
|
||||
|
||||
The replicated fields are `peers`, `checks`, and `alerts`. `version`,
|
||||
`updated_at`, and `updated_by` are server-controlled — the master
|
||||
overwrites them on commit.
|
||||
|
||||
## Test an alert without waiting for a real outage
|
||||
|
||||
```sh
|
||||
@@ -189,6 +422,7 @@ sudo setcap cap_net_raw=+ep ./qu
|
||||
qu init generate identity + keys
|
||||
qu serve run the daemon
|
||||
qu status quorum, master, check states
|
||||
qu tui interactive dashboard
|
||||
qu node add <host:port> TOFU-add a peer
|
||||
qu node list show peers + liveness
|
||||
qu node remove <node-id> remove from cluster + trust
|
||||
@@ -197,9 +431,10 @@ qu check add tcp <name> <host:port>
|
||||
qu check add icmp <name> <host>
|
||||
qu check list
|
||||
qu check remove <id-or-name>
|
||||
qu alert add smtp <name> --host … --port … --from … --to … [--user --password --starttls]
|
||||
qu alert add discord <name> --webhook …
|
||||
qu alert add smtp <name> --host … --port … --from … --to … [--user --password --starttls] [--default] [--subject … --body …]
|
||||
qu alert add discord <name> --webhook … [--default] [--body …]
|
||||
qu alert list / remove / test <id-or-name>
|
||||
qu alert default <id-or-name> on|off toggle default attachment to every check
|
||||
qu trust list / remove <node-id>
|
||||
```
|
||||
|
||||
@@ -240,4 +475,5 @@ internal/checks/ HTTP/TCP/ICMP probers, scheduler, aggregator
|
||||
internal/alerts/ SMTP + Discord dispatchers, message rendering
|
||||
internal/daemon/ glue: wires every component + control socket
|
||||
internal/cli/ cobra commands, the user-facing surface
|
||||
internal/tui/ bubbletea dashboard (qu tui)
|
||||
```
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
module git.cer.sh/axodouble/quptime
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.2
|
||||
|
||||
require (
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/prometheus-community/pro-bing v0.4.1
|
||||
github.com/spf13/cobra v1.8.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/atotto/clipboard v0.1.4 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/prometheus-community/pro-bing v0.4.1 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.27.0 // indirect
|
||||
golang.org/x/sync v0.7.0 // indirect
|
||||
golang.org/x/sys v0.22.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/text v0.16.0 // indirect
|
||||
)
|
||||
|
||||
@@ -1,21 +1,75 @@
|
||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
|
||||
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/prometheus-community/pro-bing v0.4.1 h1:aMaJwyifHZO0y+h8+icUz0xbToHbia0wdmzdVZ+Kl3w=
|
||||
github.com/prometheus-community/pro-bing v0.4.1/go.mod h1:aLsw+zqCaDoa2RLVVSX3+UiCkBBXTMtZC3c7EkfWnAE=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/net v0.27.0 h1:5K3Njcw06/l2y9vpGCSdcxWOYHOUk3dVNGDXN+FvAys=
|
||||
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI=
|
||||
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
|
||||
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
|
||||
+77
-19
@@ -1,24 +1,82 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
# Check if ~/.local/bin exists, if not, create it
|
||||
if [ ! -d "$HOME/.local/bin" ]; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
fi
|
||||
INSTALL_BIN="/usr/local/bin/qu"
|
||||
SERVICE_FILE="/etc/systemd/system/qu-serve.service"
|
||||
SERVICE_USER="${SUDO_USER:-$(whoami)}"
|
||||
SERVICE_GROUP="$(id -gn "$SERVICE_USER" 2>/dev/null || echo root)"
|
||||
|
||||
# Check if ~/.local/bin is in the PATH, if not, give the user a command to add it
|
||||
if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
|
||||
echo "Please add the following line to your shell configuration file (e.g., ~/.bashrc, ~/.zshrc) to include ~/.local/bin in your PATH:"
|
||||
echo 'export PATH="$HOME/.local/bin:$PATH"'
|
||||
echo "After adding the line, please restart your terminal or run 'source ~/.bashrc' (or the appropriate command for your shell) to apply the changes."
|
||||
fi
|
||||
|
||||
# Download the binary from git.cer.sh/axodouble/quptime
|
||||
# Check whether curl or wget is available
|
||||
if command -v curl > /dev/null; then
|
||||
curl -L -o "$HOME/.local/bin/quptime" "https://git.cer.sh/axodouble/quptime/-/raw/main/quptime"
|
||||
elif command -v wget > /dev/null; then
|
||||
wget -O "$HOME/.local/bin/quptime" "https://git.cer.sh/axodouble/quptime/-/raw/main/quptime"
|
||||
else
|
||||
echo "Error: Neither curl nor wget is installed. Please install one of these tools to download the quptime binary."
|
||||
fail() {
|
||||
echo "Error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
echo_cmd() {
|
||||
echo -e "\033[90m> $1\033[0m"
|
||||
eval "$1"
|
||||
}
|
||||
|
||||
require_command() {
|
||||
command -v "$1" > /dev/null 2>&1 || fail "$1 is not installed. Please install $1 and try again."
|
||||
}
|
||||
|
||||
write_completion() {
|
||||
local shell=$1 path=$2
|
||||
[ -d "$(dirname "$path")" ] || return 1
|
||||
if "$INSTALL_BIN" completion "$shell" > "$path" 2>/dev/null; then
|
||||
echo "> installed $shell completion -> $path"
|
||||
return 0
|
||||
fi
|
||||
rm -f "$path"
|
||||
return 1
|
||||
}
|
||||
|
||||
require_command jq
|
||||
require_command curl
|
||||
|
||||
if [ ! -w "$(dirname "$INSTALL_BIN")" ]; then
|
||||
fail "You are not allowed to write to $(dirname "$INSTALL_BIN"). Run this script with sudo or install qu manually."
|
||||
fi
|
||||
|
||||
RELEASE=$(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name')
|
||||
|
||||
echo_cmd "curl -L -o '$INSTALL_BIN' 'https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64'"
|
||||
echo_cmd "chmod +x '$INSTALL_BIN'"
|
||||
echo "> qu has been installed to $INSTALL_BIN"
|
||||
|
||||
if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then
|
||||
write_completion bash /usr/share/bash-completion/completions/qu \
|
||||
|| write_completion bash /etc/bash_completion.d/qu || true
|
||||
write_completion zsh /usr/share/zsh/site-functions/_qu || true
|
||||
write_completion fish /usr/share/fish/vendor_completions.d/qu.fish || true
|
||||
else
|
||||
echo "> qu does not expose completion support; skipping shell completion installation."
|
||||
fi
|
||||
|
||||
if ! command -v systemctl > /dev/null 2>&1; then
|
||||
echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot."
|
||||
echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "> Creating systemd service file for qu serve..."
|
||||
cat > "$SERVICE_FILE" <<EOL
|
||||
[Unit]
|
||||
Description=QUptime Serve
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
ExecStart=$INSTALL_BIN serve
|
||||
Restart=always
|
||||
User=$SERVICE_USER
|
||||
Group=$SERVICE_GROUP
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
EOL
|
||||
|
||||
echo_cmd "systemctl daemon-reload"
|
||||
echo_cmd "systemctl enable $(basename "$SERVICE_FILE")"
|
||||
echo "> qu serve service has been created and enabled. You can start it with 'systemctl start $(basename "$SERVICE_FILE")'"
|
||||
|
||||
echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation."
|
||||
|
||||
@@ -30,13 +30,16 @@ func (d *Dispatcher) OnTransition(check *config.Check, from, to checks.State, sn
|
||||
if to == checks.StateUnknown {
|
||||
return
|
||||
}
|
||||
msg := Render(d.selfID, check, from, to, snap)
|
||||
alerts := d.cluster.EffectiveAlertsFor(check)
|
||||
if len(alerts) == 0 && len(check.AlertIDs) > 0 {
|
||||
d.logger.Printf("alerts: check %q references alerts but none resolved", check.Name)
|
||||
}
|
||||
for i := range alerts {
|
||||
alert := alerts[i]
|
||||
msg, err := RenderFor(&alert, d.selfID, check, from, to, snap)
|
||||
if err != nil {
|
||||
d.logger.Printf("alerts: %q template: %v — falling back to default", alert.Name, err)
|
||||
}
|
||||
if err := d.dispatchOne(&alert, msg); err != nil {
|
||||
d.logger.Printf("alerts: %q via %s: %v", alert.Name, alert.Type, err)
|
||||
}
|
||||
@@ -44,18 +47,35 @@ func (d *Dispatcher) OnTransition(check *config.Check, from, to checks.State, sn
|
||||
}
|
||||
|
||||
// Test sends a one-shot test message to the named alert. Returns an
|
||||
// error so the CLI can surface failures interactively.
|
||||
// error so the CLI can surface failures interactively. If the alert
|
||||
// carries custom templates they are exercised against a synthetic
|
||||
// "homepage going DOWN" transition so the operator can confirm the
|
||||
// template renders before a real outage.
|
||||
func (d *Dispatcher) Test(alertID string) error {
|
||||
alert := d.cluster.FindAlert(alertID)
|
||||
if alert == nil {
|
||||
return fmt.Errorf("alert %q not found", alertID)
|
||||
}
|
||||
if alert.SubjectTemplate == "" && alert.BodyTemplate == "" {
|
||||
msg := Message{
|
||||
Subject: "[quptime] test alert",
|
||||
Body: fmt.Sprintf("This is a test of alert %q from node %s.\nIf you see this, the alert channel is wired correctly.\n", alert.Name, d.selfID),
|
||||
}
|
||||
return d.dispatchOne(alert, msg)
|
||||
}
|
||||
sample := &config.Check{
|
||||
ID: "test-check",
|
||||
Name: "test-check",
|
||||
Type: config.CheckHTTP,
|
||||
Target: "https://example.com",
|
||||
}
|
||||
snap := checks.Snapshot{Reports: 3, OKCount: 0, NotOK: 3, Detail: "synthetic test failure"}
|
||||
msg, err := RenderFor(alert, d.selfID, sample, checks.StateUp, checks.StateDown, snap)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render template: %w", err)
|
||||
}
|
||||
return d.dispatchOne(alert, msg)
|
||||
}
|
||||
|
||||
func (d *Dispatcher) dispatchOne(a *config.Alert, msg Message) error {
|
||||
switch a.Type {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package alerts
|
||||
|
||||
// TemplateVarsHint returns a compact, multi-line listing of the
|
||||
// variables a subject/body template can reference. Designed for
|
||||
// embedding in TUI form hints where vertical space is tight.
|
||||
//
|
||||
// Continuation lines are pre-indented so they line up under the
|
||||
// first line when the caller prepends a fixed indent (e.g. " ").
|
||||
func TemplateVarsHint() string {
|
||||
return "Go text/template — leave empty to use the built-in format.\n" +
|
||||
" Vars: {{.Check.Name}}, {{.Check.Target}}, {{.Check.Type}}, {{.Check.ID}},\n" +
|
||||
" {{.Verb}} (UP|DOWN|RECOVERED), {{.VerbLower}}, {{.From}}, {{.To}}, {{.NodeID}}, {{.When}},\n" +
|
||||
" {{.Snapshot.Detail}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}}, {{.Snapshot.NotOK}}"
|
||||
}
|
||||
|
||||
// TemplateVarsHelp returns the long-form documentation for available
|
||||
// template variables, suitable for embedding in a CLI command's Long
|
||||
// help text. Each variable is described on its own line and an
|
||||
// example template is included at the end.
|
||||
func TemplateVarsHelp() string {
|
||||
return `Subject and body templates use Go text/template syntax. They are
|
||||
optional — leaving them empty falls back to the built-in format.
|
||||
Discord ignores the subject template (it has no subject line); SMTP
|
||||
uses both.
|
||||
|
||||
Available variables:
|
||||
{{.Check.Name}} check name (e.g. "homepage")
|
||||
{{.Check.Target}} URL / host:port / host being probed
|
||||
{{.Check.Type}} http | tcp | icmp
|
||||
{{.Check.ID}} stable check UUID
|
||||
{{.Verb}} UP | DOWN | RECOVERED
|
||||
{{.VerbLower}} lowercase form of Verb (up | down | recovered)
|
||||
{{.From}} previous state name
|
||||
{{.To}} new state name
|
||||
{{.NodeID}} master node that rendered the message
|
||||
{{.When}} RFC3339 timestamp of the transition
|
||||
{{.Snapshot.Detail}} probe detail string (e.g. "connection refused")
|
||||
{{.Snapshot.Reports}} total reports in the flip window
|
||||
{{.Snapshot.OKCount}} ok report count
|
||||
{{.Snapshot.NotOK}} failing report count
|
||||
|
||||
Example body template:
|
||||
{{.Check.Name}} is {{.Verb}} (target {{.Check.Target}}).
|
||||
Detail: {{.Snapshot.Detail}}`
|
||||
}
|
||||
@@ -4,14 +4,30 @@
|
||||
package alerts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/checks"
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
)
|
||||
|
||||
// TemplateContext is what user-provided subject/body templates see. It
|
||||
// is also the shape the default renderer fills in, so changing one
|
||||
// place keeps the two paths consistent.
|
||||
type TemplateContext struct {
|
||||
Check *config.Check
|
||||
From string // previous state name
|
||||
To string // new state name
|
||||
Verb string // "UP" | "DOWN" | "RECOVERED"
|
||||
VerbLower string // lowercase form of Verb ("up" | "down" | "recovered")
|
||||
Snapshot checks.Snapshot // aggregate counts and detail
|
||||
NodeID string // master that rendered the message
|
||||
When string // RFC3339 timestamp
|
||||
}
|
||||
|
||||
// Message is the rendered notification ready to ship across any
|
||||
// channel. Channels may format Subject + Body differently (SMTP uses
|
||||
// both; Discord renders a single string).
|
||||
@@ -20,25 +36,84 @@ type Message struct {
|
||||
Body string
|
||||
}
|
||||
|
||||
// Render produces a human-readable message from one state transition.
|
||||
// Render produces a human-readable message from one state transition
|
||||
// using the built-in format. Used as the fallback when no custom
|
||||
// template is configured (or when a custom template fails to render).
|
||||
func Render(nodeID string, check *config.Check, from, to checks.State, snap checks.Snapshot) Message {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
verb := transitionVerb(from, to)
|
||||
subject := fmt.Sprintf("[quptime] %s %s — %s", check.Name, verb, check.Target)
|
||||
ctx := newContext(nodeID, check, from, to, snap)
|
||||
subject := fmt.Sprintf("[quptime] %s %s — %s", check.Name, ctx.Verb, check.Target)
|
||||
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "Check %q is now %s.\n", check.Name, strings.ToUpper(string(to)))
|
||||
fmt.Fprintf(&b, "Previous state: %s\n", from)
|
||||
fmt.Fprintf(&b, "Check %q is now %s.\n", check.Name, strings.ToUpper(ctx.To))
|
||||
fmt.Fprintf(&b, "Previous state: %s\n", ctx.From)
|
||||
fmt.Fprintf(&b, "Target: %s (%s)\n", check.Target, check.Type)
|
||||
fmt.Fprintf(&b, "Reports: %d (ok=%d, fail=%d)\n", snap.Reports, snap.OKCount, snap.NotOK)
|
||||
if snap.Detail != "" {
|
||||
fmt.Fprintf(&b, "Detail: %s\n", snap.Detail)
|
||||
}
|
||||
fmt.Fprintf(&b, "Master: %s\n", nodeID)
|
||||
fmt.Fprintf(&b, "When: %s\n", now)
|
||||
fmt.Fprintf(&b, "When: %s\n", ctx.When)
|
||||
return Message{Subject: subject, Body: b.String()}
|
||||
}
|
||||
|
||||
// RenderFor produces a message for one specific alert. If the alert
|
||||
// defines SubjectTemplate or BodyTemplate, those override the
|
||||
// corresponding field from the default render. A template error falls
|
||||
// back to the default for that field and is reported via the returned
|
||||
// error (the caller is expected to log but still ship the message).
|
||||
func RenderFor(alert *config.Alert, nodeID string, check *config.Check, from, to checks.State, snap checks.Snapshot) (Message, error) {
|
||||
def := Render(nodeID, check, from, to, snap)
|
||||
if alert == nil || (alert.SubjectTemplate == "" && alert.BodyTemplate == "") {
|
||||
return def, nil
|
||||
}
|
||||
ctx := newContext(nodeID, check, from, to, snap)
|
||||
msg := def
|
||||
var firstErr error
|
||||
if alert.SubjectTemplate != "" {
|
||||
s, err := execTemplate("subject", alert.SubjectTemplate, ctx)
|
||||
if err != nil {
|
||||
firstErr = err
|
||||
} else {
|
||||
msg.Subject = s
|
||||
}
|
||||
}
|
||||
if alert.BodyTemplate != "" {
|
||||
s, err := execTemplate("body", alert.BodyTemplate, ctx)
|
||||
if err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
} else if err == nil {
|
||||
msg.Body = s
|
||||
}
|
||||
}
|
||||
return msg, firstErr
|
||||
}
|
||||
|
||||
func newContext(nodeID string, check *config.Check, from, to checks.State, snap checks.Snapshot) TemplateContext {
|
||||
verb := transitionVerb(from, to)
|
||||
return TemplateContext{
|
||||
Check: check,
|
||||
From: string(from),
|
||||
To: string(to),
|
||||
Verb: verb,
|
||||
VerbLower: strings.ToLower(verb),
|
||||
Snapshot: snap,
|
||||
NodeID: nodeID,
|
||||
When: time.Now().UTC().Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
|
||||
func execTemplate(name, src string, ctx TemplateContext) (string, error) {
|
||||
tmpl, err := template.New(name).Option("missingkey=zero").Parse(src)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("parse %s template: %w", name, err)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := tmpl.Execute(&b, ctx); err != nil {
|
||||
return "", fmt.Errorf("execute %s template: %w", name, err)
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
|
||||
func transitionVerb(from, to checks.State) string {
|
||||
switch to {
|
||||
case checks.StateDown:
|
||||
|
||||
@@ -50,3 +50,61 @@ func TestRenderUpInitialTransition(t *testing.T) {
|
||||
t.Error("first-time UP should not be tagged RECOVERED")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderForUsesAlertTemplates(t *testing.T) {
|
||||
check := &config.Check{Name: "homepage", Target: "https://example.com", Type: config.CheckHTTP}
|
||||
snap := checks.Snapshot{Reports: 3, OKCount: 0, NotOK: 3, Detail: "connection refused"}
|
||||
alert := &config.Alert{
|
||||
SubjectTemplate: "{{.Check.Name}} is {{.Verb}}",
|
||||
BodyTemplate: "{{.Check.Target}} :: {{.Snapshot.Detail}}",
|
||||
}
|
||||
msg, err := RenderFor(alert, "master", check, checks.StateUp, checks.StateDown, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if msg.Subject != "homepage is DOWN" {
|
||||
t.Errorf("subject = %q", msg.Subject)
|
||||
}
|
||||
if msg.Body != "https://example.com :: connection refused" {
|
||||
t.Errorf("body = %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderForFallsBackToDefaultPerField(t *testing.T) {
|
||||
check := &config.Check{Name: "homepage", Target: "https://example.com", Type: config.CheckHTTP}
|
||||
snap := checks.Snapshot{Reports: 3, OKCount: 0, NotOK: 3}
|
||||
// only body overridden; subject should match default.
|
||||
alert := &config.Alert{BodyTemplate: "custom body"}
|
||||
msg, err := RenderFor(alert, "master", check, checks.StateUp, checks.StateDown, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg.Subject, "DOWN") {
|
||||
t.Errorf("subject should be default rendering, got %q", msg.Subject)
|
||||
}
|
||||
if msg.Body != "custom body" {
|
||||
t.Errorf("body = %q", msg.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderForReportsTemplateError(t *testing.T) {
|
||||
check := &config.Check{Name: "homepage", Target: "https://example.com"}
|
||||
snap := checks.Snapshot{}
|
||||
alert := &config.Alert{BodyTemplate: "{{.Check.MissingField"} // unbalanced
|
||||
_, err := RenderFor(alert, "master", check, checks.StateUp, checks.StateDown, snap)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error for malformed template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderForNilAlertReturnsDefault(t *testing.T) {
|
||||
check := &config.Check{Name: "homepage", Target: "https://example.com"}
|
||||
snap := checks.Snapshot{Reports: 1, OKCount: 1}
|
||||
msg, err := RenderFor(nil, "master", check, checks.StateUp, checks.StateUp, snap)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(msg.Subject, "homepage") {
|
||||
t.Errorf("default subject should mention check, got %q", msg.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
+200
-5
@@ -4,17 +4,54 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/alerts"
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
// bindTemplateFlags attaches --subject / --subject-file / --body /
|
||||
// --body-file to a cobra command. resolveTemplateFlags reads the file
|
||||
// variants (if non-empty) and returns the effective subject + body
|
||||
// template strings. Inline flags take precedence over file flags.
|
||||
func bindTemplateFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().String("subject", "", "subject template, Go text/template (SMTP only; see --help for variables)")
|
||||
cmd.Flags().String("subject-file", "", "path to a file containing the subject template")
|
||||
cmd.Flags().String("body", "", "body template, Go text/template (see --help for variables)")
|
||||
cmd.Flags().String("body-file", "", "path to a file containing the body template")
|
||||
}
|
||||
|
||||
func resolveTemplateFlags(cmd *cobra.Command) (subject, body string, err error) {
|
||||
subject, _ = cmd.Flags().GetString("subject")
|
||||
body, _ = cmd.Flags().GetString("body")
|
||||
if subject == "" {
|
||||
if p, _ := cmd.Flags().GetString("subject-file"); p != "" {
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return "", "", fmt.Errorf("read --subject-file %s: %w", p, e)
|
||||
}
|
||||
subject = string(raw)
|
||||
}
|
||||
}
|
||||
if body == "" {
|
||||
if p, _ := cmd.Flags().GetString("body-file"); p != "" {
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return "", "", fmt.Errorf("read --body-file %s: %w", p, e)
|
||||
}
|
||||
body = string(raw)
|
||||
}
|
||||
}
|
||||
return subject, body, nil
|
||||
}
|
||||
|
||||
func addAlertCmd(root *cobra.Command) {
|
||||
alert := &cobra.Command{
|
||||
Use: "alert",
|
||||
@@ -119,10 +156,152 @@ func addAlertCmd(root *cobra.Command) {
|
||||
},
|
||||
}
|
||||
|
||||
alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd)
|
||||
alert.AddCommand(addParent, listCmd, removeCmd, testCmd, defaultCmd, buildAlertEditCmd())
|
||||
root.AddCommand(alert)
|
||||
}
|
||||
|
||||
// buildAlertEditCmd returns `qu alert edit`, which updates fields of an
|
||||
// existing alert. Only flags actually passed take effect. The alert's
|
||||
// type cannot be changed (would require re-validating type-specific
|
||||
// fields end-to-end); delete and re-add instead if you need to switch
|
||||
// from SMTP to Discord or vice versa.
|
||||
func buildAlertEditCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit <id-or-name>",
|
||||
Short: "Update fields of an existing alert channel",
|
||||
Long: `Update one or more fields of an existing alert. Only flags you pass
|
||||
take effect; everything else is preserved.
|
||||
|
||||
The type (smtp/discord) cannot be changed in place — delete and re-add
|
||||
the alert if you need to switch channels.
|
||||
|
||||
` + alerts.TemplateVarsHelp(),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := config.LoadClusterConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existing := cluster.FindAlert(args[0])
|
||||
if existing == nil {
|
||||
return fmt.Errorf("no alert named %q", args[0])
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
if f.Changed("name") {
|
||||
v, _ := f.GetString("name")
|
||||
existing.Name = v
|
||||
}
|
||||
if f.Changed("default") {
|
||||
v, _ := f.GetBool("default")
|
||||
existing.Default = v
|
||||
}
|
||||
// Templates: inline flag wins over file flag. Either changing
|
||||
// applies; passing an empty inline string clears the template.
|
||||
if f.Changed("subject") {
|
||||
v, _ := f.GetString("subject")
|
||||
existing.SubjectTemplate = v
|
||||
} else if f.Changed("subject-file") {
|
||||
p, _ := f.GetString("subject-file")
|
||||
if p != "" {
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return fmt.Errorf("read --subject-file %s: %w", p, e)
|
||||
}
|
||||
existing.SubjectTemplate = string(raw)
|
||||
}
|
||||
}
|
||||
if f.Changed("body") {
|
||||
v, _ := f.GetString("body")
|
||||
existing.BodyTemplate = v
|
||||
} else if f.Changed("body-file") {
|
||||
p, _ := f.GetString("body-file")
|
||||
if p != "" {
|
||||
raw, e := os.ReadFile(p)
|
||||
if e != nil {
|
||||
return fmt.Errorf("read --body-file %s: %w", p, e)
|
||||
}
|
||||
existing.BodyTemplate = string(raw)
|
||||
}
|
||||
}
|
||||
|
||||
switch existing.Type {
|
||||
case config.AlertSMTP:
|
||||
if f.Changed("webhook") {
|
||||
return fmt.Errorf("--webhook only applies to Discord alerts")
|
||||
}
|
||||
if f.Changed("host") {
|
||||
v, _ := f.GetString("host")
|
||||
existing.SMTPHost = v
|
||||
}
|
||||
if f.Changed("port") {
|
||||
v, _ := f.GetInt("port")
|
||||
existing.SMTPPort = v
|
||||
}
|
||||
if f.Changed("user") {
|
||||
v, _ := f.GetString("user")
|
||||
existing.SMTPUser = v
|
||||
}
|
||||
if f.Changed("password") {
|
||||
v, _ := f.GetString("password")
|
||||
existing.SMTPPassword = v
|
||||
}
|
||||
if f.Changed("from") {
|
||||
v, _ := f.GetString("from")
|
||||
existing.SMTPFrom = v
|
||||
}
|
||||
if f.Changed("to") {
|
||||
v, _ := f.GetStringSlice("to")
|
||||
existing.SMTPTo = v
|
||||
}
|
||||
if f.Changed("starttls") {
|
||||
v, _ := f.GetBool("starttls")
|
||||
existing.SMTPStartTLS = v
|
||||
}
|
||||
case config.AlertDiscord:
|
||||
for _, smtpFlag := range []string{"host", "port", "user", "password", "from", "to", "starttls"} {
|
||||
if f.Changed(smtpFlag) {
|
||||
return fmt.Errorf("--%s only applies to SMTP alerts", smtpFlag)
|
||||
}
|
||||
}
|
||||
if f.Changed("webhook") {
|
||||
v, _ := f.GetString("webhook")
|
||||
existing.DiscordWebhook = v
|
||||
}
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res daemon.MutateResult
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "updated alert %s (cluster version now %d)\n", existing.Name, res.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("name", "", "rename the alert")
|
||||
cmd.Flags().Bool("default", false, "attach to every check automatically")
|
||||
cmd.Flags().String("host", "", "SMTP server host (SMTP only)")
|
||||
cmd.Flags().Int("port", 587, "SMTP server port (SMTP only)")
|
||||
cmd.Flags().String("user", "", "SMTP auth user (SMTP only)")
|
||||
cmd.Flags().String("password", "", "SMTP auth password (SMTP only)")
|
||||
cmd.Flags().String("from", "", "envelope From address (SMTP only)")
|
||||
cmd.Flags().StringSlice("to", nil, "recipient address, repeatable (SMTP only)")
|
||||
cmd.Flags().Bool("starttls", true, "negotiate STARTTLS (SMTP only)")
|
||||
cmd.Flags().String("webhook", "", "Discord webhook URL (Discord only)")
|
||||
bindTemplateFlags(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func buildSMTPAddCmd() *cobra.Command {
|
||||
var host, user, password, from string
|
||||
var port int
|
||||
@@ -132,15 +311,22 @@ func buildSMTPAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "smtp <name>",
|
||||
Short: "Add an SMTP relay alert",
|
||||
Long: "Add an SMTP relay alert.\n\n" + alerts.TemplateVarsHelp(),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
subj, body, err := resolveTemplateFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: args[0],
|
||||
Type: config.AlertSMTP,
|
||||
Default: makeDefault,
|
||||
SubjectTemplate: subj,
|
||||
BodyTemplate: body,
|
||||
SMTPHost: host,
|
||||
SMTPPort: port,
|
||||
SMTPUser: user,
|
||||
@@ -150,8 +336,8 @@ func buildSMTPAddCmd() *cobra.Command {
|
||||
SMTPStartTLS: startTLS,
|
||||
}
|
||||
payload, _ := json.Marshal(a)
|
||||
body := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
mb := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, mb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -170,6 +356,7 @@ func buildSMTPAddCmd() *cobra.Command {
|
||||
cmd.Flags().StringSliceVar(&to, "to", nil, "recipient address (repeat or comma-separate)")
|
||||
cmd.Flags().BoolVar(&startTLS, "starttls", true, "negotiate STARTTLS")
|
||||
cmd.Flags().BoolVar(&makeDefault, "default", false, "attach this alert to every check automatically")
|
||||
bindTemplateFlags(cmd)
|
||||
_ = cmd.MarkFlagRequired("host")
|
||||
_ = cmd.MarkFlagRequired("from")
|
||||
_ = cmd.MarkFlagRequired("to")
|
||||
@@ -182,20 +369,27 @@ func buildDiscordAddCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "discord <name>",
|
||||
Short: "Add a Discord webhook alert",
|
||||
Long: "Add a Discord webhook alert.\n\n" + alerts.TemplateVarsHelp(),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
subj, body, err := resolveTemplateFlags(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: args[0],
|
||||
Type: config.AlertDiscord,
|
||||
Default: makeDefault,
|
||||
SubjectTemplate: subj,
|
||||
BodyTemplate: body,
|
||||
DiscordWebhook: webhook,
|
||||
}
|
||||
payload, _ := json.Marshal(a)
|
||||
body := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
mb := daemon.MutateBody{Kind: transport.MutationAddAlert, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, mb)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -208,6 +402,7 @@ func buildDiscordAddCmd() *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVar(&webhook, "webhook", "", "discord webhook URL")
|
||||
cmd.Flags().BoolVar(&makeDefault, "default", false, "attach this alert to every check automatically")
|
||||
bindTemplateFlags(cmd)
|
||||
_ = cmd.MarkFlagRequired("webhook")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+111
-1
@@ -84,10 +84,120 @@ func addCheckCmd(root *cobra.Command) {
|
||||
},
|
||||
}
|
||||
|
||||
check.AddCommand(addParent, listCmd, removeCmd)
|
||||
check.AddCommand(addParent, listCmd, removeCmd, buildCheckEditCmd())
|
||||
root.AddCommand(check)
|
||||
}
|
||||
|
||||
// buildCheckEditCmd returns `qu check edit`, which updates fields of an
|
||||
// existing check in place. Only flags that the operator actually passes
|
||||
// modify the corresponding field — everything else is preserved from the
|
||||
// existing record, including the ID. Identity match is by ID or Name.
|
||||
func buildCheckEditCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit <id-or-name>",
|
||||
Short: "Update fields of an existing check",
|
||||
Long: `Update one or more fields of an existing check.
|
||||
|
||||
Identifies the target by ID or Name. Only flags you pass take effect;
|
||||
all other fields are preserved from the existing record. HTTP-only flags
|
||||
(--expect, --body-match) error out on non-HTTP checks.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
cluster, err := config.LoadClusterConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap := cluster.Snapshot()
|
||||
var existing *config.Check
|
||||
for i := range snap.Checks {
|
||||
if snap.Checks[i].ID == args[0] || snap.Checks[i].Name == args[0] {
|
||||
cp := snap.Checks[i]
|
||||
existing = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
return fmt.Errorf("no check named %q", args[0])
|
||||
}
|
||||
|
||||
f := cmd.Flags()
|
||||
if f.Changed("name") {
|
||||
v, _ := f.GetString("name")
|
||||
existing.Name = strings.TrimSpace(v)
|
||||
}
|
||||
if f.Changed("target") {
|
||||
v, _ := f.GetString("target")
|
||||
existing.Target = strings.TrimSpace(v)
|
||||
}
|
||||
if f.Changed("interval") {
|
||||
s, _ := f.GetString("interval")
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--interval: %w", err)
|
||||
}
|
||||
existing.Interval = d
|
||||
}
|
||||
if f.Changed("timeout") {
|
||||
s, _ := f.GetString("timeout")
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--timeout: %w", err)
|
||||
}
|
||||
existing.Timeout = d
|
||||
}
|
||||
if f.Changed("alerts") {
|
||||
csv, _ := f.GetString("alerts")
|
||||
existing.AlertIDs = nil
|
||||
for _, p := range strings.Split(csv, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
existing.AlertIDs = append(existing.AlertIDs, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.Changed("expect") {
|
||||
if existing.Type != config.CheckHTTP {
|
||||
return fmt.Errorf("--expect only applies to HTTP checks (this is %s)", existing.Type)
|
||||
}
|
||||
v, _ := f.GetInt("expect")
|
||||
existing.ExpectStatus = v
|
||||
}
|
||||
if f.Changed("body-match") {
|
||||
if existing.Type != config.CheckHTTP {
|
||||
return fmt.Errorf("--body-match only applies to HTTP checks (this is %s)", existing.Type)
|
||||
}
|
||||
v, _ := f.GetString("body-match")
|
||||
existing.BodyMatch = v
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: transport.MutationAddCheck, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res daemon.MutateResult
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "updated check %s (cluster version now %d)\n", existing.Name, res.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("name", "", "rename the check")
|
||||
cmd.Flags().String("target", "", "new probe target (URL, host:port, or host)")
|
||||
cmd.Flags().String("interval", "", "new probe interval (e.g. 30s, 1m)")
|
||||
cmd.Flags().String("timeout", "", "new per-probe timeout (e.g. 10s)")
|
||||
cmd.Flags().String("alerts", "", "replace alert list with this CSV of IDs/names (pass empty to clear)")
|
||||
cmd.Flags().Int("expect", 0, "expected HTTP status code (HTTP only)")
|
||||
cmd.Flags().String("body-match", "", "substring required in body (HTTP only)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// buildAddCheckCmd produces the per-type "qu check add <type>" subcommand.
|
||||
func buildAddCheckCmd(ctype config.CheckType, use, argSpec, short string,
|
||||
bind func(args []string, c *config.Check) error,
|
||||
|
||||
@@ -25,5 +25,6 @@ func NewRootCommand(version string) *cobra.Command {
|
||||
addAlertCmd(root)
|
||||
addTrustCmd(root)
|
||||
addStatusCmd(root)
|
||||
addTUICmd(root)
|
||||
return root
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
func addNodeCmd(root *cobra.Command) {
|
||||
@@ -66,9 +68,76 @@ func addNodeCmd(root *cobra.Command) {
|
||||
}
|
||||
node.AddCommand(remove)
|
||||
|
||||
node.AddCommand(buildNodeEditCmd())
|
||||
|
||||
root.AddCommand(node)
|
||||
}
|
||||
|
||||
// buildNodeEditCmd returns `qu node edit`, which currently only updates
|
||||
// the peer's advertise address. The NodeID, fingerprint, and certificate
|
||||
// are part of the cluster's trust relationship and cannot be edited —
|
||||
// remove and re-add the node (with the new cert) if those need to change.
|
||||
func buildNodeEditCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "edit <node-id>",
|
||||
Short: "Update the advertise address (host:port) of an existing peer",
|
||||
Long: `Update fields of an existing peer.
|
||||
|
||||
Only the advertise address is editable — the NodeID, fingerprint, and
|
||||
certificate are bound by trust and cannot be changed in place. To change
|
||||
those, remove the node and add it again (which re-performs TOFU).`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if !cmd.Flags().Changed("address") {
|
||||
return fmt.Errorf("--address is required")
|
||||
}
|
||||
newAddr, _ := cmd.Flags().GetString("address")
|
||||
newAddr = strings.TrimSpace(newAddr)
|
||||
if newAddr == "" {
|
||||
return fmt.Errorf("--address cannot be empty")
|
||||
}
|
||||
|
||||
cluster, err := config.LoadClusterConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
snap := cluster.Snapshot()
|
||||
var existing *config.PeerInfo
|
||||
for i := range snap.Peers {
|
||||
if snap.Peers[i].NodeID == args[0] {
|
||||
cp := snap.Peers[i]
|
||||
existing = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
return fmt.Errorf("no peer with node id %q", args[0])
|
||||
}
|
||||
existing.Advertise = newAddr
|
||||
|
||||
payload, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: transport.MutationAddPeer, Payload: payload}
|
||||
raw, err := callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var res daemon.MutateResult
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "updated peer %s -> %s (cluster version now %d)\n",
|
||||
existing.NodeID, existing.Advertise, res.Version)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("address", "", "new host:port advertise address")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runNodeAdd does a two-step TOFU: probe peer, confirm fingerprint
|
||||
// interactively, then issue the actual add.
|
||||
func runNodeAdd(ctx context.Context, cmd *cobra.Command, addr string) error {
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/tui"
|
||||
)
|
||||
|
||||
func addTUICmd(root *cobra.Command) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "tui",
|
||||
Short: "Open the interactive terminal UI",
|
||||
Long: "Open a full-screen TUI that overlays the same commands the CLI offers.\n" +
|
||||
"The TUI is a thin client over the local daemon socket — start the daemon\n" +
|
||||
"with `qu serve` before running this.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return tui.Run()
|
||||
},
|
||||
}
|
||||
root.AddCommand(cmd)
|
||||
}
|
||||
@@ -85,6 +85,16 @@ type Alert struct {
|
||||
|
||||
// Discord options.
|
||||
DiscordWebhook string `yaml:"discord_webhook,omitempty"`
|
||||
|
||||
// SubjectTemplate / BodyTemplate are optional text/template strings
|
||||
// that override the default rendering. Empty means use the built-in
|
||||
// format. Discord ignores SubjectTemplate (it has no subject line);
|
||||
// SMTP uses both. Available variables: {{.Check.Name}},
|
||||
// {{.Check.Type}}, {{.Check.Target}}, {{.Check.ID}}, {{.From}},
|
||||
// {{.To}}, {{.Verb}}, {{.VerbLower}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}},
|
||||
// {{.Snapshot.NotOK}}, {{.Snapshot.Detail}}, {{.NodeID}}, {{.When}}.
|
||||
SubjectTemplate string `yaml:"subject_template,omitempty"`
|
||||
BodyTemplate string `yaml:"body_template,omitempty"`
|
||||
}
|
||||
|
||||
// ClusterConfig is the replicated cluster state. The Version field
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
)
|
||||
|
||||
// callDaemon is the same protocol the cli package uses against the
|
||||
// local control socket — duplicated here so the TUI doesn't have to
|
||||
// import the cli package (which would cycle).
|
||||
func callDaemon(ctx context.Context, method string, body any) (json.RawMessage, error) {
|
||||
var rawBody json.RawMessage
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawBody = b
|
||||
}
|
||||
req := daemon.CtrlRequest{Method: method, Body: rawBody}
|
||||
reqBytes, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sock := config.SocketPath()
|
||||
d := net.Dialer{}
|
||||
conn, err := d.DialContext(ctx, "unix", sock)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("dial daemon socket %s: %w", sock, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
if dl, ok := ctx.Deadline(); ok {
|
||||
_ = conn.SetDeadline(dl)
|
||||
} else {
|
||||
_ = conn.SetDeadline(time.Now().Add(30 * time.Second))
|
||||
}
|
||||
|
||||
if err := writeFrame(conn, reqBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
respBytes, err := readFrame(conn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var resp daemon.CtrlResponse
|
||||
if err := json.Unmarshal(respBytes, &resp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.Error != "" {
|
||||
return nil, errors.New(resp.Error)
|
||||
}
|
||||
return resp.Body, nil
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, body []byte) error {
|
||||
var hdr [4]byte
|
||||
binary.BigEndian.PutUint32(hdr[:], uint32(len(body)))
|
||||
if _, err := w.Write(hdr[:]); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
func readFrame(r io.Reader) ([]byte, error) {
|
||||
var hdr [4]byte
|
||||
if _, err := io.ReadFull(r, hdr[:]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n := binary.BigEndian.Uint32(hdr[:])
|
||||
buf := make([]byte, n)
|
||||
if _, err := io.ReadFull(r, buf); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
@@ -0,0 +1,815 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/alerts"
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
// modalDone tells the parent the modal is finished. Flash, when set,
|
||||
// is shown as a one-shot status line; level controls the color.
|
||||
type modalDone struct {
|
||||
flash string
|
||||
level flashLevel
|
||||
}
|
||||
|
||||
type flashLevel int
|
||||
|
||||
const (
|
||||
flashInfo flashLevel = iota
|
||||
flashWarn
|
||||
flashError
|
||||
)
|
||||
|
||||
// modal is implemented by every pop-up form/dialog. Parent passes all
|
||||
// input to the modal's Update; when the modal completes it returns
|
||||
// modalDone as a tea.Msg via its tea.Cmd.
|
||||
type modal interface {
|
||||
Update(tea.Msg) (modal, tea.Cmd)
|
||||
View() string
|
||||
Title() string
|
||||
}
|
||||
|
||||
func modalDoneCmd(flash string, level flashLevel) tea.Cmd {
|
||||
return func() tea.Msg { return modalDone{flash: flash, level: level} }
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Generic field-based form (used by check/alert/node add flows).
|
||||
// =============================================================
|
||||
|
||||
type formField struct {
|
||||
label string
|
||||
input textinput.Model
|
||||
required bool
|
||||
hint string
|
||||
}
|
||||
|
||||
type form struct {
|
||||
title string
|
||||
fields []formField
|
||||
cursor int
|
||||
busy bool
|
||||
err string
|
||||
width int // current terminal width; inputs resize to fill it
|
||||
|
||||
submit func(values []string) tea.Cmd
|
||||
}
|
||||
|
||||
// defaultFieldWidth is the fallback input width used before the first
|
||||
// WindowSizeMsg has arrived. Once we know the terminal size, inputs
|
||||
// grow to fill the available horizontal space.
|
||||
const defaultFieldWidth = 40
|
||||
|
||||
// fieldWidthFor derives the per-input visible width from the terminal
|
||||
// width. It subtracts the modal's border+padding (6) and the form's
|
||||
// label indent (2), then a couple of chars of safety margin.
|
||||
func fieldWidthFor(termWidth int) int {
|
||||
w := termWidth - 12
|
||||
if w < defaultFieldWidth {
|
||||
return defaultFieldWidth
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form {
|
||||
for i := range fields {
|
||||
fields[i].input.Prompt = ""
|
||||
fields[i].input.CharLimit = 256
|
||||
if i == 0 {
|
||||
fields[i].input.Focus()
|
||||
} else {
|
||||
fields[i].input.Blur()
|
||||
}
|
||||
}
|
||||
return &form{title: title, fields: fields, submit: submit}
|
||||
}
|
||||
|
||||
func textField(label, hint string, required bool) formField {
|
||||
return textFieldWithValue(label, hint, "", required)
|
||||
}
|
||||
|
||||
// textFieldWithValue is the same as textField but pre-populates the
|
||||
// input with `value`. Used by edit forms so the user sees the current
|
||||
// contents and can tweak instead of retyping everything.
|
||||
func textFieldWithValue(label, hint, value string, required bool) formField {
|
||||
ti := textinput.New()
|
||||
ti.Width = defaultFieldWidth
|
||||
ti.Placeholder = hint
|
||||
if value != "" {
|
||||
ti.SetValue(value)
|
||||
}
|
||||
return formField{label: label, hint: hint, required: required, input: ti}
|
||||
}
|
||||
|
||||
func passwordField(label, hint string) formField {
|
||||
return passwordFieldWithValue(label, hint, "")
|
||||
}
|
||||
|
||||
// passwordFieldWithValue pre-populates the masked input. Mostly useful
|
||||
// for edit forms — the user sees that *something* is set (dots) without
|
||||
// the actual value leaking on-screen.
|
||||
func passwordFieldWithValue(label, hint, value string) formField {
|
||||
ti := textinput.New()
|
||||
ti.Width = defaultFieldWidth
|
||||
ti.Placeholder = hint
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
if value != "" {
|
||||
ti.SetValue(value)
|
||||
}
|
||||
return formField{label: label, hint: hint, input: ti}
|
||||
}
|
||||
|
||||
func (f *form) Title() string { return f.title }
|
||||
|
||||
func (f *form) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(f.title))
|
||||
for i, fld := range f.fields {
|
||||
marker := " "
|
||||
labelStyle := subtleStyle
|
||||
if i == f.cursor {
|
||||
marker = "▸ "
|
||||
labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label))
|
||||
fmt.Fprintf(&b, " %s\n", fld.input.View())
|
||||
if i == f.cursor && fld.hint != "" {
|
||||
fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint))
|
||||
}
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
if f.err != "" {
|
||||
fmt.Fprintf(&b, "%s\n\n", flashErrorStyle.Render("error: "+f.err))
|
||||
}
|
||||
if f.busy {
|
||||
fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…"))
|
||||
} else {
|
||||
fmt.Fprintf(&b, "%s\n", helpStyle.Render("↑↓ field enter next/submit esc cancel"))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
f.width = msg.Width
|
||||
w := fieldWidthFor(msg.Width)
|
||||
for i := range f.fields {
|
||||
f.fields[i].input.Width = w
|
||||
}
|
||||
return f, nil
|
||||
|
||||
case formSubmitErr:
|
||||
f.busy = false
|
||||
f.err = string(msg)
|
||||
return f, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return f, modalDoneCmd("", flashInfo)
|
||||
case "tab", "down":
|
||||
f.advance(1)
|
||||
return f, nil
|
||||
case "shift+tab", "up":
|
||||
f.advance(-1)
|
||||
return f, nil
|
||||
case "enter":
|
||||
if f.busy {
|
||||
return f, nil
|
||||
}
|
||||
if f.cursor < len(f.fields)-1 {
|
||||
f.advance(1)
|
||||
return f, nil
|
||||
}
|
||||
vals := make([]string, len(f.fields))
|
||||
for i, fld := range f.fields {
|
||||
vals[i] = fld.input.Value()
|
||||
}
|
||||
for i, fld := range f.fields {
|
||||
if fld.required && strings.TrimSpace(vals[i]) == "" {
|
||||
f.err = fld.label + " is required"
|
||||
f.cursor = i
|
||||
f.focusOnly(i)
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
f.busy = true
|
||||
f.err = ""
|
||||
return f, f.submit(vals)
|
||||
}
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg)
|
||||
return f, cmd
|
||||
}
|
||||
|
||||
func (f *form) advance(delta int) {
|
||||
n := len(f.fields)
|
||||
if n == 0 {
|
||||
return
|
||||
}
|
||||
f.cursor = (f.cursor + delta + n) % n
|
||||
f.focusOnly(f.cursor)
|
||||
}
|
||||
|
||||
func (f *form) focusOnly(i int) {
|
||||
for j := range f.fields {
|
||||
if j == i {
|
||||
f.fields[j].input.Focus()
|
||||
} else {
|
||||
f.fields[j].input.Blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// formSubmitErr is a tea.Msg the submit cmd returns to surface an
|
||||
// error inline without closing the form.
|
||||
type formSubmitErr string
|
||||
|
||||
func submitErr(err error) tea.Cmd {
|
||||
return func() tea.Msg { return formSubmitErr(err.Error()) }
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Specific forms.
|
||||
// =============================================================
|
||||
|
||||
func newAddCheckForm(checkType config.CheckType) *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Target", targetHint(checkType), true),
|
||||
textField("Interval", "e.g. 30s, 1m", false),
|
||||
textField("Timeout", "e.g. 10s", false),
|
||||
textField("Alerts", "comma-separated alert IDs/names (optional)", false),
|
||||
}
|
||||
if checkType == config.CheckHTTP {
|
||||
fields = append(fields,
|
||||
textField("Expect status", "e.g. 200 (HTTP only)", false),
|
||||
textField("Body match", "substring required (HTTP only)", false),
|
||||
)
|
||||
}
|
||||
return newForm("Add "+strings.ToUpper(string(checkType))+" check", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ch := config.Check{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: checkType,
|
||||
Target: strings.TrimSpace(vals[1]),
|
||||
Interval: parseDurationOr(vals[2], 30*time.Second),
|
||||
Timeout: parseDurationOr(vals[3], 10*time.Second),
|
||||
}
|
||||
if a := strings.TrimSpace(vals[4]); a != "" {
|
||||
for _, p := range strings.Split(a, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
ch.AlertIDs = append(ch.AlertIDs, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checkType == config.CheckHTTP {
|
||||
ch.ExpectStatus = atoiOr(vals[5], 200)
|
||||
ch.BodyMatch = strings.TrimSpace(vals[6])
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddCheck, ch); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added check " + ch.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddDiscordForm() *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Webhook URL", "https://discord.com/api/webhooks/...", true),
|
||||
textField("Default", "yes/no — attach to every check automatically", false),
|
||||
textField("Body template", alerts.TemplateVarsHint(), false),
|
||||
}
|
||||
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertDiscord,
|
||||
DiscordWebhook: strings.TrimSpace(vals[1]),
|
||||
Default: parseBool(vals[2]),
|
||||
BodyTemplate: vals[3],
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added discord alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddSMTPForm() *form {
|
||||
fields := []formField{
|
||||
textField("Name", "human-friendly identifier", true),
|
||||
textField("Host", "smtp.example.com", true),
|
||||
textField("Port", "default 587", false),
|
||||
textField("User", "leave empty for anonymous", false),
|
||||
passwordField("Password", "smtp auth password"),
|
||||
textField("From", "envelope From address", true),
|
||||
textField("To", "comma-separated recipient addresses", true),
|
||||
textField("StartTLS", "yes/no — default yes", false),
|
||||
textField("Default", "yes/no — attach to every check", false),
|
||||
textField("Subject template", alerts.TemplateVarsHint(), false),
|
||||
textField("Body template", alerts.TemplateVarsHint(), false),
|
||||
}
|
||||
return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
to := strings.Split(strings.TrimSpace(vals[6]), ",")
|
||||
for i := range to {
|
||||
to[i] = strings.TrimSpace(to[i])
|
||||
}
|
||||
a := config.Alert{
|
||||
ID: uuid.NewString(),
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertSMTP,
|
||||
SMTPHost: strings.TrimSpace(vals[1]),
|
||||
SMTPPort: atoiOr(vals[2], 587),
|
||||
SMTPUser: strings.TrimSpace(vals[3]),
|
||||
SMTPPassword: vals[4],
|
||||
SMTPFrom: strings.TrimSpace(vals[5]),
|
||||
SMTPTo: to,
|
||||
SMTPStartTLS: parseBoolOr(vals[7], true),
|
||||
Default: parseBool(vals[8]),
|
||||
SubjectTemplate: vals[9],
|
||||
BodyTemplate: vals[10],
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "added smtp alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newAddNodeForm() *form {
|
||||
fields := []formField{
|
||||
textField("Address", "host:9901 of the peer to invite", true),
|
||||
}
|
||||
return newForm("Add node (TOFU)", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
addr := strings.TrimSpace(vals[0])
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
raw, err := callDaemon(ctx, daemon.CtrlNodeProbe, daemon.NodeProbeBody{Address: addr})
|
||||
if err != nil {
|
||||
return formSubmitErr(fmt.Sprintf("probe: %v", err))
|
||||
}
|
||||
var probe daemon.NodeProbeResult
|
||||
if err := json.Unmarshal(raw, &probe); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
// auto-accept the fingerprint we just observed. The cluster
|
||||
// secret check on the remote side already prevents random
|
||||
// hosts from being trusted.
|
||||
raw, err = callDaemon(ctx, daemon.CtrlNodeAdd, daemon.NodeAddBody{
|
||||
Address: addr,
|
||||
Fingerprint: probe.Fingerprint,
|
||||
})
|
||||
if err != nil {
|
||||
return formSubmitErr(fmt.Sprintf("add: %v", err))
|
||||
}
|
||||
var res daemon.NodeAddResult
|
||||
_ = json.Unmarshal(raw, &res)
|
||||
return modalDone{
|
||||
flash: fmt.Sprintf("added node %s — cluster version %d", res.NodeID, res.Version),
|
||||
level: flashInfo,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Edit forms — same shape as the add forms above, but the inputs are
|
||||
// pre-populated from an existing record and the submit closure reuses
|
||||
// the original ID so the daemon's upsert path replaces the entry
|
||||
// instead of creating a new one.
|
||||
// =============================================================
|
||||
|
||||
func newEditCheckForm(existing config.Check) *form {
|
||||
intervalStr := ""
|
||||
if existing.Interval > 0 {
|
||||
intervalStr = existing.Interval.String()
|
||||
}
|
||||
timeoutStr := ""
|
||||
if existing.Timeout > 0 {
|
||||
timeoutStr = existing.Timeout.String()
|
||||
}
|
||||
expectStr := ""
|
||||
if existing.ExpectStatus > 0 {
|
||||
expectStr = fmt.Sprintf("%d", existing.ExpectStatus)
|
||||
}
|
||||
|
||||
fields := []formField{
|
||||
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
|
||||
textFieldWithValue("Target", targetHint(existing.Type), existing.Target, true),
|
||||
textFieldWithValue("Interval", "e.g. 30s, 1m", intervalStr, false),
|
||||
textFieldWithValue("Timeout", "e.g. 10s", timeoutStr, false),
|
||||
textFieldWithValue("Alerts", "comma-separated alert IDs/names (optional)", strings.Join(existing.AlertIDs, ","), false),
|
||||
}
|
||||
if existing.Type == config.CheckHTTP {
|
||||
fields = append(fields,
|
||||
textFieldWithValue("Expect status", "e.g. 200 (HTTP only)", expectStr, false),
|
||||
textFieldWithValue("Body match", "substring required (HTTP only)", existing.BodyMatch, false),
|
||||
)
|
||||
}
|
||||
checkType := existing.Type
|
||||
id := existing.ID
|
||||
suppress := append([]string(nil), existing.SuppressAlertIDs...)
|
||||
return newForm("Edit "+strings.ToUpper(string(checkType))+" check", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ch := config.Check{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: checkType,
|
||||
Target: strings.TrimSpace(vals[1]),
|
||||
Interval: parseDurationOr(vals[2], 30*time.Second),
|
||||
Timeout: parseDurationOr(vals[3], 10*time.Second),
|
||||
SuppressAlertIDs: suppress,
|
||||
}
|
||||
if a := strings.TrimSpace(vals[4]); a != "" {
|
||||
for _, p := range strings.Split(a, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
ch.AlertIDs = append(ch.AlertIDs, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
if checkType == config.CheckHTTP {
|
||||
ch.ExpectStatus = atoiOr(vals[5], 200)
|
||||
ch.BodyMatch = strings.TrimSpace(vals[6])
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddCheck, ch); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "updated check " + ch.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newEditDiscordForm(existing config.Alert) *form {
|
||||
fields := []formField{
|
||||
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
|
||||
textFieldWithValue("Webhook URL", "https://discord.com/api/webhooks/...", existing.DiscordWebhook, true),
|
||||
textFieldWithValue("Default", "yes/no — attach to every check automatically", boolStr(existing.Default), false),
|
||||
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
|
||||
}
|
||||
id := existing.ID
|
||||
subject := existing.SubjectTemplate
|
||||
return newForm("Edit Discord alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
a := config.Alert{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertDiscord,
|
||||
DiscordWebhook: strings.TrimSpace(vals[1]),
|
||||
Default: parseBool(vals[2]),
|
||||
BodyTemplate: vals[3],
|
||||
SubjectTemplate: subject,
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "updated discord alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func newEditSMTPForm(existing config.Alert) *form {
|
||||
portStr := ""
|
||||
if existing.SMTPPort > 0 {
|
||||
portStr = fmt.Sprintf("%d", existing.SMTPPort)
|
||||
}
|
||||
fields := []formField{
|
||||
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
|
||||
textFieldWithValue("Host", "smtp.example.com", existing.SMTPHost, true),
|
||||
textFieldWithValue("Port", "default 587", portStr, false),
|
||||
textFieldWithValue("User", "leave empty for anonymous", existing.SMTPUser, false),
|
||||
passwordFieldWithValue("Password", "smtp auth password", existing.SMTPPassword),
|
||||
textFieldWithValue("From", "envelope From address", existing.SMTPFrom, true),
|
||||
textFieldWithValue("To", "comma-separated recipient addresses", strings.Join(existing.SMTPTo, ","), true),
|
||||
textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false),
|
||||
textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false),
|
||||
textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, false),
|
||||
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
|
||||
}
|
||||
id := existing.ID
|
||||
return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
to := strings.Split(strings.TrimSpace(vals[6]), ",")
|
||||
for i := range to {
|
||||
to[i] = strings.TrimSpace(to[i])
|
||||
}
|
||||
a := config.Alert{
|
||||
ID: id,
|
||||
Name: strings.TrimSpace(vals[0]),
|
||||
Type: config.AlertSMTP,
|
||||
SMTPHost: strings.TrimSpace(vals[1]),
|
||||
SMTPPort: atoiOr(vals[2], 587),
|
||||
SMTPUser: strings.TrimSpace(vals[3]),
|
||||
SMTPPassword: vals[4],
|
||||
SMTPFrom: strings.TrimSpace(vals[5]),
|
||||
SMTPTo: to,
|
||||
SMTPStartTLS: parseBoolOr(vals[7], true),
|
||||
Default: parseBool(vals[8]),
|
||||
SubjectTemplate: vals[9],
|
||||
BodyTemplate: vals[10],
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddAlert, a); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "updated smtp alert " + a.Name, level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// newEditNodeForm only exposes the advertise address. The NodeID and
|
||||
// fingerprint/cert are bound by trust and cannot be edited in place;
|
||||
// removing and re-adding the node is the path for those changes.
|
||||
func newEditNodeForm(existing config.PeerInfo) *form {
|
||||
fields := []formField{
|
||||
textFieldWithValue("Address", "host:9901 — peer's advertise endpoint", existing.Advertise, true),
|
||||
}
|
||||
id := existing.NodeID
|
||||
fp := existing.Fingerprint
|
||||
cert := existing.CertPEM
|
||||
return newForm("Edit node "+shortID(id), fields, func(vals []string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
p := config.PeerInfo{
|
||||
NodeID: id,
|
||||
Advertise: strings.TrimSpace(vals[0]),
|
||||
Fingerprint: fp,
|
||||
CertPEM: cert,
|
||||
}
|
||||
if err := mutateAdd(transport.MutationAddPeer, p); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "updated node " + shortID(id), level: flashInfo}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "yes"
|
||||
}
|
||||
return "no"
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Pickers and confirmations.
|
||||
// =============================================================
|
||||
|
||||
type pickerOption struct {
|
||||
label string
|
||||
hint string
|
||||
choose func() modal
|
||||
act func() tea.Cmd // if non-nil, picker returns this cmd directly instead of opening another modal
|
||||
}
|
||||
|
||||
type picker struct {
|
||||
title string
|
||||
options []pickerOption
|
||||
cursor int
|
||||
}
|
||||
|
||||
func newPicker(title string, options []pickerOption) *picker {
|
||||
return &picker{title: title, options: options}
|
||||
}
|
||||
|
||||
func (p *picker) Title() string { return p.title }
|
||||
|
||||
func (p *picker) View() string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "%s\n\n", titleStyle.Render(p.title))
|
||||
for i, o := range p.options {
|
||||
marker := " "
|
||||
style := subtleStyle
|
||||
if i == p.cursor {
|
||||
marker = "▸ "
|
||||
style = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s%s\n", marker, style.Render(o.label))
|
||||
if o.hint != "" {
|
||||
fmt.Fprintf(&b, " %s\n", helpStyle.Render(o.hint))
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, "\n%s\n", helpStyle.Render("↑↓ select enter pick esc cancel"))
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (p *picker) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
km, ok := msg.(tea.KeyMsg)
|
||||
if !ok {
|
||||
return p, nil
|
||||
}
|
||||
switch km.String() {
|
||||
case "esc":
|
||||
return p, modalDoneCmd("", flashInfo)
|
||||
case "up", "k":
|
||||
if p.cursor > 0 {
|
||||
p.cursor--
|
||||
}
|
||||
return p, nil
|
||||
case "down", "j":
|
||||
if p.cursor < len(p.options)-1 {
|
||||
p.cursor++
|
||||
}
|
||||
return p, nil
|
||||
case "enter":
|
||||
if p.cursor < 0 || p.cursor >= len(p.options) {
|
||||
return p, nil
|
||||
}
|
||||
opt := p.options[p.cursor]
|
||||
if opt.act != nil {
|
||||
return p, opt.act()
|
||||
}
|
||||
if opt.choose != nil {
|
||||
return opt.choose(), nil
|
||||
}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// confirm asks yes/no and runs onConfirm if the user picks yes.
|
||||
type confirm struct {
|
||||
prompt string
|
||||
onConfirm func() tea.Cmd
|
||||
choice int // 0=no, 1=yes
|
||||
busy bool
|
||||
err string
|
||||
}
|
||||
|
||||
func newConfirm(prompt string, onConfirm func() tea.Cmd) *confirm {
|
||||
return &confirm{prompt: prompt, onConfirm: onConfirm}
|
||||
}
|
||||
|
||||
func (c *confirm) Title() string { return "Confirm" }
|
||||
|
||||
func (c *confirm) View() string {
|
||||
noStyle, yesStyle := subtleStyle, subtleStyle
|
||||
if c.choice == 0 {
|
||||
noStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
} else {
|
||||
yesStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
|
||||
}
|
||||
body := fmt.Sprintf("%s\n\n [%s] [%s]\n",
|
||||
c.prompt,
|
||||
noStyle.Render("No"),
|
||||
yesStyle.Render("Yes"),
|
||||
)
|
||||
if c.err != "" {
|
||||
body += "\n" + flashErrorStyle.Render("error: "+c.err) + "\n"
|
||||
}
|
||||
if c.busy {
|
||||
body += "\n" + flashWarnStyle.Render("working…") + "\n"
|
||||
} else {
|
||||
body += "\n" + helpStyle.Render("←→ or h/l select enter confirm esc cancel")
|
||||
}
|
||||
return body
|
||||
}
|
||||
|
||||
func (c *confirm) Update(msg tea.Msg) (modal, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case formSubmitErr:
|
||||
c.busy = false
|
||||
c.err = string(msg)
|
||||
return c, nil
|
||||
case tea.KeyMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
return c, modalDoneCmd("", flashInfo)
|
||||
case "left", "right", "h", "l", "tab":
|
||||
c.choice = 1 - c.choice
|
||||
return c, nil
|
||||
case "y", "Y":
|
||||
c.choice = 1
|
||||
return c.commit()
|
||||
case "n", "N":
|
||||
return c, modalDoneCmd("cancelled", flashInfo)
|
||||
case "enter":
|
||||
if c.choice == 1 {
|
||||
return c.commit()
|
||||
}
|
||||
return c, modalDoneCmd("cancelled", flashInfo)
|
||||
}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *confirm) commit() (modal, tea.Cmd) {
|
||||
if c.busy {
|
||||
return c, nil
|
||||
}
|
||||
c.busy = true
|
||||
c.err = ""
|
||||
return c, c.onConfirm()
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Helpers shared by submit closures.
|
||||
// =============================================================
|
||||
|
||||
func mutateAdd(kind transport.MutationKind, payload any) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: kind, Payload: raw}
|
||||
_, err = callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func mutateRemove(kind transport.MutationKind, idOrName string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
raw, err := json.Marshal(idOrName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body := daemon.MutateBody{Kind: kind, Payload: raw}
|
||||
_, err = callDaemon(ctx, daemon.CtrlMutate, body)
|
||||
return err
|
||||
}
|
||||
|
||||
func targetHint(t config.CheckType) string {
|
||||
switch t {
|
||||
case config.CheckHTTP:
|
||||
return "https://example.com/health"
|
||||
case config.CheckTCP:
|
||||
return "db.internal:5432"
|
||||
case config.CheckICMP:
|
||||
return "10.0.0.1"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseDurationOr(s string, fallback time.Duration) time.Duration {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
d, err := time.ParseDuration(s)
|
||||
if err != nil {
|
||||
return fallback
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
func atoiOr(s string, fallback int) int {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
var n int
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return fallback
|
||||
}
|
||||
n = n*10 + int(r-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseBool(s string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "yes", "y", "true", "t", "on", "1":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func parseBoolOr(s string, fallback bool) bool {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
switch s {
|
||||
case "yes", "y", "true", "t", "on", "1":
|
||||
return true
|
||||
case "no", "n", "false", "f", "off", "0":
|
||||
return false
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
var (
|
||||
colorBorder = lipgloss.Color("63") // soft purple
|
||||
colorAccent = lipgloss.Color("212") // pink
|
||||
colorMuted = lipgloss.Color("241") // gray
|
||||
colorSuccess = lipgloss.Color("42") // green
|
||||
colorWarn = lipgloss.Color("214") // orange
|
||||
colorError = lipgloss.Color("196") // red
|
||||
colorTabActive = lipgloss.Color("212")
|
||||
colorTabIdle = lipgloss.Color("241")
|
||||
)
|
||||
|
||||
var (
|
||||
titleStyle = lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(colorAccent).
|
||||
Bold(true).
|
||||
Padding(0, 1)
|
||||
|
||||
subtleStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
|
||||
headerStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1)
|
||||
|
||||
tabActiveStyle = lipgloss.NewStyle().
|
||||
Foreground(colorTabActive).
|
||||
Bold(true).
|
||||
Underline(true).
|
||||
Padding(0, 1)
|
||||
|
||||
tabIdleStyle = lipgloss.NewStyle().
|
||||
Foreground(colorTabIdle).
|
||||
Padding(0, 1)
|
||||
|
||||
bodyStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(0, 1)
|
||||
|
||||
helpStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
|
||||
flashInfoStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true)
|
||||
flashErrorStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true)
|
||||
flashWarnStyle = lipgloss.NewStyle().Foreground(colorWarn).Bold(true)
|
||||
|
||||
modalStyle = lipgloss.NewStyle().
|
||||
Border(lipgloss.DoubleBorder()).
|
||||
BorderForeground(colorAccent).
|
||||
Padding(1, 2)
|
||||
|
||||
stateUpStyle = lipgloss.NewStyle().Foreground(colorSuccess).Bold(true)
|
||||
stateDownStyle = lipgloss.NewStyle().Foreground(colorError).Bold(true)
|
||||
stateUnknownStyle = lipgloss.NewStyle().Foreground(colorMuted)
|
||||
)
|
||||
|
||||
// renderState returns a plain-text state label for use inside the
|
||||
// bubbles table. The table truncates cells with runewidth.Truncate
|
||||
// which counts the printable bytes of ANSI escape sequences toward
|
||||
// column width, so a styled value gets chopped down to just "…".
|
||||
func renderState(s string) string {
|
||||
switch s {
|
||||
case "up":
|
||||
return "● up"
|
||||
case "down":
|
||||
return "● down"
|
||||
default:
|
||||
return "○ unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func renderLive(live bool) string {
|
||||
if live {
|
||||
return stateUpStyle.Render("● live")
|
||||
}
|
||||
return stateDownStyle.Render("● dead")
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/table"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
// tabModel is the small surface every tab implements. Tabs share the
|
||||
// same Update/View shape so the parent can dispatch generically.
|
||||
type tabModel interface {
|
||||
Update(tea.Msg) (tabModel, tea.Cmd)
|
||||
View() string
|
||||
SetSize(width, height int)
|
||||
// Selected returns the row identifier for the row under the cursor,
|
||||
// or "" if the table is empty. For peers/nodes this is a NodeID;
|
||||
// for checks it's a CheckID; for alerts it's an AlertID.
|
||||
Selected() string
|
||||
// SelectedName returns a human-friendly label for the selected row
|
||||
// (used in confirm dialogs).
|
||||
SelectedName() string
|
||||
}
|
||||
|
||||
// peersTab — read-only view of cluster membership.
|
||||
type peersTab struct {
|
||||
tbl table.Model
|
||||
}
|
||||
|
||||
func newPeersTab() *peersTab {
|
||||
cols := []table.Column{
|
||||
{Title: "NODE_ID", Width: 38},
|
||||
{Title: "ADVERTISE", Width: 28},
|
||||
{Title: "LIVE", Width: 8},
|
||||
{Title: "LAST SEEN", Width: 22},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &peersTab{tbl: t}
|
||||
}
|
||||
|
||||
func (p *peersTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
p.tbl, cmd = p.tbl.Update(msg)
|
||||
return p, cmd
|
||||
}
|
||||
|
||||
func (p *peersTab) View() string { return p.tbl.View() }
|
||||
|
||||
func (p *peersTab) SetSize(w, h int) {
|
||||
p.tbl.SetWidth(w)
|
||||
p.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (p *peersTab) Selected() string {
|
||||
r := p.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (p *peersTab) SelectedName() string { return p.Selected() }
|
||||
|
||||
func (p *peersTab) Refresh(st transport.StatusResponse, selfID string) {
|
||||
rows := make([]table.Row, 0, len(st.Peers))
|
||||
for _, peer := range st.Peers {
|
||||
lastSeen := "-"
|
||||
if !peer.LastSeen.IsZero() {
|
||||
lastSeen = peer.LastSeen.UTC().Format(time.RFC3339)
|
||||
}
|
||||
id := peer.NodeID
|
||||
if peer.NodeID == selfID {
|
||||
id = "* " + peer.NodeID
|
||||
}
|
||||
rows = append(rows, table.Row{id, peer.Advertise, livenessText(peer.Live), lastSeen})
|
||||
}
|
||||
p.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
// checksTab — checks with state and effective alerts.
|
||||
type checksTab struct {
|
||||
tbl table.Model
|
||||
}
|
||||
|
||||
func newChecksTab() *checksTab {
|
||||
cols := []table.Column{
|
||||
{Title: "ID", Width: 38},
|
||||
{Title: "NAME", Width: 18},
|
||||
{Title: "STATE", Width: 12},
|
||||
{Title: "OK/TOTAL", Width: 10},
|
||||
{Title: "ALERTS", Width: 24},
|
||||
{Title: "DETAIL", Width: 40},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &checksTab{tbl: t}
|
||||
}
|
||||
|
||||
func (c *checksTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
c.tbl, cmd = c.tbl.Update(msg)
|
||||
return c, cmd
|
||||
}
|
||||
|
||||
func (c *checksTab) View() string { return c.tbl.View() }
|
||||
|
||||
func (c *checksTab) SetSize(w, h int) {
|
||||
c.tbl.SetWidth(w)
|
||||
c.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (c *checksTab) Selected() string {
|
||||
r := c.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (c *checksTab) SelectedName() string {
|
||||
r := c.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[1]
|
||||
}
|
||||
|
||||
func (c *checksTab) Refresh(st transport.StatusResponse) {
|
||||
rows := make([]table.Row, 0, len(st.Checks))
|
||||
for _, ch := range st.Checks {
|
||||
okTotal := lipgloss.NewStyle().Render("0/0")
|
||||
if ch.Total > 0 {
|
||||
okTotal = lipgloss.NewStyle().Render(itoa(ch.OKCount) + "/" + itoa(ch.Total))
|
||||
}
|
||||
alerts := strings.Join(ch.Alerts, ",")
|
||||
if alerts == "" {
|
||||
alerts = "-"
|
||||
}
|
||||
rows = append(rows, table.Row{
|
||||
ch.CheckID, ch.Name, renderState(ch.State), okTotal, alerts, truncate(ch.Detail, 38),
|
||||
})
|
||||
}
|
||||
c.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
// alertsTab — configured notification channels.
|
||||
type alertsTab struct {
|
||||
tbl table.Model
|
||||
alerts []alertRow
|
||||
}
|
||||
|
||||
type alertRow struct {
|
||||
ID string
|
||||
Name string
|
||||
Type string
|
||||
Default bool
|
||||
HasTmpl bool
|
||||
Endpoint string
|
||||
}
|
||||
|
||||
func newAlertsTab() *alertsTab {
|
||||
cols := []table.Column{
|
||||
{Title: "ID", Width: 38},
|
||||
{Title: "NAME", Width: 16},
|
||||
{Title: "TYPE", Width: 10},
|
||||
{Title: "DEFAULT", Width: 8},
|
||||
{Title: "CUSTOM-MSG", Width: 11},
|
||||
{Title: "ENDPOINT", Width: 36},
|
||||
}
|
||||
t := table.New(table.WithColumns(cols), table.WithFocused(true))
|
||||
t.SetStyles(tableStyles())
|
||||
return &alertsTab{tbl: t}
|
||||
}
|
||||
|
||||
func (a *alertsTab) Update(msg tea.Msg) (tabModel, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
a.tbl, cmd = a.tbl.Update(msg)
|
||||
return a, cmd
|
||||
}
|
||||
|
||||
func (a *alertsTab) View() string { return a.tbl.View() }
|
||||
|
||||
func (a *alertsTab) SetSize(w, h int) {
|
||||
a.tbl.SetWidth(w)
|
||||
a.tbl.SetHeight(h)
|
||||
}
|
||||
|
||||
func (a *alertsTab) Selected() string {
|
||||
r := a.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[0]
|
||||
}
|
||||
|
||||
func (a *alertsTab) SelectedName() string {
|
||||
r := a.tbl.SelectedRow()
|
||||
if r == nil {
|
||||
return ""
|
||||
}
|
||||
return r[1]
|
||||
}
|
||||
|
||||
// SelectedAlert returns the row metadata for the cursor, so the parent
|
||||
// can flip the default flag without a roundtrip.
|
||||
func (a *alertsTab) SelectedAlert() *alertRow {
|
||||
idx := a.tbl.Cursor()
|
||||
if idx < 0 || idx >= len(a.alerts) {
|
||||
return nil
|
||||
}
|
||||
cp := a.alerts[idx]
|
||||
return &cp
|
||||
}
|
||||
|
||||
func (a *alertsTab) Refresh(alerts []alertRow) {
|
||||
a.alerts = alerts
|
||||
rows := make([]table.Row, 0, len(alerts))
|
||||
for _, r := range alerts {
|
||||
def := "-"
|
||||
if r.Default {
|
||||
def = "yes"
|
||||
}
|
||||
tmpl := "-"
|
||||
if r.HasTmpl {
|
||||
tmpl = "yes"
|
||||
}
|
||||
rows = append(rows, table.Row{r.ID, r.Name, r.Type, def, tmpl, truncate(r.Endpoint, 34)})
|
||||
}
|
||||
a.tbl.SetRows(rows)
|
||||
}
|
||||
|
||||
func tableStyles() table.Styles {
|
||||
s := table.DefaultStyles()
|
||||
s.Header = s.Header.
|
||||
BorderStyle(lipgloss.NormalBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
BorderBottom(true).
|
||||
Bold(true)
|
||||
s.Selected = s.Selected.
|
||||
Foreground(lipgloss.Color("230")).
|
||||
Background(colorAccent).
|
||||
Bold(true)
|
||||
return s
|
||||
}
|
||||
|
||||
func livenessText(live bool) string {
|
||||
if live {
|
||||
return "live"
|
||||
}
|
||||
return "dead"
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
// avoid pulling fmt in the hot path of refresh
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := i < 0
|
||||
if neg {
|
||||
i = -i
|
||||
}
|
||||
var buf [20]byte
|
||||
pos := len(buf)
|
||||
for i > 0 {
|
||||
pos--
|
||||
buf[pos] = byte('0' + i%10)
|
||||
i /= 10
|
||||
}
|
||||
if neg {
|
||||
pos--
|
||||
buf[pos] = '-'
|
||||
}
|
||||
return string(buf[pos:])
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
if max <= 0 || len(s) <= max {
|
||||
return s
|
||||
}
|
||||
if max < 4 {
|
||||
return s[:max]
|
||||
}
|
||||
return s[:max-1] + "…"
|
||||
}
|
||||
@@ -0,0 +1,709 @@
|
||||
// Package tui implements the interactive overview/control surface
|
||||
// reachable via `qu tui`. It is a thin bubbletea client over the same
|
||||
// unix control socket the CLI uses; nothing here talks to peers
|
||||
// directly.
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"git.cer.sh/axodouble/quptime/internal/config"
|
||||
"git.cer.sh/axodouble/quptime/internal/daemon"
|
||||
"git.cer.sh/axodouble/quptime/internal/transport"
|
||||
)
|
||||
|
||||
const refreshInterval = 2 * time.Second
|
||||
|
||||
// Run starts the bubbletea program. Blocks until the user quits.
|
||||
func Run() error {
|
||||
m := initialModel()
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
type tabIndex int
|
||||
|
||||
const (
|
||||
tabPeers tabIndex = iota
|
||||
tabChecks
|
||||
tabAlerts
|
||||
)
|
||||
|
||||
var tabNames = []string{"Peers", "Checks", "Alerts"}
|
||||
|
||||
type model struct {
|
||||
width, height int
|
||||
|
||||
status transport.StatusResponse
|
||||
statusLoaded bool
|
||||
statusErr string
|
||||
|
||||
// Full records cached from cluster.yaml directly (the daemon status
|
||||
// only ships per-check effective alert names and per-peer liveness).
|
||||
// We need the full records to render the alerts tab, to support the
|
||||
// default-toggle, and to pre-fill edit forms with current values.
|
||||
peersFull []config.PeerInfo
|
||||
checksFull []config.Check
|
||||
alerts []config.Alert
|
||||
|
||||
active tabIndex
|
||||
peers *peersTab
|
||||
checks *checksTab
|
||||
alertsT *alertsTab
|
||||
|
||||
modal modal
|
||||
|
||||
flash string
|
||||
flashLevel flashLevel
|
||||
flashUntil time.Time
|
||||
}
|
||||
|
||||
func initialModel() model {
|
||||
return model{
|
||||
peers: newPeersTab(),
|
||||
checks: newChecksTab(),
|
||||
alertsT: newAlertsTab(),
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Bubbletea lifecycle.
|
||||
// =============================================================
|
||||
|
||||
func (m model) Init() tea.Cmd {
|
||||
return tea.Batch(loadStatusCmd(), loadConfigCmd(), tickCmd())
|
||||
}
|
||||
|
||||
type tickMsg time.Time
|
||||
|
||||
type statusMsg struct {
|
||||
st transport.StatusResponse
|
||||
err error
|
||||
}
|
||||
|
||||
type configMsg struct {
|
||||
peers []config.PeerInfo
|
||||
checks []config.Check
|
||||
alerts []config.Alert
|
||||
err error
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(refreshInterval, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func loadStatusCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
|
||||
defer cancel()
|
||||
raw, err := callDaemon(ctx, daemon.CtrlStatus, nil)
|
||||
if err != nil {
|
||||
return statusMsg{err: err}
|
||||
}
|
||||
var st transport.StatusResponse
|
||||
if err := json.Unmarshal(raw, &st); err != nil {
|
||||
return statusMsg{err: err}
|
||||
}
|
||||
return statusMsg{st: st}
|
||||
}
|
||||
}
|
||||
|
||||
func loadConfigCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
cfg, err := config.LoadClusterConfig()
|
||||
if err != nil {
|
||||
return configMsg{err: err}
|
||||
}
|
||||
snap := cfg.Snapshot()
|
||||
return configMsg{peers: snap.Peers, checks: snap.Checks, alerts: snap.Alerts}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.width, m.height = msg.Width, msg.Height
|
||||
m.resizeTabs()
|
||||
if m.modal != nil {
|
||||
m.modal, _ = m.modal.Update(msg)
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tickMsg:
|
||||
return m, tea.Batch(loadStatusCmd(), loadConfigCmd(), tickCmd())
|
||||
|
||||
case statusMsg:
|
||||
if msg.err != nil {
|
||||
m.statusErr = msg.err.Error()
|
||||
} else {
|
||||
m.statusErr = ""
|
||||
wasLoaded := m.statusLoaded
|
||||
m.status = msg.st
|
||||
m.statusLoaded = true
|
||||
m.peers.Refresh(msg.st, msg.st.NodeID)
|
||||
m.checks.Refresh(msg.st)
|
||||
// First load may change header height on narrow terminals;
|
||||
// re-run the layout so the body shrinks to compensate.
|
||||
if !wasLoaded {
|
||||
m.resizeTabs()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case configMsg:
|
||||
if msg.err == nil {
|
||||
m.peersFull = msg.peers
|
||||
m.checksFull = msg.checks
|
||||
m.alerts = msg.alerts
|
||||
m.alertsT.Refresh(toAlertRows(msg.alerts))
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case modalDone:
|
||||
m.modal = nil
|
||||
if msg.flash != "" {
|
||||
m.setFlash(msg.flash, msg.level)
|
||||
}
|
||||
// Force-refresh in case the modal mutated cluster state.
|
||||
return m, tea.Batch(loadStatusCmd(), loadConfigCmd())
|
||||
}
|
||||
|
||||
// Modal grabs all input while open.
|
||||
if m.modal != nil {
|
||||
prev := m.modal
|
||||
newModal, cmd := m.modal.Update(msg)
|
||||
m.modal = newModal
|
||||
// If the modal handed off to a different modal (e.g. picker →
|
||||
// form), seed the new one with the current terminal size so its
|
||||
// text inputs can size themselves on first paint.
|
||||
if newModal != nil && newModal != prev {
|
||||
m.seedModalSize()
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
if km, ok := msg.(tea.KeyMsg); ok {
|
||||
return m.handleKey(km)
|
||||
}
|
||||
|
||||
// Pass through to the active tab so j/k/PgUp/PgDn scroll the table.
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
_, cmd := m.peers.Update(msg)
|
||||
return m, cmd
|
||||
case tabChecks:
|
||||
_, cmd := m.checks.Update(msg)
|
||||
return m, cmd
|
||||
case tabAlerts:
|
||||
_, cmd := m.alertsT.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch km.String() {
|
||||
case "ctrl+c", "q":
|
||||
return m, tea.Quit
|
||||
case "tab", "right", "L":
|
||||
m.active = (m.active + 1) % 3
|
||||
return m, nil
|
||||
case "shift+tab", "left", "H":
|
||||
m.active = (m.active + 2) % 3
|
||||
return m, nil
|
||||
case "1":
|
||||
m.active = tabPeers
|
||||
return m, nil
|
||||
case "2":
|
||||
m.active = tabChecks
|
||||
return m, nil
|
||||
case "3":
|
||||
m.active = tabAlerts
|
||||
return m, nil
|
||||
case "r":
|
||||
m.setFlash("refreshing…", flashInfo)
|
||||
return m, tea.Batch(loadStatusCmd(), loadConfigCmd())
|
||||
case "a":
|
||||
m.modal = m.openAddPicker()
|
||||
m.seedModalSize()
|
||||
return m, nil
|
||||
case "d":
|
||||
return m.openRemoveConfirm()
|
||||
case "e":
|
||||
return m.openEditForm()
|
||||
case "t":
|
||||
if m.active == tabAlerts {
|
||||
return m.testSelectedAlert()
|
||||
}
|
||||
case "D":
|
||||
if m.active == tabAlerts {
|
||||
return m.toggleSelectedDefault()
|
||||
}
|
||||
}
|
||||
|
||||
// Forward everything else (arrow keys etc.) to the active tab.
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
_, cmd := m.peers.Update(km)
|
||||
return m, cmd
|
||||
case tabChecks:
|
||||
_, cmd := m.checks.Update(km)
|
||||
return m, cmd
|
||||
case tabAlerts:
|
||||
_, cmd := m.alertsT.Update(km)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// View.
|
||||
// =============================================================
|
||||
|
||||
func (m model) View() string {
|
||||
if m.width == 0 {
|
||||
return "loading…"
|
||||
}
|
||||
header := m.renderHeader()
|
||||
tabs := m.renderTabs()
|
||||
body := m.renderActiveTab()
|
||||
help := m.renderHelp()
|
||||
|
||||
page := lipgloss.JoinVertical(lipgloss.Left, header, tabs, body, m.renderFlash(), help)
|
||||
|
||||
if m.modal != nil {
|
||||
overlay := modalStyle.Render(m.modal.View())
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, overlay, lipgloss.WithWhitespaceChars(" "))
|
||||
}
|
||||
return page
|
||||
}
|
||||
|
||||
func (m model) renderHeader() string {
|
||||
outerW := m.width - 2
|
||||
if outerW < 20 {
|
||||
outerW = 20
|
||||
}
|
||||
// headerStyle has Padding(0,1), so the usable content width is outerW-2.
|
||||
innerW := outerW - 2
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
|
||||
if !m.statusLoaded {
|
||||
msg := "connecting to daemon…"
|
||||
if m.statusErr != "" {
|
||||
msg = "daemon: " + m.statusErr
|
||||
}
|
||||
return headerStyle.Width(outerW).Render(titleStyle.Render("QUptime") + " " + helpStyle.Render(msg))
|
||||
}
|
||||
st := m.status
|
||||
quorum := stateDownStyle.Render("● no quorum")
|
||||
if st.HasQuorum {
|
||||
quorum = stateUpStyle.Render(fmt.Sprintf("● quorum %d/%d", liveCount(st.Peers), st.QuorumSize))
|
||||
}
|
||||
master := stateUnknownStyle.Render("master: —")
|
||||
if st.MasterID != "" {
|
||||
master = "master: " + shortID(st.MasterID)
|
||||
}
|
||||
role := ""
|
||||
if st.NodeID == st.MasterID {
|
||||
role = stateUpStyle.Render("(you are master)")
|
||||
} else {
|
||||
role = subtleStyle.Render("(follower)")
|
||||
}
|
||||
left := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
titleStyle.Render("QUptime"),
|
||||
" ",
|
||||
"node: "+shortID(st.NodeID),
|
||||
" ",
|
||||
master,
|
||||
" ",
|
||||
role,
|
||||
)
|
||||
right := lipgloss.JoinHorizontal(lipgloss.Top,
|
||||
quorum,
|
||||
" ",
|
||||
subtleStyle.Render(fmt.Sprintf("term %d ver %d", st.Term, st.Version)),
|
||||
)
|
||||
leftW := lipgloss.Width(left)
|
||||
rightW := lipgloss.Width(right)
|
||||
|
||||
// Single row when both halves fit with at least one space between them.
|
||||
if leftW+rightW+1 <= innerW {
|
||||
gap := innerW - leftW - rightW
|
||||
row := left + strings.Repeat(" ", gap) + right
|
||||
return headerStyle.Width(outerW).Render(row)
|
||||
}
|
||||
|
||||
// Otherwise stack vertically so nothing gets clipped on narrow terminals.
|
||||
rows := lipgloss.JoinVertical(lipgloss.Left, left, right)
|
||||
return headerStyle.Width(outerW).Render(rows)
|
||||
}
|
||||
|
||||
// headerHeight returns the actual number of terminal rows renderHeader
|
||||
// produces, including the rounded border. Used to compute the body area in
|
||||
// resizeTabs. We measure the rendered output rather than guess because the
|
||||
// header's content can line-wrap on very narrow terminals (e.g. the left
|
||||
// half being wider than the inner content area), which a width-based
|
||||
// heuristic can't see.
|
||||
func (m model) headerHeight() int {
|
||||
if m.width == 0 {
|
||||
return 3
|
||||
}
|
||||
return lipgloss.Height(m.renderHeader())
|
||||
}
|
||||
|
||||
func (m model) renderTabs() string {
|
||||
parts := make([]string, len(tabNames))
|
||||
for i, name := range tabNames {
|
||||
count := ""
|
||||
switch tabIndex(i) {
|
||||
case tabPeers:
|
||||
count = fmt.Sprintf(" (%d)", len(m.status.Peers))
|
||||
case tabChecks:
|
||||
count = fmt.Sprintf(" (%d)", len(m.status.Checks))
|
||||
case tabAlerts:
|
||||
count = fmt.Sprintf(" (%d)", len(m.alerts))
|
||||
}
|
||||
label := name + count
|
||||
if tabIndex(i) == m.active {
|
||||
parts[i] = tabActiveStyle.Render(label)
|
||||
} else {
|
||||
parts[i] = tabIdleStyle.Render(fmt.Sprintf("[%d] %s", i+1, label))
|
||||
}
|
||||
}
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, parts...)
|
||||
}
|
||||
|
||||
func (m model) renderActiveTab() string {
|
||||
var view string
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
view = m.peers.View()
|
||||
case tabChecks:
|
||||
view = m.checks.View()
|
||||
case tabAlerts:
|
||||
view = m.alertsT.View()
|
||||
}
|
||||
// Table columns can sum to more than the terminal width on narrow
|
||||
// terminals. Without this, bodyStyle.Width(...) would wrap each over-wide
|
||||
// row onto extra lines and push the page taller than m.height, clipping
|
||||
// the top of the TUI. Truncate per line so the bordered box stays the
|
||||
// exact bodyH rows we sized for.
|
||||
innerW := m.width - 4
|
||||
if innerW < 1 {
|
||||
innerW = 1
|
||||
}
|
||||
view = lipgloss.NewStyle().MaxWidth(innerW).Render(view)
|
||||
return bodyStyle.Width(m.width - 2).Render(view)
|
||||
}
|
||||
|
||||
func (m model) renderHelp() string {
|
||||
specific := ""
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
specific = "a add e edit d remove"
|
||||
case tabChecks:
|
||||
specific = "a add e edit d remove"
|
||||
case tabAlerts:
|
||||
specific = "a add e edit d remove t test D toggle default"
|
||||
}
|
||||
return helpStyle.Render(fmt.Sprintf("↑↓ navigate ⇥ next tab 1/2/3 jump r refresh %s q quit", specific))
|
||||
}
|
||||
|
||||
func (m model) renderFlash() string {
|
||||
if m.flash == "" || time.Now().After(m.flashUntil) {
|
||||
return ""
|
||||
}
|
||||
switch m.flashLevel {
|
||||
case flashError:
|
||||
return flashErrorStyle.Render(m.flash)
|
||||
case flashWarn:
|
||||
return flashWarnStyle.Render(m.flash)
|
||||
default:
|
||||
return flashInfoStyle.Render(m.flash)
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Actions.
|
||||
// =============================================================
|
||||
|
||||
func (m model) openAddPicker() modal {
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
return newAddNodeForm()
|
||||
case tabChecks:
|
||||
return newPicker("Add check — pick type", []pickerOption{
|
||||
{label: "HTTP", hint: "url + status code", choose: func() modal { return newAddCheckForm(config.CheckHTTP) }},
|
||||
{label: "TCP", hint: "host:port connect", choose: func() modal { return newAddCheckForm(config.CheckTCP) }},
|
||||
{label: "ICMP", hint: "ping a host", choose: func() modal { return newAddCheckForm(config.CheckICMP) }},
|
||||
})
|
||||
case tabAlerts:
|
||||
return newPicker("Add alert — pick type", []pickerOption{
|
||||
{label: "Discord", hint: "webhook URL", choose: func() modal { return newAddDiscordForm() }},
|
||||
{label: "SMTP", hint: "email via relay", choose: func() modal { return newAddSMTPForm() }},
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) {
|
||||
var prompt string
|
||||
var run func() tea.Cmd
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
id := m.peers.Selected()
|
||||
name := strings.TrimPrefix(m.peers.SelectedName(), "* ")
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
id = strings.TrimPrefix(id, "* ")
|
||||
prompt = fmt.Sprintf("Remove peer %s from the cluster?\nThis revokes trust and updates cluster.yaml.", shortID(name))
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
if _, err := callDaemon(ctx, daemon.CtrlNodeRemove, daemon.NodeRemoveBody{NodeID: id}); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed node " + shortID(id), level: flashInfo}
|
||||
}
|
||||
}
|
||||
case tabChecks:
|
||||
id := m.checks.Selected()
|
||||
name := m.checks.SelectedName()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
prompt = fmt.Sprintf("Remove check %q?", name)
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := mutateRemove(transport.MutationRemoveCheck, id); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed check " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
case tabAlerts:
|
||||
id := m.alertsT.Selected()
|
||||
name := m.alertsT.SelectedName()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
prompt = fmt.Sprintf("Remove alert %q?", name)
|
||||
run = func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if err := mutateRemove(transport.MutationRemoveAlert, id); err != nil {
|
||||
return formSubmitErr(err.Error())
|
||||
}
|
||||
return modalDone{flash: "removed alert " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
m.modal = newConfirm(prompt, run)
|
||||
m.seedModalSize()
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// seedModalSize forwards the current terminal dimensions to the modal
|
||||
// so its inputs can size themselves on first paint. Called whenever a
|
||||
// new modal is installed.
|
||||
func (m *model) seedModalSize() {
|
||||
if m.modal == nil || m.width == 0 {
|
||||
return
|
||||
}
|
||||
m.modal, _ = m.modal.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height})
|
||||
}
|
||||
|
||||
// openEditForm dispatches to the right pre-filled edit form based on the
|
||||
// active tab and the row under the cursor. Looks up the full record in
|
||||
// m.peersFull / m.checksFull / m.alerts (populated by loadConfigCmd) so
|
||||
// the form starts with the entry's current values rather than blanks.
|
||||
func (m model) openEditForm() (tea.Model, tea.Cmd) {
|
||||
switch m.active {
|
||||
case tabPeers:
|
||||
id := strings.TrimPrefix(m.peers.Selected(), "* ")
|
||||
if id == "" {
|
||||
m.setFlash("no peer selected", flashWarn)
|
||||
return m, nil
|
||||
}
|
||||
for i := range m.peersFull {
|
||||
if m.peersFull[i].NodeID == id {
|
||||
m.modal = newEditNodeForm(m.peersFull[i])
|
||||
m.seedModalSize()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.setFlash("peer not found in local cluster.yaml", flashError)
|
||||
return m, nil
|
||||
|
||||
case tabChecks:
|
||||
id := m.checks.Selected()
|
||||
if id == "" {
|
||||
m.setFlash("no check selected", flashWarn)
|
||||
return m, nil
|
||||
}
|
||||
for i := range m.checksFull {
|
||||
if m.checksFull[i].ID == id {
|
||||
m.modal = newEditCheckForm(m.checksFull[i])
|
||||
m.seedModalSize()
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
m.setFlash("check not found in local cluster.yaml", flashError)
|
||||
return m, nil
|
||||
|
||||
case tabAlerts:
|
||||
id := m.alertsT.Selected()
|
||||
if id == "" {
|
||||
m.setFlash("no alert selected", flashWarn)
|
||||
return m, nil
|
||||
}
|
||||
for i := range m.alerts {
|
||||
if m.alerts[i].ID != id {
|
||||
continue
|
||||
}
|
||||
switch m.alerts[i].Type {
|
||||
case config.AlertDiscord:
|
||||
m.modal = newEditDiscordForm(m.alerts[i])
|
||||
case config.AlertSMTP:
|
||||
m.modal = newEditSMTPForm(m.alerts[i])
|
||||
default:
|
||||
m.setFlash("unsupported alert type", flashError)
|
||||
return m, nil
|
||||
}
|
||||
m.seedModalSize()
|
||||
return m, nil
|
||||
}
|
||||
m.setFlash("alert not found in local cluster.yaml", flashError)
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m model) testSelectedAlert() (tea.Model, tea.Cmd) {
|
||||
id := m.alertsT.Selected()
|
||||
if id == "" {
|
||||
return m, nil
|
||||
}
|
||||
name := m.alertsT.SelectedName()
|
||||
m.setFlash("sending test to "+name+"…", flashInfo)
|
||||
return m, func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := callDaemon(ctx, daemon.CtrlAlertTest, daemon.AlertTestBody{AlertID: id}); err != nil {
|
||||
return modalDone{flash: "test failed: " + err.Error(), level: flashError}
|
||||
}
|
||||
return modalDone{flash: "test sent via " + name, level: flashInfo}
|
||||
}
|
||||
}
|
||||
|
||||
func (m model) toggleSelectedDefault() (tea.Model, tea.Cmd) {
|
||||
row := m.alertsT.SelectedAlert()
|
||||
if row == nil {
|
||||
return m, nil
|
||||
}
|
||||
var target *config.Alert
|
||||
for i := range m.alerts {
|
||||
if m.alerts[i].ID == row.ID {
|
||||
cp := m.alerts[i]
|
||||
target = &cp
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
m.setFlash("alert not found in local cluster.yaml", flashError)
|
||||
return m, nil
|
||||
}
|
||||
target.Default = !target.Default
|
||||
name := target.Name
|
||||
newState := target.Default
|
||||
return m, func() tea.Msg {
|
||||
if err := mutateAdd(transport.MutationAddAlert, target); err != nil {
|
||||
return modalDone{flash: "toggle failed: " + err.Error(), level: flashError}
|
||||
}
|
||||
state := "off"
|
||||
if newState {
|
||||
state = "on"
|
||||
}
|
||||
return modalDone{flash: fmt.Sprintf("alert %s default=%s", name, state), level: flashInfo}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================
|
||||
// Small helpers.
|
||||
// =============================================================
|
||||
|
||||
func (m *model) setFlash(s string, level flashLevel) {
|
||||
m.flash = s
|
||||
m.flashLevel = level
|
||||
m.flashUntil = time.Now().Add(4 * time.Second)
|
||||
}
|
||||
|
||||
func (m *model) resizeTabs() {
|
||||
// Rows consumed outside the body: header (variable), tabs (1),
|
||||
// body's own rounded border (2), flash (1), help (1).
|
||||
reserved := m.headerHeight() + 5
|
||||
bodyH := m.height - reserved
|
||||
if bodyH < 5 {
|
||||
bodyH = 5
|
||||
}
|
||||
bodyW := m.width - 4
|
||||
if bodyW < 20 {
|
||||
bodyW = 20
|
||||
}
|
||||
m.peers.SetSize(bodyW, bodyH)
|
||||
m.checks.SetSize(bodyW, bodyH)
|
||||
m.alertsT.SetSize(bodyW, bodyH)
|
||||
}
|
||||
|
||||
func toAlertRows(alerts []config.Alert) []alertRow {
|
||||
out := make([]alertRow, 0, len(alerts))
|
||||
for _, a := range alerts {
|
||||
endpoint := ""
|
||||
switch a.Type {
|
||||
case config.AlertDiscord:
|
||||
endpoint = a.DiscordWebhook
|
||||
case config.AlertSMTP:
|
||||
endpoint = fmt.Sprintf("%s:%d → %s", a.SMTPHost, a.SMTPPort, strings.Join(a.SMTPTo, ","))
|
||||
}
|
||||
out = append(out, alertRow{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Type: string(a.Type),
|
||||
Default: a.Default,
|
||||
HasTmpl: a.SubjectTemplate != "" || a.BodyTemplate != "",
|
||||
Endpoint: endpoint,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func liveCount(peers []transport.PeerLiveness) int {
|
||||
n := 0
|
||||
for _, p := range peers {
|
||||
if p.Live {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func shortID(id string) string {
|
||||
if len(id) <= 8 {
|
||||
return id
|
||||
}
|
||||
return id[:8]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user