24 Commits

Author SHA1 Message Date
Axodouble b029c0a25d Added example compose for a tailscale deployment
Container image / image (push) Successful in 3m36s
Release / release (push) Successful in 4m7s
2026-05-15 02:01:01 +00:00
Axodouble 3453bf5ec7 Updated action to use a pat due to failure otherwise, fixed cache issue
Container image / image (push) Successful in 3m17s
2026-05-15 01:44:39 +00:00
Axodouble acd55d145c Fixed incorrect shell causing a broken substitution
Container image / image (push) Failing after 9m41s
2026-05-15 01:19:21 +00:00
Axodouble ebbbd8c218 Updated when workflows run and fixed issue with the duplicate mount
Container image / image (push) Failing after 10m21s
2026-05-15 01:11:27 +00:00
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
14 changed files with 536 additions and 71 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
.claude
.github
dist
*.md
install.sh
+122
View File
@@ -0,0 +1,122 @@
name: Container image
# Three modes, all driven by the same job:
# - Tag push (v*) → full release: :v1.2.3, :1.2, :latest, :sha-<short>
# - Branch push → canary: :<branch>, :sha-<short>
# - Pull request → smoke test: build only, nothing pushed
#
# metadata-action emits the right subset of tags for each event based
# on the `tags:` rules below — no manual branching needed.
on:
push:
branches:
- "**"
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. Override to an
# act-compatible image that ships docker + buildx. The runner
# already bind-mounts /var/run/docker.sock into every job
# container, so we do NOT add a `volumes:` entry — doing so
# produces a duplicate-mount error from the daemon.
container:
image: catthehacker/ubuntu:act-latest
# aether-runner defaults `run:` blocks to POSIX `sh`, which
# chokes on bash-isms like ${var,,} (lowercase) and ${var:0:7}
# (substring). Pin bash for the whole job.
defaults:
run:
shell: bash
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
with:
# Skip the GHA-cache lookup for the binfmt image. The Gitea
# runner has no GHA cache server, so the action would
# otherwise sit in a ~5-minute TCP timeout before falling
# back to a direct docker pull. Going straight to pull
# cuts QEMU setup from ~5 min to ~15 s.
cache-image: false
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
# Registries want lowercase namespaces, and Gitea's container
# registry is case-sensitive on the login username too. Lowercase
# both repo path and actor once here and reuse below.
- name: Resolve image name
id: img
run: |
repo='${{ github.repository }}'
actor='${{ github.actor }}'
echo "ref=git.cer.sh/${repo,,}" >> "$GITHUB_OUTPUT"
echo "user=${actor,,}" >> "$GITHUB_OUTPUT"
# Version stamp baked into the binary via -ldflags. Tag pushes
# use the tag name directly; everything else gets a short SHA
# suffix so `qu version` on a canary build is debuggable.
- name: Compute version
id: ver
run: |
if [[ "$GITHUB_REF" == refs/tags/* ]]; then
v="${GITHUB_REF_NAME}"
else
v="${GITHUB_REF_NAME}-${GITHUB_SHA:0:7}"
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
# Prefers a user-provided PAT (repo secret REGISTRY_TOKEN with
# `write:package` scope) and falls back to the auto-injected
# runner token. The auto-token works on Gitea >= 1.21 when the
# workflow declares `packages: write` in permissions, but if
# the registry still rejects it (older instance, container
# registry gated by config, etc.), REGISTRY_TOKEN takes over
# without any workflow edits.
- name: Login to Gitea registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: git.cer.sh
username: ${{ steps.img.outputs.user }}
password: ${{ secrets.REGISTRY_TOKEN || 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') }}
type=ref,event=branch
type=sha,prefix=sha-,format=short
- name: Build (and push on push events)
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.ver.outputs.version }}
# 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 ./...
+118 -26
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
@@ -210,17 +220,17 @@ every two seconds.
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 |
| 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
@@ -247,27 +257,109 @@ qu alert add smtp ops --host ... --from ... --to ... \
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` |
| `{{.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 |
| 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
+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"]
+37
View File
@@ -0,0 +1,37 @@
# An example of a docker compose with Tailscale & QUptime.
# This setup is specifically intended for hosts that may not be able to reach each other directly or have a public IP address.
services:
tailscale:
image: tailscale/tailscale:latest
container_name: tailscale
cap_add:
- NET_ADMIN
environment:
- TS_AUTHKEY=${TAILSCALE_AUTHKEY} # Set this in your .env file with a Tailscale auth key
- TS_HOSTNAME=quptime-tailscale
volumes:
- /dev/net/tun:/dev/net/tun
- tailscale:/var/lib/tailscale
restart: unless-stopped
quptime:
image: git.cer.sh/axodouble/quptime:master
container_name: quptime
volumes:
- quptime:/etc/quptime
ports:
- "9901:9901"
depends_on:
- tailscale
# No restart directive, user needs to init quptime first
# Run `docker compose -f docker-compose-tailscale.yml run --rm quptime init` to initialize
# the data volume before starting the service
# If this is not the master node, use
# `docker compose -f docker-compose-tailscale.yml run --rm quptime --advertise <TAILSCALE_IP>:9901 --secret <SECRET>`
# And add the individual nodes to the cluster with `docker compose -f docker-compose-tailscale.yml run --rm quptime node add <OTHER_NODE_IP>:9901`
network_mode: "service:tailscale" # Use the Tailscale network stack
volumes:
tailscale:
quptime:
+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."
+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}}`
}
+10 -7
View File
@@ -22,6 +22,7 @@ type TemplateContext struct {
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
@@ -88,14 +89,16 @@ func RenderFor(alert *config.Alert, nodeID string, check *config.Check, from, to
}
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: transitionVerb(from, to),
Snapshot: snap,
NodeID: nodeID,
When: time.Now().UTC().Format(time.RFC3339),
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),
}
}
+8 -3
View File
@@ -11,6 +11,7 @@ import (
"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"
@@ -21,9 +22,9 @@ import (
// 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 (text/template syntax — SMTP only)")
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 (text/template syntax)")
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")
}
@@ -172,7 +173,9 @@ func buildAlertEditCmd() *cobra.Command {
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.`,
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)
@@ -308,6 +311,7 @@ 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)
@@ -365,6 +369,7 @@ 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)
+1 -1
View File
@@ -91,7 +91,7 @@ type Alert struct {
// 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}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}},
// {{.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"`
+34 -8
View File
@@ -12,6 +12,7 @@ import (
"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"
@@ -62,10 +63,27 @@ type form struct {
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 = ""
@@ -88,7 +106,7 @@ func textField(label, hint string, required bool) formField {
// contents and can tweak instead of retyping everything.
func textFieldWithValue(label, hint, value string, required bool) formField {
ti := textinput.New()
ti.Width = 40
ti.Width = defaultFieldWidth
ti.Placeholder = hint
if value != "" {
ti.SetValue(value)
@@ -105,7 +123,7 @@ func passwordField(label, hint string) formField {
// the actual value leaking on-screen.
func passwordFieldWithValue(label, hint, value string) formField {
ti := textinput.New()
ti.Width = 40
ti.Width = defaultFieldWidth
ti.Placeholder = hint
ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•'
@@ -147,6 +165,14 @@ func (f *form) View() 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)
@@ -272,7 +298,7 @@ func newAddDiscordForm() *form {
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", "leave empty for default formatting", false),
textField("Body template", alerts.TemplateVarsHint(), false),
}
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
@@ -303,8 +329,8 @@ func newAddSMTPForm() *form {
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", "optional", false),
textField("Body template", "optional", 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 {
@@ -445,7 +471,7 @@ func newEditDiscordForm(existing config.Alert) *form {
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", "leave empty for default formatting", existing.BodyTemplate, false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
subject := existing.SubjectTemplate
@@ -483,8 +509,8 @@ func newEditSMTPForm(existing config.Alert) *form {
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", "optional", existing.SubjectTemplate, false),
textFieldWithValue("Body template", "optional", existing.BodyTemplate, 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 {
+7 -3
View File
@@ -58,14 +58,18 @@ var (
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 stateUpStyle.Render("● up")
return "● up"
case "down":
return stateDownStyle.Render("● down")
return "● down"
default:
return stateUnknownStyle.Render("○ unknown")
return "○ unknown"
}
}
+25
View File
@@ -132,6 +132,9 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
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:
@@ -175,8 +178,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 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
}
@@ -223,6 +233,7 @@ func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(loadStatusCmd(), loadConfigCmd())
case "a":
m.modal = m.openAddPicker()
m.seedModalSize()
return m, nil
case "d":
return m.openRemoveConfirm()
@@ -501,9 +512,20 @@ func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) {
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
@@ -519,6 +541,7 @@ func (m model) openEditForm() (tea.Model, tea.Cmd) {
for i := range m.peersFull {
if m.peersFull[i].NodeID == id {
m.modal = newEditNodeForm(m.peersFull[i])
m.seedModalSize()
return m, nil
}
}
@@ -534,6 +557,7 @@ func (m model) openEditForm() (tea.Model, tea.Cmd) {
for i := range m.checksFull {
if m.checksFull[i].ID == id {
m.modal = newEditCheckForm(m.checksFull[i])
m.seedModalSize()
return m, nil
}
}
@@ -559,6 +583,7 @@ func (m model) openEditForm() (tea.Model, tea.Cmd) {
m.setFlash("unsupported alert type", flashError)
return m, nil
}
m.seedModalSize()
return m, nil
}
m.setFlash("alert not found in local cluster.yaml", flashError)