12 Commits

Author SHA1 Message Date
Axodouble 55d966ba8f Fixed failed QEMU set up in container workflow
Container image / image (push) Failing after 1m49s
Release / release (push) Successful in 1m46s
2026-05-15 01:01:58 +00:00
Axodouble 74cb42ea28 Added workflow for docker containers
Container image / image (push) Has been cancelled
Release / release (push) Has been cancelled
2026-05-15 00:51:33 +00:00
Axodouble 2382aebc10 Added some examples of custom messages with GO's templating 2026-05-15 00:45:31 +00:00
Axodouble 9105cba380 Updated TUI field sizing 2026-05-15 00:40:01 +00:00
Axodouble a8f69cd7cc Added VerbLower to have lowercase verbs 2026-05-15 00:34:53 +00:00
Axodouble 1f1dd32741 Fixed issue with bash completions potentially crashing 2026-05-14 07:59:31 +00:00
Axodouble 231176ce41 I have spent more time on the installation script than I would've wanted to 2026-05-14 07:52:33 +00:00
Axodouble 5f7185e5b1 Updated shell assumptions 2026-05-14 07:48:12 +00:00
Axodouble a6283d9d43 Updated the installer to setup the service as qu, added some improvements to the installation 2026-05-14 07:41:49 +00:00
Axodouble 7a1ea39f78 Updated release workflow to drop cache and updated actual go version used for build for optimization
Release / release (push) Successful in 2m55s
2026-05-14 07:02:19 +00:00
Axodouble e8656b09a7 Fixed state being truncated in cell
Release / release (push) Has been cancelled
2026-05-14 06:56:34 +00:00
Axodouble 5c54a1cd91 Updated install script to add shell completions 2026-05-14 06:53:21 +00:00
12 changed files with 343 additions and 55 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitea
.claude
.github
dist
*.md
install.sh
+84
View File
@@ -0,0 +1,84 @@
name: Container image
# Builds the multi-arch container image. On tag push (v*) it logs in
# to the Gitea registry on this host and publishes the image as
# git.cer.sh/<owner>/<repo>:<version> plus :latest. On pull requests
# it builds without pushing — purely a smoke test that the Dockerfile
# still works.
on:
push:
tags:
- 'v*'
pull_request:
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
# The default `ubuntu-latest` label on aether-runner maps to
# `node:16-bullseye`, which has no docker CLI — so the docker/*
# actions fail. Override the job container to catthehacker's
# act-compatible image (ships docker CLI + buildx) and mount the
# host's docker socket through. The runner already has the socket
# bind-mounted from the host (see docker.yml gitea-runner volume),
# so this exposes that same daemon to the nested job container.
container:
image: catthehacker/ubuntu:act-latest
volumes:
- /var/run/docker.sock:/var/run/docker.sock
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Buildx
uses: docker/setup-buildx-action@v3
# github.repository is owner/name with the repo's original casing;
# registries require lowercase, so normalise once here and reuse
# the result in metadata-action below.
- name: Resolve image name
id: img
run: |
repo='${{ github.repository }}'
echo "ref=git.cer.sh/${repo,,}" >> "$GITHUB_OUTPUT"
- name: Login to Gitea registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: git.cer.sh
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.img.outputs.ref }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build (and push on tag)
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
VERSION=${{ github.ref_name }}
# Inline cache embeds layer metadata into the pushed image
# itself — no external cache server needed, which keeps the
# workflow self-contained on the Gitea runner.
cache-from: type=inline
cache-to: type=inline
+3 -3
View File
@@ -22,9 +22,9 @@ jobs:
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version: '1.23' go-version: '1.24'
check-latest: true check-latest: false
cache: true cache: false
- name: Test - name: Test
run: go test -race ./... run: go test -race ./...
+41
View File
@@ -0,0 +1,41 @@
# syntax=docker/dockerfile:1.7
# Build stage. Runs on the runner's native arch (BUILDPLATFORM) and
# cross-compiles the Go binary for whichever target the manifest list
# is being assembled for (TARGETOS/TARGETARCH). Keeps multi-arch
# builds fast — only the final link is per-arch, the Go toolchain is
# always native.
FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
ARG TARGETOS
ARG TARGETARCH
ARG VERSION=dev
WORKDIR /src
# Module cache layer — re-uses unless go.mod/go.sum change.
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
go build \
-trimpath \
-ldflags "-s -w -X main.version=${VERSION}" \
-o /out/qu \
./cmd/qu
# Runtime stage. distroless/static has CA roots for HTTPS probes and
# nothing else — no shell, no package manager. Runs as root so the
# daemon can open ICMP sockets and write under /etc/quptime; operators
# can override at deploy time with `docker run --user`.
FROM gcr.io/distroless/static-debian12:latest
COPY --from=builder /out/qu /usr/local/bin/qu
ENV QUPTIME_DIR=/etc/quptime
VOLUME ["/etc/quptime"]
EXPOSE 9901
ENTRYPOINT ["/usr/local/bin/qu"]
CMD ["serve"]
+76
View File
@@ -266,6 +266,7 @@ Available template variables:
| `{{.From}}` | previous state (`up` / `down` / `unknown`) | | `{{.From}}` | previous state (`up` / `down` / `unknown`) |
| `{{.To}}` | new state | | `{{.To}}` | new state |
| `{{.Verb}}` | `UP` / `DOWN` / `RECOVERED` | | `{{.Verb}}` | `UP` / `DOWN` / `RECOVERED` |
| `{{.VerbLower}}` | lowercase form (`up` / `down` / `recovered`) |
| `{{.Snapshot.Reports}}` | total per-node reports counted | | `{{.Snapshot.Reports}}` | total per-node reports counted |
| `{{.Snapshot.OKCount}}` | how many reported OK | | `{{.Snapshot.OKCount}}` | how many reported OK |
| `{{.Snapshot.NotOK}}` | how many reported failure | | `{{.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 production traffic depends on it. A template parse or execution error
falls back to the built-in format and is logged. 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 ## Edit cluster.yaml directly
Anything you can do through the CLI you can also do by editing Anything you can do through the CLI you can also do by editing
+56 -34
View File
@@ -1,60 +1,82 @@
#!/bin/bash #!/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_cmd() {
echo -e "\033[90m> $1\033[0m" echo -e "\033[90m> $1\033[0m"
eval "$1" eval "$1"
} }
# Check if jq and curl are installed, if not, error out and ask the user to install them require_command() {
if ! command -v jq > /dev/null; then command -v "$1" > /dev/null 2>&1 || fail "$1 is not installed. Please install $1 and try again."
echo "Error: jq is not installed. Please install jq 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 fi
if ! command -v curl > /dev/null; then rm -f "$path"
echo "Error: curl is not installed. Please install curl and try again." return 1
exit 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 fi
# 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') 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 '$INSTALL_BIN' 'https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64'"
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 '$INSTALL_BIN'"
echo_cmd "chmod +x \"/usr/local/bin/qu\"" echo "> qu has been installed to $INSTALL_BIN"
echo "> qu has been installed to /usr/local/bin/qu"
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 else
echo "Error: curl is not installed. Please install curl and try again." echo "> qu does not expose completion support; skipping shell completion installation."
exit 1 fi
fi
else if ! command -v systemctl > /dev/null 2>&1; then
echo "Error: You are not allowed to write to /usr/local/bin. Please install qu manually, or run this script with sudo." echo "> Warning: systemd is not available on this system. qu serve will not be automatically started on boot."
exit 1 echo "Installation complete, before starting qu serve, make sure to run qu init and read the documentation."
exit 0
fi 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..." echo "> Creating systemd service file for qu serve..."
cat <<EOL > /etc/systemd/system/qu-serve.service cat > "$SERVICE_FILE" <<EOL
[Unit] [Unit]
Description=QUptime Serve Description=QUptime Serve
After=network.target After=network.target
[Service] [Service]
ExecStart=/usr/local/bin/qu serve ExecStart=$INSTALL_BIN serve
Restart=always Restart=always
User=$(whoami) User=$SERVICE_USER
Group=$SERVICE_GROUP
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
EOL 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 { func TemplateVarsHint() string {
return "Go text/template — leave empty to use the built-in format.\n" + return "Go text/template — leave empty to use the built-in format.\n" +
" Vars: {{.Check.Name}}, {{.Check.Target}}, {{.Check.Type}}, {{.Check.ID}},\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}}" " {{.Snapshot.Detail}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}}, {{.Snapshot.NotOK}}"
} }
@@ -29,6 +29,7 @@ Available variables:
{{.Check.Type}} http | tcp | icmp {{.Check.Type}} http | tcp | icmp
{{.Check.ID}} stable check UUID {{.Check.ID}} stable check UUID
{{.Verb}} UP | DOWN | RECOVERED {{.Verb}} UP | DOWN | RECOVERED
{{.VerbLower}} lowercase form of Verb (up | down | recovered)
{{.From}} previous state name {{.From}} previous state name
{{.To}} new state name {{.To}} new state name
{{.NodeID}} master node that rendered the message {{.NodeID}} master node that rendered the message
+4 -1
View File
@@ -22,6 +22,7 @@ type TemplateContext struct {
From string // previous state name From string // previous state name
To string // new state name To string // new state name
Verb string // "UP" | "DOWN" | "RECOVERED" Verb string // "UP" | "DOWN" | "RECOVERED"
VerbLower string // lowercase form of Verb ("up" | "down" | "recovered")
Snapshot checks.Snapshot // aggregate counts and detail Snapshot checks.Snapshot // aggregate counts and detail
NodeID string // master that rendered the message NodeID string // master that rendered the message
When string // RFC3339 timestamp When string // RFC3339 timestamp
@@ -88,11 +89,13 @@ 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 { func newContext(nodeID string, check *config.Check, from, to checks.State, snap checks.Snapshot) TemplateContext {
verb := transitionVerb(from, to)
return TemplateContext{ return TemplateContext{
Check: check, Check: check,
From: string(from), From: string(from),
To: string(to), To: string(to),
Verb: transitionVerb(from, to), Verb: verb,
VerbLower: strings.ToLower(verb),
Snapshot: snap, Snapshot: snap,
NodeID: nodeID, NodeID: nodeID,
When: time.Now().UTC().Format(time.RFC3339), 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); // format. Discord ignores SubjectTemplate (it has no subject line);
// SMTP uses both. Available variables: {{.Check.Name}}, // SMTP uses both. Available variables: {{.Check.Name}},
// {{.Check.Type}}, {{.Check.Target}}, {{.Check.ID}}, {{.From}}, // {{.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}}. // {{.Snapshot.NotOK}}, {{.Snapshot.Detail}}, {{.NodeID}}, {{.When}}.
SubjectTemplate string `yaml:"subject_template,omitempty"` SubjectTemplate string `yaml:"subject_template,omitempty"`
BodyTemplate string `yaml:"body_template,omitempty"` BodyTemplate string `yaml:"body_template,omitempty"`
+27 -2
View File
@@ -63,10 +63,27 @@ type form struct {
cursor int cursor int
busy bool busy bool
err string err string
width int // current terminal width; inputs resize to fill it
submit func(values []string) tea.Cmd 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 { func newForm(title string, fields []formField, submit func([]string) tea.Cmd) *form {
for i := range fields { for i := range fields {
fields[i].input.Prompt = "" fields[i].input.Prompt = ""
@@ -89,7 +106,7 @@ func textField(label, hint string, required bool) formField {
// contents and can tweak instead of retyping everything. // contents and can tweak instead of retyping everything.
func textFieldWithValue(label, hint, value string, required bool) formField { func textFieldWithValue(label, hint, value string, required bool) formField {
ti := textinput.New() ti := textinput.New()
ti.Width = 40 ti.Width = defaultFieldWidth
ti.Placeholder = hint ti.Placeholder = hint
if value != "" { if value != "" {
ti.SetValue(value) ti.SetValue(value)
@@ -106,7 +123,7 @@ func passwordField(label, hint string) formField {
// the actual value leaking on-screen. // the actual value leaking on-screen.
func passwordFieldWithValue(label, hint, value string) formField { func passwordFieldWithValue(label, hint, value string) formField {
ti := textinput.New() ti := textinput.New()
ti.Width = 40 ti.Width = defaultFieldWidth
ti.Placeholder = hint ti.Placeholder = hint
ti.EchoMode = textinput.EchoPassword ti.EchoMode = textinput.EchoPassword
ti.EchoCharacter = '•' ti.EchoCharacter = '•'
@@ -148,6 +165,14 @@ func (f *form) View() string {
func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) { func (f *form) Update(msg tea.Msg) (modal, tea.Cmd) {
switch msg := msg.(type) { 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: case formSubmitErr:
f.busy = false f.busy = false
f.err = string(msg) f.err = string(msg)
+7 -3
View File
@@ -58,14 +58,18 @@ var (
stateUnknownStyle = lipgloss.NewStyle().Foreground(colorMuted) 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 { func renderState(s string) string {
switch s { switch s {
case "up": case "up":
return stateUpStyle.Render("● up") return "● up"
case "down": case "down":
return stateDownStyle.Render("● down") return "● down"
default: 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: case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height m.width, m.height = msg.Width, msg.Height
m.resizeTabs() m.resizeTabs()
if m.modal != nil {
m.modal, _ = m.modal.Update(msg)
}
return m, nil return m, nil
case tickMsg: case tickMsg:
@@ -175,8 +178,15 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Modal grabs all input while open. // Modal grabs all input while open.
if m.modal != nil { if m.modal != nil {
prev := m.modal
newModal, cmd := m.modal.Update(msg) newModal, cmd := m.modal.Update(msg)
m.modal = newModal 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 return m, cmd
} }
@@ -223,6 +233,7 @@ func (m model) handleKey(km tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(loadStatusCmd(), loadConfigCmd()) return m, tea.Batch(loadStatusCmd(), loadConfigCmd())
case "a": case "a":
m.modal = m.openAddPicker() m.modal = m.openAddPicker()
m.seedModalSize()
return m, nil return m, nil
case "d": case "d":
return m.openRemoveConfirm() return m.openRemoveConfirm()
@@ -501,9 +512,20 @@ func (m model) openRemoveConfirm() (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.modal = newConfirm(prompt, run) m.modal = newConfirm(prompt, run)
m.seedModalSize()
return m, nil 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 // 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 // active tab and the row under the cursor. Looks up the full record in
// m.peersFull / m.checksFull / m.alerts (populated by loadConfigCmd) so // 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 { for i := range m.peersFull {
if m.peersFull[i].NodeID == id { if m.peersFull[i].NodeID == id {
m.modal = newEditNodeForm(m.peersFull[i]) m.modal = newEditNodeForm(m.peersFull[i])
m.seedModalSize()
return m, nil return m, nil
} }
} }
@@ -534,6 +557,7 @@ func (m model) openEditForm() (tea.Model, tea.Cmd) {
for i := range m.checksFull { for i := range m.checksFull {
if m.checksFull[i].ID == id { if m.checksFull[i].ID == id {
m.modal = newEditCheckForm(m.checksFull[i]) m.modal = newEditCheckForm(m.checksFull[i])
m.seedModalSize()
return m, nil return m, nil
} }
} }
@@ -559,6 +583,7 @@ func (m model) openEditForm() (tea.Model, tea.Cmd) {
m.setFlash("unsupported alert type", flashError) m.setFlash("unsupported alert type", flashError)
return m, nil return m, nil
} }
m.seedModalSize()
return m, nil return m, nil
} }
m.setFlash("alert not found in local cluster.yaml", flashError) m.setFlash("alert not found in local cluster.yaml", flashError)