Compare commits
12 Commits
v0.0.1-rc2
...
v0.0.1-rc4
| Author | SHA1 | Date | |
|---|---|---|---|
| 55d966ba8f | |||
| 74cb42ea28 | |||
| 2382aebc10 | |||
| 9105cba380 | |||
| a8f69cd7cc | |||
| 1f1dd32741 | |||
| 231176ce41 | |||
| 5f7185e5b1 | |||
| a6283d9d43 | |||
| 7a1ea39f78 | |||
| e8656b09a7 | |||
| 5c54a1cd91 |
@@ -0,0 +1,7 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.claude
|
||||||
|
.github
|
||||||
|
dist
|
||||||
|
*.md
|
||||||
|
install.sh
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user