3 Commits

Author SHA1 Message Date
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
Axodouble b46c258e4e Added github workflows next to gitea's workflows
Container image / image (push) Successful in 1m43s
Release / release (push) Successful in 1m45s
2026-05-15 05:16:26 +00:00
9 changed files with 462 additions and 102 deletions
+72
View File
@@ -0,0 +1,72 @@
name: Container image
# Mirrors .gitea/workflows/container.yaml — publishes a multi-arch
# (amd64 + arm64) image to the GitHub Container Registry whenever the
# Gitea→GitHub mirror pushes a `v*` tag. Image lands at
# ghcr.io/axodouble/quptime with tags :vX.Y.Z, :X.Y, and :latest.
on:
push:
tags:
- 'v*'
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
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
# GHCR namespaces must be lowercase. Lowercase the repository
# path once and reuse below so a mixed-case org/repo (e.g.
# Axodouble/QUptime) still resolves to a valid image reference.
- name: Resolve image name
id: img
run: |
repo='${{ github.repository }}'
echo "ref=ghcr.io/${repo,,}" >> "$GITHUB_OUTPUT"
- name: Compute version
id: ver
run: |
echo "version=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
- name: Login to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
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
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: ./docker/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ steps.ver.outputs.version }}
cache-from: type=gha
cache-to: type=gha,mode=max
+60
View File
@@ -0,0 +1,60 @@
name: Release
# Mirrors .gitea/workflows/release.yaml — fires when the Gitea→GitHub
# mirror pushes a `v*` tag, builds static Linux binaries for amd64 +
# arm64, and publishes them to GitHub Releases alongside the Gitea
# release the same tag produces upstream.
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
check-latest: false
cache: false
- name: Test
run: go test -race ./...
- name: Build binaries
env:
CGO_ENABLED: '0'
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME}"
mkdir -p dist
for arch in amd64 arm64; do
out="dist/qu-${VERSION}-linux-${arch}"
echo "building ${out}"
GOOS=linux GOARCH="${arch}" \
go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o "${out}" \
./cmd/qu
done
(cd dist && sha256sum qu-* > SHA256SUMS)
ls -lh dist
- name: Publish release
uses: softprops/action-gh-release@v2
with:
files: |
dist/qu-*
dist/SHA256SUMS
fail_on_unmatched_files: true
generate_release_notes: true
token: ${{ secrets.GITHUB_TOKEN }}
+6 -3
View File
@@ -44,10 +44,13 @@ Initial public release.
`quptime` user, `ProtectSystem=strict`, all capabilities dropped by `quptime` user, `ProtectSystem=strict`, all capabilities dropped by
default. default.
- **Multi-arch Docker images** (`linux/amd64`, `linux/arm64`) - **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 - **Static Linux binaries** (`amd64`, `arm64`) published per tag with
a `SHA256SUMS` file; the official installer verifies the checksum a `SHA256SUMS` file to both Gitea Releases (primary) and GitHub
before placing the binary on disk. Releases (mirror). The official installer prefers Gitea, falls back
to GitHub on failure, and verifies the checksum before placing the
binary on disk.
### Security ### Security
+27 -2
View File
@@ -14,12 +14,37 @@ trust — no central CA, no shared secret.
### From pre-built binary ### From pre-built binary
This can be done in one step, either by downloading the latest release from The canonical home is Gitea; the repo is push-mirrored to GitHub on
the [Gitea releases page](https://git.cer.sh/axodouble/quptime/releases) or by running the following script: 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 ```sh
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash 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 ## Why
Most uptime monitors are either a SaaS or a single box that, by 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 restart: unless-stopped
quptime: quptime:
image: git.cer.sh/axodouble/quptime:master image: git.cer.sh/axodouble/quptime:latest
container_name: quptime container_name: quptime
environment: environment:
# host:port other QUptime nodes use to reach this one. Use the # 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 ## 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:master # tip of main, multi-arch
git.cer.sh/axodouble/quptime:latest # latest tagged release 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) 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 — The image embeds `QUPTIME_DIR=/etc/quptime` and declares it a volume —
treat it as the only piece of state worth persisting. 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) ## Pre-built binary (recommended)
Releases are published to the [Gitea releases Every tag triggers identical builds on both sources, so either one
page](https://git.cer.sh/axodouble/quptime/releases) with a serves the same artefact set. Gitea is the canonical home; GitHub is a
`SHA256SUMS` file. Two architectures are built: `linux-amd64` and push-mirror.
`linux-arm64`.
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 ```sh
# Always pin to a tag — `latest` resolves on the server side. # Always pin to a tag — `latest` resolves on the server side.
TAG=v0.1.0 TAG=v0.0.1
ARCH=amd64 # or arm64 ARCH=amd64 # or arm64
# Primary: Gitea
curl -fSL -o qu \ curl -fSL -o qu \
"https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH}" "https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/qu-${TAG}-linux-${ARCH}"
curl -fSL -o SHA256SUMS \ curl -fSL -o SHA256SUMS \
"https://git.cer.sh/axodouble/quptime/releases/download/${TAG}/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 sha256sum --check --ignore-missing SHA256SUMS
install -m 0755 qu /usr/local/bin/qu install -m 0755 qu /usr/local/bin/qu
@@ -34,31 +48,37 @@ install -m 0755 qu /usr/local/bin/qu
## One-line install script ## One-line install script
The repo ships an `install.sh` that handles the download, checksum, 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 under `sudo` so it can write to `/usr/local/bin` and
`/etc/systemd/system`. `/etc/systemd/system`.
```sh ```sh
curl -fsSL https://git.cer.sh/Axodouble/QUptime/raw/branch/master/install.sh | sudo bash 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: What it does:
1. Looks up the latest release via the Gitea API. 1. Looks up the latest release via the Gitea API; falls back to the
2. Downloads the binary to `/usr/local/bin/qu`. 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. 3. Installs bash / zsh / fish completion if a target directory exists.
4. Writes `/etc/systemd/system/qu-serve.service` and enables it (but 4. Creates a dedicated `quptime` system user and writes
does **not** start it — you need to run `qu init` first). `/etc/systemd/system/quptime.service` (hardened — matches the unit
in [systemd.md](deployment/systemd.md)). Enables but does not start
The unit it writes is minimal. For a production unit with hardening, the service, so you can configure identity before first boot.
see the [systemd deployment guide](deployment/systemd.md).
## Build from source ## Build from source
Requires Go 1.24.2 or newer. Requires Go 1.24.2 or newer.
```sh ```sh
# Either remote — Gitea is canonical, GitHub is a push-mirror.
git clone https://git.cer.sh/axodouble/quptime.git git clone https://git.cer.sh/axodouble/quptime.git
# git clone https://github.com/Axodouble/QUptime.git
cd quptime cd quptime
go build -ldflags "-X main.version=$(git describe --tags --always)" -o qu ./cmd/qu 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 ## Docker image
A multi-arch (`amd64` + `arm64`) image is published to the Gitea The same multi-arch (`amd64` + `arm64`) image is published to two
registry on every tag and every push to `master`: 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:latest # latest tagged release
git.cer.sh/axodouble/quptime:v0.0.1 # pinned 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 See the [Docker deployment guide](deployment/docker.md) for compose
files and volume layout. files and volume layout.
+92 -40
View File
@@ -1,12 +1,17 @@
#!/bin/bash #!/bin/bash
# QUptime installer. # QUptime installer.
# #
# Downloads the latest released `qu` binary from the Gitea release # Downloads the latest released `qu` binary, verifies it against the
# page, verifies it against the published SHA256SUMS, installs it to # published SHA256SUMS, installs it to /usr/local/bin, and (on systemd
# /usr/local/bin, and (on systemd hosts) drops in a hardened # hosts) drops in a hardened quptime.service that matches the unit
# quptime.service that matches the unit documented in # documented in docs/deployment/systemd.md.
# docs/deployment/systemd.md. Idempotent — re-running upgrades the #
# binary and refreshes the unit without touching the data directory. # 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 set -euo pipefail
INSTALL_BIN="/usr/local/bin/qu" INSTALL_BIN="/usr/local/bin/qu"
@@ -15,8 +20,15 @@ SERVICE_NAME="$(basename "$SERVICE_FILE")"
SERVICE_USER="quptime" SERVICE_USER="quptime"
SERVICE_GROUP="quptime" SERVICE_GROUP="quptime"
DATA_DIR="/etc/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() { fail() {
echo "Error: $*" >&2 echo "Error: $*" >&2
@@ -38,6 +50,51 @@ write_completion() {
return 1 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 curl
require_command jq require_command jq
require_command sha256sum 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." fail "Cannot write to $(dirname "$INSTALL_BIN"). Run this script with sudo, or set INSTALL_BIN to a writable location."
fi fi
# --- latest release tag ------------------------------------------------- # --- download + verify (with fallback) ----------------------------------
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.
TMPDIR=$(mktemp -d) TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT trap 'rm -rf "$TMPDIR"' EXIT
echo "> downloading $BINARY_NAME" # Globals filled in by fetch_from_source on success.
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/$BINARY_NAME" "$BINARY_URL" RELEASE=""
echo "> downloading SHA256SUMS" BINARY_NAME=""
curl -fsSL --proto '=https' --tlsv1.2 -o "$TMPDIR/SHA256SUMS" "$SUMS_URL" INSTALLED_FROM=""
INSTALLED_TMP=""
echo "> verifying checksum" for source_spec in "${SOURCES[@]}"; do
# Pull just our binary's entry so sha256sum -c doesn't fail on the IFS='|' read -r src_name src_api src_base <<<"$source_spec"
# arches we didn't download. src_tmp="$TMPDIR/$src_name"
( mkdir -p "$src_tmp"
cd "$TMPDIR" echo "> trying release source: $src_name"
if ! grep -E "[[:space:]]\\*?${BINARY_NAME}\$" SHA256SUMS > expected.sum; then # `set -e` would abort the whole script the moment fetch_from_source
fail "no entry for $BINARY_NAME in published SHA256SUMS — refusing to install" # 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 fi
if ! sha256sum -c expected.sum >/dev/null 2>&1; then echo "> $src_name: unavailable"
echo "expected: $(awk '{print $1}' expected.sum)" done
echo "actual: $(sha256sum "$BINARY_NAME" | awk '{print $1}')"
fail "checksum mismatch for $BINARY_NAME — refusing to install"
fi
)
echo "> checksum OK"
install -m 0755 "$TMPDIR/$BINARY_NAME" "$INSTALL_BIN" if [ -z "$INSTALLED_FROM" ]; then
echo "> qu ${RELEASE} installed to $INSTALL_BIN" 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 -------------------------------------------------- # --- shell completions --------------------------------------------------
if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then if "$INSTALL_BIN" --help 2>/dev/null | grep -q "completion"; then
+127 -25
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/textinput" "github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -53,10 +54,45 @@ func modalDoneCmd(flash string, level flashLevel) tea.Cmd {
type formField struct { type formField struct {
label string label string
input textinput.Model input textinput.Model
textarea textarea.Model
multiline bool
required bool required bool
hint string 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 { type form struct {
title string title string
fields []formField fields []formField
@@ -86,12 +122,14 @@ func fieldWidthFor(termWidth int) int {
func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form { func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form {
for i := range fields { for i := range fields {
if !fields[i].multiline {
fields[i].input.Prompt = "" fields[i].input.Prompt = ""
fields[i].input.CharLimit = 256 fields[i].input.CharLimit = 256
}
if i == 0 { if i == 0 {
fields[i].input.Focus() fields[i].focus()
} else { } else {
fields[i].input.Blur() fields[i].blur()
} }
} }
return &form{title: title, fields: fields, submit: submit} 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} 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 { func passwordField(label, hint string) formField {
return passwordFieldWithValue(label, hint, "") return passwordFieldWithValue(label, hint, "")
} }
@@ -146,7 +209,11 @@ func (f *form) View() string {
labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true) labelStyle = lipgloss.NewStyle().Foreground(colorAccent).Bold(true)
} }
fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label)) fmt.Fprintf(&b, "%s%s\n", marker, labelStyle.Render(fld.label))
if fld.multiline {
fmt.Fprintf(&b, "%s\n", fld.textarea.View())
} else {
fmt.Fprintf(&b, " %s\n", fld.input.View()) fmt.Fprintf(&b, " %s\n", fld.input.View())
}
if i == f.cursor && fld.hint != "" { if i == f.cursor && fld.hint != "" {
fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint)) fmt.Fprintf(&b, " %s\n", helpStyle.Render(fld.hint))
} }
@@ -158,7 +225,11 @@ func (f *form) View() string {
if f.busy { if f.busy {
fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…")) fmt.Fprintf(&b, "%s\n", flashWarnStyle.Render("working…"))
} else { } 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() return b.String()
} }
@@ -169,7 +240,7 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
f.width = msg.Width f.width = msg.Width
w := fieldWidthFor(msg.Width) w := fieldWidthFor(msg.Width)
for i := range f.fields { for i := range f.fields {
f.fields[i].input.Width = w f.fields[i].setWidth(w)
} }
return f, nil return f, nil
@@ -179,43 +250,74 @@ func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
return f, nil return f, nil
case tea.KeyMsg: 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": case "esc":
return f, modalDoneCmd("", flashInfo) return f, modalDoneCmd("", flashInfo)
case "tab", "down": case "tab":
f.advance(1) f.advance(1)
return f, nil return f, nil
case "shift+tab", "up": case "shift+tab":
f.advance(-1) f.advance(-1)
return f, nil return f, nil
case "enter": case "down":
if f.busy { if !multiline {
f.advance(1)
return f, nil return f, nil
} }
case "up":
if !multiline {
f.advance(-1)
return f, nil
}
case "enter":
if !multiline {
return f, f.submitOrAdvance()
}
case "shift+enter", "ctrl+s":
return f, f.submitOrAdvance()
}
}
var cmd tea.Cmd
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 { if f.cursor < len(f.fields)-1 {
f.advance(1) f.advance(1)
return f, nil return nil
} }
vals := make([]string, len(f.fields)) vals := make([]string, len(f.fields))
for i, fld := range f.fields { for i := range f.fields {
vals[i] = fld.input.Value() vals[i] = f.fields[i].value()
} }
for i, fld := range f.fields { for i, fld := range f.fields {
if fld.required && strings.TrimSpace(vals[i]) == "" { if fld.required && strings.TrimSpace(vals[i]) == "" {
f.err = fld.label + " is required" f.err = fld.label + " is required"
f.cursor = i f.cursor = i
f.focusOnly(i) f.focusOnly(i)
return f, nil return nil
} }
} }
f.busy = true f.busy = true
f.err = "" f.err = ""
return f, f.submit(vals) return 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) { func (f *form) advance(delta int) {
@@ -230,9 +332,9 @@ func (f *form) advance(delta int) {
func (f *form) focusOnly(i int) { func (f *form) focusOnly(i int) {
for j := range f.fields { for j := range f.fields {
if j == i { if j == i {
f.fields[j].input.Focus() f.fields[j].focus()
} else { } else {
f.fields[j].input.Blur() f.fields[j].blur()
} }
} }
} }
@@ -294,7 +396,7 @@ func newAddDiscordForm() *form {
textField("Name", "human-friendly identifier", true), textField("Name", "human-friendly identifier", true),
textField("Webhook URL", "https://discord.com/api/webhooks/...", true), textField("Webhook URL", "https://discord.com/api/webhooks/...", true),
textField("Default", "yes/no — attach to every check automatically", false), 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 newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -326,7 +428,7 @@ func newAddSMTPForm() *form {
textField("StartTLS", "yes/no — default yes", false), textField("StartTLS", "yes/no — default yes", false),
textField("Default", "yes/no — attach to every check", false), textField("Default", "yes/no — attach to every check", false),
textField("Subject template", alerts.TemplateVarsHint(), 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 newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
@@ -467,7 +569,7 @@ func newEditDiscordForm(existing config.Alert) *form {
textFieldWithValue("Name", "human-friendly identifier", existing.Name, true), textFieldWithValue("Name", "human-friendly identifier", existing.Name, true),
textFieldWithValue("Webhook URL", "https://discord.com/api/webhooks/...", existing.DiscordWebhook, 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("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 id := existing.ID
subject := existing.SubjectTemplate subject := existing.SubjectTemplate
@@ -506,7 +608,7 @@ func newEditSMTPForm(existing config.Alert) *form {
textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false), textFieldWithValue("StartTLS", "yes/no — default yes", boolStr(existing.SMTPStartTLS), false),
textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false), textFieldWithValue("Default", "yes/no — attach to every check", boolStr(existing.Default), false),
textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, 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 id := existing.ID
return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd { return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd {