27 Commits

Author SHA1 Message Date
Axodouble 55d966ba8f Fixed failed QEMU set up in container workflow
Container image / image (push) Failing after 1m49s
Release / release (push) Successful in 1m46s
2026-05-15 01:01:58 +00:00
Axodouble 74cb42ea28 Added workflow for docker containers
Container image / image (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-15 00:51:33 +00:00
Axodouble 2382aebc10 Added some examples of custom messages with GO's templating 2026-05-15 00:45:31 +00:00
Axodouble 9105cba380 Updated TUI field sizing 2026-05-15 00:40:01 +00:00
Axodouble a8f69cd7cc Added VerbLower to have lowercase verbs 2026-05-15 00:34:53 +00:00
Axodouble 1f1dd32741 Fixed issue with bash completions potentially crashing 2026-05-14 07:59:31 +00:00
Axodouble 231176ce41 I have spent more time on the installation script than I would've wanted to 2026-05-14 07:52:33 +00:00
Axodouble 5f7185e5b1 Updated shell assumptions 2026-05-14 07:48:12 +00:00
Axodouble a6283d9d43 Updated the installer to setup the service as qu, added some improvements to the installation 2026-05-14 07:41:49 +00:00
Axodouble 7a1ea39f78 Updated release workflow to drop cache and updated actual go version used for build for optimization
Release / release (push) Successful in 2m55s
2026-05-14 07:02:19 +00:00
Axodouble e8656b09a7 Fixed state being truncated in cell
Release / release (push) Has been cancelled
2026-05-14 06:56:34 +00:00
Axodouble 5c54a1cd91 Updated install script to add shell completions 2026-05-14 06:53:21 +00:00
Axodouble 7b45c8fcf0 Updated readme with the correct text formatting options
Release / release (push) Successful in 11m40s
2026-05-14 06:38:02 +00:00
Axodouble cbb311d877 Added helper variables and templating for custom messages 2026-05-14 06:26:00 +00:00
Axodouble d30dd5906a Updated exit conditions 2026-05-14 06:00:44 +00:00
Axodouble 40c0d9e5a0 Another attempt at fixing the autoinstall 2026-05-14 05:47:27 +00:00
Axodouble d1913c4278 Added correct build name in script 2026-05-14 05:45:26 +00:00
Axodouble eedd86e571 Updated subshell for release tag 2026-05-14 05:44:35 +00:00
Axodouble c07079497b Added check for used commands 2026-05-14 05:43:42 +00:00
Axodouble 4cfd7159bf Updated install scripts 2026-05-14 05:42:14 +00:00
Axodouble 624d8d8e44 Added the ability to edit entries in the CLI & TUI
Release / release (push) Successful in 12m26s
2026-05-14 05:18:23 +00:00
Axodouble ce5c089413 Fixed issue on 48x91 terminal size causing top two lines to disappear 2026-05-14 05:02:06 +00:00
Axodouble 2d192f3a32 Added better compatibility for the TUI in smaller terminals 2026-05-14 03:43:54 +00:00
Axodouble 481839d348 Update README.md 2026-05-14 01:10:58 +00:00
Axodouble 1b14a3ed33 Added full bubbletea TUI
Release / release (push) Has been cancelled
2026-05-14 01:09:45 +00:00
Axodouble d6f65c58f6 Added custom messages for uptime alerts 2026-05-14 00:55:09 +00:00
Axodouble 6d7c0ce58b Updated readme 2026-05-14 00:31:02 +00:00
23 changed files with 3154 additions and 66 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
.claude
.github
dist
*.md
install.sh
+84
View File
@@ -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
+3 -3
View File
@@ -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
View File
@@ -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"]
+239 -3
View File
@@ -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)
```
+27 -4
View File
@@ -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
)
+56 -2
View File
@@ -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=
+78 -20
View File
@@ -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
fi
}
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."
+25 -5
View File
@@ -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,15 +47,32 @@ 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)
}
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),
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)
}
+45
View File
@@ -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}}`
}
+82 -7
View File
@@ -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:
+58
View File
@@ -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)
}
}
+216 -21
View File
@@ -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,26 +311,33 @@ 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,
SMTPHost: host,
SMTPPort: port,
SMTPUser: user,
SMTPPassword: password,
SMTPFrom: from,
SMTPTo: to,
SMTPStartTLS: startTLS,
ID: uuid.NewString(),
Name: args[0],
Type: config.AlertSMTP,
Default: makeDefault,
SubjectTemplate: subj,
BodyTemplate: body,
SMTPHost: host,
SMTPPort: port,
SMTPUser: user,
SMTPPassword: password,
SMTPFrom: from,
SMTPTo: to,
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,
DiscordWebhook: webhook,
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
View File
@@ -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,
+1
View File
@@ -25,5 +25,6 @@ func NewRootCommand(version string) *cobra.Command {
addAlertCmd(root)
addTrustCmd(root)
addStatusCmd(root)
addTUICmd(root)
return root
}
+69
View File
@@ -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 {
+21
View File
@@ -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)
}
+10
View File
@@ -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
+87
View File
@@ -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
}
+815
View File
@@ -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
}
+81
View File
@@ -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")
}
+289
View File
@@ -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] + "…"
}
+709
View File
@@ -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]
}