5 Commits

Author SHA1 Message Date
Axodouble 3c85caabcf Fix Previously up services are alerted as going back up if the master goes down #1
Container image / image (push) Successful in 1m45s
Release / release (push) Successful in 1m44s
This gets rid of the alert on unknown -> up, will still alert unknown -> down by design.
2026-05-15 07:01:29 +00:00
Axodouble 8638ab5432 Updated formatting for discord messages
Container image / image (push) Successful in 1m45s
2026-05-15 06:55:43 +00:00
Axodouble a11b31f160 Updated changelog
Container image / image (push) Successful in 1m39s
2026-05-15 06:44:18 +00:00
Axodouble 005be12dd1 Updated the custom message area to be a text area instead for better text editing
Container image / image (push) Successful in 1m36s
Release / release (push) Successful in 1m41s
2026-05-15 06:41:10 +00:00
Axodouble e48da30240 Added documentation and installer support for the github secondary mirror
Container image / image (push) Successful in 1m42s
2026-05-15 05:32:03 +00:00
10 changed files with 395 additions and 104 deletions
+12 -3
View File
@@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [v0.0.2] — 2026-05-15
### Fixed
- Text template field in the TUI did not support newlines, causing multi-line templates to render as a single line and losing formatting. This has been fixed by changing the field into a textarea and escaping the `enter` key to insert newlines.
## [v0.0.1] — 2026-05-15
Initial public release.
@@ -44,10 +50,13 @@ Initial public release.
`quptime` user, `ProtectSystem=strict`, all capabilities dropped by
default.
- **Multi-arch Docker images** (`linux/amd64`, `linux/arm64`)
published to `git.cer.sh/axodouble/quptime`.
published to `git.cer.sh/axodouble/quptime` (primary) and
`ghcr.io/axodouble/quptime` (GitHub push-mirror) on every tag.
- **Static Linux binaries** (`amd64`, `arm64`) published per tag with
a `SHA256SUMS` file; the official installer verifies the checksum
before placing the binary on disk.
a `SHA256SUMS` file to both Gitea Releases (primary) and GitHub
Releases (mirror). The official installer prefers Gitea, falls back
to GitHub on failure, and verifies the checksum before placing the
binary on disk.
### Security
+27 -2
View File
@@ -14,12 +14,37 @@ trust — no central CA, no shared secret.
### 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:
The canonical home is Gitea; the repo is push-mirrored to GitHub on
every tag. Releases and multi-arch container images are published to
both.
| Source | Releases | Container image |
| ---------------- | ------------------------------------------------------------ | -------------------------------- |
| Gitea (primary) | <https://git.cer.sh/axodouble/quptime/releases> | `git.cer.sh/axodouble/quptime` |
| GitHub (mirror) | <https://github.com/Axodouble/QUptime/releases> | `ghcr.io/axodouble/quptime` |
One-step install — tries Gitea first, falls back to GitHub automatically:
```sh
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash
# or, via the GitHub mirror:
# curl -fsSL https://raw.githubusercontent.com/Axodouble/QUptime/master/install.sh | sudo bash
```
The script verifies the binary against the published `SHA256SUMS`
before installing and refuses to proceed on a mismatch.
### From Docker
```sh
docker pull git.cer.sh/axodouble/quptime:latest
# or, via the GitHub mirror:
# docker pull ghcr.io/axodouble/quptime:latest
```
See [docs/deployment/docker.md](docs/deployment/docker.md) for compose
recipes.
## Why
Most uptime monitors are either a SaaS or a single box that, by
+1 -1
View File
@@ -25,7 +25,7 @@ services:
restart: unless-stopped
quptime:
image: git.cer.sh/axodouble/quptime:master
image: git.cer.sh/axodouble/quptime:latest
container_name: quptime
environment:
# host:port other QUptime nodes use to reach this one. Use the
+15
View File
@@ -7,6 +7,13 @@ daemon can bind privileged ports and open ICMP sockets; override with
## Image references
The same multi-arch (amd64 + arm64) image is published to two
registries. **The Gitea registry is the canonical source** — it also
publishes canary `:master` builds on every branch push. GHCR is a
tag-only push-mirror for users who can't reach `git.cer.sh`.
Primary — Gitea registry:
```
git.cer.sh/axodouble/quptime:master # tip of main, multi-arch
git.cer.sh/axodouble/quptime:latest # latest tagged release
@@ -14,6 +21,14 @@ git.cer.sh/axodouble/quptime:v0.0.1 # specific tagged release
git.cer.sh/axodouble/quptime:latest-amd64 # single-arch (if you must pin)
```
Fallback — GitHub Container Registry:
```
ghcr.io/axodouble/quptime:latest # latest tagged release
ghcr.io/axodouble/quptime:v0.0.1 # specific tagged release
ghcr.io/axodouble/quptime:0.0 # latest patch in the 0.0 minor line
```
The image embeds `QUPTIME_DIR=/etc/quptime` and declares it a volume —
treat it as the only piece of state worth persisting.
+48 -17
View File
@@ -10,22 +10,36 @@ matches how you manage software on the host.
## Pre-built binary (recommended)
Releases are published to the [Gitea releases
page](https://git.cer.sh/axodouble/quptime/releases) with a
`SHA256SUMS` file. Two architectures are built: `linux-amd64` and
`linux-arm64`.
Every tag triggers identical builds on both sources, so either one
serves the same artefact set. Gitea is the canonical home; GitHub is a
push-mirror.
Primary — Gitea releases:
<https://git.cer.sh/axodouble/quptime/releases>
Fallback — GitHub releases (mirrored from the same tag):
<https://github.com/Axodouble/QUptime/releases>
Each release ships `qu-${TAG}-linux-amd64`, `qu-${TAG}-linux-arm64`,
and a `SHA256SUMS` file.
```sh
# Always pin to a tag — `latest` resolves on the server side.
TAG=v0.1.0
TAG=v0.0.1
ARCH=amd64 # or arm64
# Primary: Gitea
curl -fSL -o qu \
"https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH}"
curl -fSL -o SHA256SUMS \
"https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/SHA256SUMS"
# Verify before installing.
# (or the GitHub mirror — substitute the host below if Gitea is unreachable)
# https://github.com/Axodouble/QUptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH}
# https://github.com/Axodouble/QUptime/releases/download/${TAG}/SHA256SUMS
# Verify before installing. Use the SHA256SUMS from the SAME source
# as the binary — never mix.
sha256sum --check --ignore-missing SHA256SUMS
install -m 0755 qu /usr/local/bin/qu
@@ -34,31 +48,37 @@ install -m 0755 qu /usr/local/bin/qu
## One-line install script
The repo ships an `install.sh` that handles the download, checksum,
shell-completion installation, and a default systemd unit file. Run it
shell-completion installation, and a hardened systemd unit. Run it
under `sudo` so it can write to `/usr/local/bin` and
`/etc/systemd/system`.
```sh
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash
# or, via the GitHub mirror:
# curl -fsSL https://raw.githubusercontent.com/Axodouble/QUptime/master/install.sh | sudo bash
```
What it does:
1. Looks up the latest release via the Gitea API.
2. Downloads the binary to `/usr/local/bin/qu`.
1. Looks up the latest release via the Gitea API; falls back to the
GitHub API if Gitea is unreachable.
2. Downloads the per-arch binary and the matching `SHA256SUMS` from
the same source, then verifies the checksum. Refuses to install on
a mismatch.
3. Installs bash / zsh / fish completion if a target directory exists.
4. Writes `/etc/systemd/system/qu-serve.service` and enables it (but
does **not** start it — you need to run `qu init` first).
The unit it writes is minimal. For a production unit with hardening,
see the [systemd deployment guide](deployment/systemd.md).
4. Creates a dedicated `quptime` system user and writes
`/etc/systemd/system/quptime.service` (hardened — matches the unit
in [systemd.md](deployment/systemd.md)). Enables but does not start
the service, so you can configure identity before first boot.
## Build from source
Requires Go 1.24.2 or newer.
```sh
# Either remote — Gitea is canonical, GitHub is a push-mirror.
git clone https://git.cer.sh/axodouble/quptime.git
# git clone https://github.com/Axodouble/QUptime.git
cd quptime
go build -ldflags "-X main.version=$(git describe --tags --always)" -o qu ./cmd/qu
@@ -74,15 +94,26 @@ CGO_ENABLED=0 go build -trimpath -ldflags "-s -w" -o qu ./cmd/qu
## Docker image
A multi-arch (`amd64` + `arm64`) image is published to the Gitea
registry on every tag and every push to `master`:
The same multi-arch (`amd64` + `arm64`) image is published to two
registries on every tag. The Gitea registry is the canonical source
and also gets canary `:master` builds; GHCR is a tag-only mirror.
Primary — Gitea registry:
```
git.cer.sh/axodouble/quptime:master # tip of main
git.cer.sh/axodouble/quptime:master # tip of main (canary)
git.cer.sh/axodouble/quptime:latest # latest tagged release
git.cer.sh/axodouble/quptime:v0.0.1 # pinned release
```
Fallback — GitHub Container Registry:
```
ghcr.io/axodouble/quptime:latest # latest tagged release
ghcr.io/axodouble/quptime:v0.0.1 # pinned release
ghcr.io/axodouble/quptime:0.0 # latest 0.0.x
```
See the [Docker deployment guide](deployment/docker.md) for compose
files and volume layout.
+92 -40
View File
@@ -1,12 +1,17 @@
#!/bin/bash
# QUptime installer.
#
# Downloads the latest released `qu` binary from the Gitea release
# page, verifies it against the published SHA256SUMS, installs it to
# /usr/local/bin, and (on systemd hosts) drops in a hardened
# quptime.service that matches the unit documented in
# docs/deployment/systemd.md. Idempotent — re-running upgrades the
# binary and refreshes the unit without touching the data directory.
# Downloads the latest released `qu` binary, verifies it against the
# published SHA256SUMS, installs it to /usr/local/bin, and (on systemd
# hosts) drops in a hardened quptime.service that matches the unit
# documented in docs/deployment/systemd.md.
#
# Release sources, tried in order:
# 1. Gitea: git.cer.sh/axodouble/quptime/releases (primary — canonical home)
# 2. GitHub: github.com/Axodouble/QUptime/releases (push-mirror fallback)
#
# Idempotent — re-running upgrades the binary and refreshes the unit
# without touching the data directory.
set -euo pipefail
INSTALL_BIN="/usr/local/bin/qu"
@@ -15,8 +20,15 @@ SERVICE_NAME="$(basename "$SERVICE_FILE")"
SERVICE_USER="quptime"
SERVICE_GROUP="quptime"
DATA_DIR="/etc/quptime"
REPO_API="https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest"
RELEASE_BASE="https://git.cer.sh/axodouble/quptime/releases/download"
# Release sources, in preference order. Each row is:
# <name>|<latest-release API endpoint>|<release-asset base URL>
# The asset URL is concatenated with `/<tag>/<filename>`. Adjust here
# if the project moves hosts.
SOURCES=(
"gitea|https://git.cer.sh/api/v1/repos/axodouble/quptime/releases/latest|https://git.cer.sh/axodouble/quptime/releases/download"
"github|https://api.github.com/repos/Axodouble/QUptime/releases/latest|https://github.com/Axodouble/QUptime/releases/download"
)
fail() {
echo "Error: $*" >&2
@@ -38,6 +50,51 @@ write_completion() {
return 1
}
# fetch_from_source tries one release source end-to-end: pulls the
# latest tag from its API, downloads the per-arch binary and the
# accompanying SHA256SUMS, and verifies the checksum. Returns 0 on
# success (with RELEASE and BINARY_NAME set as globals) or 1 if any
# step fails — callers can then try the next source. Stderr is kept
# quiet so a failed primary doesn't spam the operator before the
# fallback is attempted.
fetch_from_source() {
local api_url=$1
local release_base=$2
local tmpdir=$3
local release
release=$(curl -fsSL --proto '=https' --tlsv1.2 "$api_url" 2>/dev/null | jq -r '.tag_name' 2>/dev/null) \
|| return 1
[ -n "$release" ] && [ "$release" != "null" ] || return 1
local binary_name="qu-${release}-linux-${ARCH}"
local binary_url="${release_base}/${release}/${binary_name}"
local sums_url="${release_base}/${release}/SHA256SUMS"
curl -fsSL --proto '=https' --tlsv1.2 -o "$tmpdir/$binary_name" "$binary_url" 2>/dev/null \
|| return 1
curl -fsSL --proto '=https' --tlsv1.2 -o "$tmpdir/SHA256SUMS" "$sums_url" 2>/dev/null \
|| return 1
# Verify against the SHA256SUMS that came from the same source as
# the binary. Never mix sources here — verifying a GitHub-hosted
# binary against a Gitea-hosted SHA256SUMS would defeat the
# tamper check.
(
cd "$tmpdir"
if ! grep -E "[[:space:]]\\*?${binary_name}\$" SHA256SUMS > expected.sum; then
exit 1
fi
if ! sha256sum -c expected.sum >/dev/null 2>&1; then
exit 1
fi
) || return 1
RELEASE="$release"
BINARY_NAME="$binary_name"
return 0
}
require_command curl
require_command jq
require_command sha256sum
@@ -55,44 +112,39 @@ if [ ! -w "$(dirname "$INSTALL_BIN")" ]; then
fail "Cannot write to $(dirname "$INSTALL_BIN"). Run this script with sudo, or set INSTALL_BIN to a writable location."
fi
# --- latest release tag -------------------------------------------------
RELEASE=$(curl -fsSL "$REPO_API" | jq -r '.tag_name')
[ -n "$RELEASE" ] && [ "$RELEASE" != "null" ] \
|| fail "could not determine the latest release tag from $REPO_API"
BINARY_NAME="qu-${RELEASE}-linux-${ARCH}"
BINARY_URL="${RELEASE_BASE}/${RELEASE}/${BINARY_NAME}"
SUMS_URL="${RELEASE_BASE}/${RELEASE}/SHA256SUMS"
# --- download + verify --------------------------------------------------
# Stage in a temp dir so a failed verification never leaves a partial
# or unverified binary on disk.
# --- download + verify (with fallback) ----------------------------------
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
echo "> downloading $BINARY_NAME"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/$BINARY_NAME" "$BINARY_URL"
echo "> downloading SHA256SUMS"
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/SHA256SUMS" "$SUMS_URL"
# Globals filled in by fetch_from_source on success.
RELEASE=""
BINARY_NAME=""
INSTALLED_FROM=""
INSTALLED_TMP=""
echo "> verifying checksum"
# Pull just our binary's entry so sha256sum -c doesn't fail on the
# arches we didn't download.
(
cd "$TMPDIR"
if ! grep -E "[[:space:]]\\*?${BINARY_NAME}\$" SHA256SUMS > expected.sum; then
fail "no entry for $BINARY_NAME in published SHA256SUMS — refusing to install"
for source_spec in "${SOURCES[@]}"; do
IFS='|' read -r src_name src_api src_base <<<"$source_spec"
src_tmp="$TMPDIR/$src_name"
mkdir -p "$src_tmp"
echo "> trying release source: $src_name"
# `set -e` would abort the whole script the moment fetch_from_source
# returns nonzero; we want the loop to fall through to the next
# source instead. Wrap the call so a failure is just data.
if fetch_from_source "$src_api" "$src_base" "$src_tmp"; then
INSTALLED_FROM="$src_name"
INSTALLED_TMP="$src_tmp"
echo "> $src_name: ${RELEASE} ✓ checksum OK"
break
fi
if ! sha256sum -c expected.sum >/dev/null 2>&1; then
echo "expected: $(awk '{print $1}' expected.sum)"
echo "actual: $(sha256sum "$BINARY_NAME" | awk '{print $1}')"
fail "checksum mismatch for $BINARY_NAME — refusing to install"
fi
)
echo "> checksum OK"
echo "> $src_name: unavailable"
done
install -m 0755 "$TMPDIR/$BINARY_NAME" "$INSTALL_BIN"
echo "> qu ${RELEASE} installed to $INSTALL_BIN"
if [ -z "$INSTALLED_FROM" ]; then
fail "no release source reachable — tried: $(printf '%s ' "${SOURCES[@]%%|*}"). Check network access to git.cer.sh and github.com."
fi
install -m 0755 "$INSTALLED_TMP/$BINARY_NAME" "$INSTALL_BIN"
echo "> qu ${RELEASE} installed to $INSTALL_BIN (source: $INSTALLED_FROM)"
# --- shell completions --------------------------------------------------
if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then
+9 -1
View File
@@ -25,12 +25,20 @@ type discordPayload struct {
}
// sendDiscord posts msg.Subject + body to the configured webhook URL.
// When the alert has a custom BodyTemplate, the rendered body is shipped
// verbatim — the operator has opted out of the default subject header
// and code-block wrapping in favour of their own formatting.
func sendDiscord(a *config.Alert, msg Message) error {
if a.DiscordWebhook == "" {
return errors.New("discord webhook url not set")
}
content := msg.Subject + "\n```\n" + msg.Body + "\n```"
var content string
if a.BodyTemplate != "" {
content = msg.Body
} else {
content = msg.Subject + "\n```\n" + msg.Body + "\n```"
}
raw, err := json.Marshal(discordPayload{Content: content})
if err != nil {
return err
+20 -1
View File
@@ -27,7 +27,7 @@ func New(cluster *config.ClusterConfig, selfID string, logger *log.Logger) *Disp
// OnTransition is wired as checks.TransitionFn.
func (d *Dispatcher) OnTransition(check *config.Check, from, to checks.State, snap checks.Snapshot) {
if to == checks.StateUnknown {
if !shouldAlert(from, to) {
return
}
alerts := d.cluster.EffectiveAlertsFor(check)
@@ -77,6 +77,25 @@ func (d *Dispatcher) Test(alertID string) error {
return d.dispatchOne(alert, msg)
}
// shouldAlert decides whether a committed state transition warrants
// firing the configured alert channels.
//
// A fresh master's aggregator starts every check at StateUnknown, so
// the first successful evaluation always commits Unknown→Up. Without
// filtering, every master failover (or daemon restart) would spam an
// "is now UP" alert for every healthy check. We treat Unknown→Up as a
// silent cold start; real recoveries (Down→Up) and any transition to
// Down still alert.
func shouldAlert(from, to checks.State) bool {
if to == checks.StateUnknown {
return false
}
if from == checks.StateUnknown && to == checks.StateUp {
return false
}
return true
}
func (d *Dispatcher) dispatchOne(a *config.Alert, msg Message) error {
switch a.Type {
case config.AlertSMTP:
+30
View File
@@ -0,0 +1,30 @@
package alerts
import (
"testing"
"git.cer.sh/axodouble/quptime/internal/checks"
)
func TestShouldAlertFiltersColdStartUp(t *testing.T) {
cases := []struct {
name string
from checks.State
to checks.State
want bool
}{
{"cold start to up (master failover / daemon restart)", checks.StateUnknown, checks.StateUp, false},
{"cold start to down still alerts", checks.StateUnknown, checks.StateDown, true},
{"real recovery alerts", checks.StateDown, checks.StateUp, true},
{"regression alerts", checks.StateUp, checks.StateDown, true},
{"stale (up to unknown) suppressed", checks.StateUp, checks.StateUnknown, false},
{"stale (down to unknown) suppressed", checks.StateDown, checks.StateUnknown, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := shouldAlert(c.from, c.to); got != c.want {
t.Errorf("shouldAlert(%s→%s) = %v, want %v", c.from, c.to, got, c.want)
}
})
}
}
+141 -39
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
@@ -51,10 +52,45 @@ func modalDoneCmd(flash string, level flashLevel) tea.Cmd {
// =============================================================
type formField struct {
label string
input textinput.Model
required bool
hint string
label string
input textinput.Model
textarea textarea.Model
multiline bool
required bool
hint string
}
// value returns the field's current text regardless of whether it's
// backed by a single-line input or a multiline textarea.
func (fld *formField) value() string {
if fld.multiline {
return fld.textarea.Value()
}
return fld.input.Value()
}
func (fld *formField) focus() {
if fld.multiline {
fld.textarea.Focus()
return
}
fld.input.Focus()
}
func (fld *formField) blur() {
if fld.multiline {
fld.textarea.Blur()
return
}
fld.input.Blur()
}
func (fld *formField) setWidth(w int) {
if fld.multiline {
fld.textarea.SetWidth(w)
return
}
fld.input.Width = w
}
type form struct {
@@ -86,12 +122,14 @@ func fieldWidthFor(termWidth int) int {
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 !fields[i].multiline {
fields[i].input.Prompt = ""
fields[i].input.CharLimit = 256
}
if i == 0 {
fields[i].input.Focus()
fields[i].focus()
} else {
fields[i].input.Blur()
fields[i].blur()
}
}
return &form{title: title, fields: fields, submit: submit}
@@ -114,6 +152,31 @@ func textFieldWithValue(label, hint, value string, required bool) formField {
return formField{label: label, hint: hint, required: required, input: ti}
}
// textAreaField creates a multiline field. Enter inserts a newline;
// the form uses shift+enter / ctrl+s to submit when the cursor is on
// one of these. Useful for things like alert body templates where the
// rendered message naturally spans multiple lines.
func textAreaField(label, hint string, required bool) formField {
return textAreaFieldWithValue(label, hint, "", required)
}
func textAreaFieldWithValue(label, hint, value string, required bool) formField {
ta := textarea.New()
ta.Placeholder = hint
ta.ShowLineNumbers = false
ta.Prompt = " "
ta.SetHeight(5)
ta.SetWidth(defaultFieldWidth)
ta.CharLimit = 0
// Keep enter bound to "insert newline" (the textarea default) — the
// surrounding form intercepts enter on single-line fields and handles
// shift+enter/ctrl+s as the submit/advance trigger for multiline ones.
if value != "" {
ta.SetValue(value)
}
return formField{label: label, hint: hint, required: required, multiline: true, textarea: ta}
}
func passwordField(label, hint string) formField {
return passwordFieldWithValue(label, hint, "")
}
@@ -146,7 +209,11 @@ func (f *form) View() string {
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 fld.multiline {
fmt.Fprintf(&b, "%s\n", fld.textarea.View())
} else {
fmt.Fprintf(&b, " %s\n", fld.input.View())
}
if i == f.cursor && fld.hint != "" {
fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint))
}
@@ -158,7 +225,11 @@ func (f *form) View() string {
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"))
help := "↑↓ field enter next/submit esc cancel"
if f.cursor < len(f.fields) && f.fields[f.cursor].multiline {
help = "tab field enter newline shift+enter/ctrl+s submit esc cancel"
}
fmt.Fprintf(&b, "%s\n", helpStyle.Render(help))
}
return b.String()
}
@@ -169,7 +240,7 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
f.width = msg.Width
w := fieldWidthFor(msg.Width)
for i := range f.fields {
f.fields[i].input.Width = w
f.fields[i].setWidth(w)
}
return f, nil
@@ -179,45 +250,76 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
return f, nil
case tea.KeyMsg:
switch msg.String() {
key := msg.String()
// up/down on a multiline field belong to in-text navigation;
// leave field-switching to tab/shift+tab there. Same for enter:
// the textarea owns it as "insert newline", so submission moves
// to shift+enter / ctrl+s.
multiline := f.cursor < len(f.fields) && f.fields[f.cursor].multiline
switch key {
case "esc":
return f, modalDoneCmd("", flashInfo)
case "tab", "down":
case "tab":
f.advance(1)
return f, nil
case "shift+tab", "up":
case "shift+tab":
f.advance(-1)
return f, nil
case "enter":
if f.busy {
return f, nil
}
if f.cursor < len(f.fields)-1 {
case "down":
if !multiline {
f.advance(1)
return f, nil
}
vals := make([]string, len(f.fields))
for i, fld := range f.fields {
vals[i] = fld.input.Value()
case "up":
if !multiline {
f.advance(-1)
return f, nil
}
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
}
case "enter":
if !multiline {
return f, f.submitOrAdvance()
}
f.busy = true
f.err = ""
return f, f.submit(vals)
case "shift+enter", "ctrl+s":
return f, f.submitOrAdvance()
}
}
var cmd tea.Cmd
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg)
if f.fields[f.cursor].multiline {
f.fields[f.cursor].textarea, cmd = f.fields[f.cursor].textarea.Update(msg)
} else {
f.fields[f.cursor].input, cmd = f.fields[f.cursor].input.Update(msg)
}
return f, cmd
}
// submitOrAdvance is the shared trigger for enter on single-line fields
// and shift+enter / ctrl+s on multiline fields: jump to the next field
// or, on the last one, validate and run submit.
func (f *form) submitOrAdvance() tea.Cmd {
if f.busy {
return nil
}
if f.cursor < len(f.fields)-1 {
f.advance(1)
return nil
}
vals := make([]string, len(f.fields))
for i := range f.fields {
vals[i] = f.fields[i].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 nil
}
}
f.busy = true
f.err = ""
return f.submit(vals)
}
func (f *form) advance(delta int) {
n := len(f.fields)
if n == 0 {
@@ -230,9 +332,9 @@ func (f *form) advance(delta int) {
func (f *form) focusOnly(i int) {
for j := range f.fields {
if j == i {
f.fields[j].input.Focus()
f.fields[j].focus()
} else {
f.fields[j].input.Blur()
f.fields[j].blur()
}
}
}
@@ -294,7 +396,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", alerts.TemplateVarsHint(), false),
textAreaField("Body template", alerts.TemplateVarsHint(), false),
}
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
@@ -326,7 +428,7 @@ func newAddSMTPForm() *form {
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),
textAreaField("Body template", alerts.TemplateVarsHint(), false),
}
return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
@@ -467,7 +569,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", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
textAreaFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
subject := existing.SubjectTemplate
@@ -506,7 +608,7 @@ func newEditSMTPForm(existing config.Alert) *form {
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),
textAreaFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd {