9 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
10 changed files with 333 additions and 67 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
+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"]
+76
View File
@@ -266,6 +266,7 @@ Available template variables:
| `{{.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 |
@@ -284,6 +285,81 @@ or Body template field in the add/edit alert forms.
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
+60 -56
View File
@@ -1,78 +1,82 @@
#!/bin/bash
set -euo pipefail
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)"
fail() {
echo "Error: $*" >&2
exit 1
}
# Helper function which echo's all commands before executing them in grayscale prefixed with >
echo_cmd() {
echo -e "\033[90m> $1\033[0m"
eval "$1"
}
# Check if jq and curl are installed, if not, error out and ask the user to install them
if ! command -v jq > /dev/null; then
echo "Error: jq is not installed. Please install jq and try again."
exit 1
fi
if ! command -v curl > /dev/null; then
echo "Error: curl is not installed. Please install curl and try again."
exit 1
fi
require_command() {
command -v "$1" > /dev/null 2>&1 || fail "$1 is not installed. Please install $1 and try again."
}
# Check if the user is allowed to write to /usr/local/bin, if so, install qu there, else error out and ask the user to install qu manually
if [ -w "/usr/local/bin" ]; then
# Get release tag by $(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name')
RELEASE=$(curl -s https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest | jq -r '.tag_name')
# Download the latest release binary from the Git repository and save it to /usr/local/bin/qu
if command -v curl > /dev/null; then
echo_cmd "curl -L -o \"/usr/local/bin/qu\" \"https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64\""
echo_cmd "chmod +x \"/usr/local/bin/qu\""
echo "> qu has been installed to /usr/local/bin/qu"
# Drop completions into the directories each shell already scans.
# No rc-file edits, and uninstall is just `rm`. Silently skips
# shells whose completion dir is absent.
write_completion() {
local shell=$1 path=$2
[ -d "$(dirname "$path")" ] || return 1
if /usr/local/bin/qu completion "$shell" > "$path" 2>/dev/null; then
echo "> installed $shell completion -> $path"
return 0
fi
rm -f "$path"
return 1
}
write_completion bash /usr/share/bash-completion/completions/qu \
|| write_completion bash /etc/bash_completion.d/qu
write_completion zsh /usr/share/zsh/site-functions/_qu
write_completion fish /usr/share/fish/vendor_completions.d/qu.fish
else
echo "Error: curl is not installed. Please install curl and try again."
exit 1
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
else
echo "Error: You are not allowed to write to /usr/local/bin. Please install qu manually, or run this script with sudo."
exit 1
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
# Check if the user has systemd, if so create a systemd service file for qu serve
if command -v systemctl > /dev/null; then
echo "> Creating systemd service file for qu serve..."
cat <<EOL > /etc/systemd/system/qu-serve.service
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=/usr/local/bin/qu serve
ExecStart=$INSTALL_BIN serve
Restart=always
User=$(whoami)
User=$SERVICE_USER
Group=$SERVICE_GROUP
[Install]
WantedBy=multi-user.target
EOL
echo_cmd "systemctl daemon-reload"
echo_cmd "systemctl enable qu-serve.service"
echo "> qu serve service has been created and enabled. You can start it with 'systemctl start qu-serve.service'"
else
echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot. You can start it manually with '/usr/local/bin/qu serve'"
fi
echo "Installation complete, before starting `qu serve`, make sure to run `qu init` and read the documentation."
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."
+2 -1
View File
@@ -9,7 +9,7 @@ package alerts
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), {{.From}}, {{.To}}, {{.NodeID}}, {{.When}},\n" +
" {{.Verb}} (UP|DOWN|RECOVERED), {{.VerbLower}}, {{.From}}, {{.To}}, {{.NodeID}}, {{.When}},\n" +
" {{.Snapshot.Detail}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}}, {{.Snapshot.NotOK}}"
}
@@ -29,6 +29,7 @@ Available variables:
{{.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
+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),
}
}
+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"`
+27 -2
View File
@@ -63,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 = ""
@@ -89,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)
@@ -106,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 = '•'
@@ -148,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)
+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)