5 Commits

Author SHA1 Message Date
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
Axodouble 7b45c8fcf0 Updated readme with the correct text formatting options
Release / release (push) Successful in 11m40s
2026-05-14 06:38:02 +00:00
Axodouble cbb311d877 Added helper variables and templating for custom messages 2026-05-14 06:26:00 +00:00
7 changed files with 93 additions and 15 deletions
+3 -3
View File
@@ -22,9 +22,9 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
check-latest: true
cache: true
go-version: '1.24'
check-latest: false
cache: false
- name: Test
run: go test -race ./...
+6
View File
@@ -273,6 +273,12 @@ Available template variables:
| `{{.NodeID}}` | master that dispatched |
| `{{.When}}` | RFC3339 timestamp |
The same variable list is surfaced in-app: `qu alert add smtp --help`,
`qu alert add discord --help`, and `qu alert edit --help` each print
it under their flag table, and `qu tui` shows a compact reminder of
the supported variables as a hint when the cursor lands on a Subject
or Body template field in the add/edit alert forms.
`qu alert test <name>` exercises the template against a synthetic
"homepage going DOWN" transition, so you can verify rendering before
production traffic depends on it. A template parse or execution error
+18
View File
@@ -25,6 +25,24 @@ if [ -w "/usr/local/bin" ]; then
echo_cmd "curl -L -o \"/usr/local/bin/qu\" \"https://git.cer.sh/axodouble/quptime/releases/download/${RELEASE}/qu-${RELEASE}-linux-amd64\""
echo_cmd "chmod +x \"/usr/local/bin/qu\""
echo "> qu has been installed to /usr/local/bin/qu"
# Drop completions into the directories each shell already scans.
# No rc-file edits, and uninstall is just `rm`. Silently skips
# shells whose completion dir is absent.
write_completion() {
local shell=$1 path=$2
[ -d "$(dirname "$path")" ] || return 1
if /usr/local/bin/qu completion "$shell" > "$path" 2>/dev/null; then
echo "> installed $shell completion -> $path"
return 0
fi
rm -f "$path"
return 1
}
write_completion bash /usr/share/bash-completion/completions/qu \
|| write_completion bash /etc/bash_completion.d/qu
write_completion zsh /usr/share/zsh/site-functions/_qu
write_completion fish /usr/share/fish/vendor_completions.d/qu.fish
else
echo "Error: curl is not installed. Please install curl and try again."
exit 1
+44
View File
@@ -0,0 +1,44 @@
package alerts
// TemplateVarsHint returns a compact, multi-line listing of the
// variables a subject/body template can reference. Designed for
// embedding in TUI form hints where vertical space is tight.
//
// Continuation lines are pre-indented so they line up under the
// first line when the caller prepends a fixed indent (e.g. " ").
func TemplateVarsHint() string {
return "Go text/template — leave empty to use the built-in format.\n" +
" Vars: {{.Check.Name}}, {{.Check.Target}}, {{.Check.Type}}, {{.Check.ID}},\n" +
" {{.Verb}} (UP|DOWN|RECOVERED), {{.From}}, {{.To}}, {{.NodeID}}, {{.When}},\n" +
" {{.Snapshot.Detail}}, {{.Snapshot.Reports}}, {{.Snapshot.OKCount}}, {{.Snapshot.NotOK}}"
}
// TemplateVarsHelp returns the long-form documentation for available
// template variables, suitable for embedding in a CLI command's Long
// help text. Each variable is described on its own line and an
// example template is included at the end.
func TemplateVarsHelp() string {
return `Subject and body templates use Go text/template syntax. They are
optional — leaving them empty falls back to the built-in format.
Discord ignores the subject template (it has no subject line); SMTP
uses both.
Available variables:
{{.Check.Name}} check name (e.g. "homepage")
{{.Check.Target}} URL / host:port / host being probed
{{.Check.Type}} http | tcp | icmp
{{.Check.ID}} stable check UUID
{{.Verb}} UP | DOWN | RECOVERED
{{.From}} previous state name
{{.To}} new state name
{{.NodeID}} master node that rendered the message
{{.When}} RFC3339 timestamp of the transition
{{.Snapshot.Detail}} probe detail string (e.g. "connection refused")
{{.Snapshot.Reports}} total reports in the flip window
{{.Snapshot.OKCount}} ok report count
{{.Snapshot.NotOK}} failing report count
Example body template:
{{.Check.Name}} is {{.Verb}} (target {{.Check.Target}}).
Detail: {{.Snapshot.Detail}}`
}
+8 -3
View File
@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/spf13/cobra"
"git.cer.sh/axodouble/quptime/internal/alerts"
"git.cer.sh/axodouble/quptime/internal/config"
"git.cer.sh/axodouble/quptime/internal/daemon"
"git.cer.sh/axodouble/quptime/internal/transport"
@@ -21,9 +22,9 @@ import (
// variants (if non-empty) and returns the effective subject + body
// template strings. Inline flags take precedence over file flags.
func bindTemplateFlags(cmd *cobra.Command) {
cmd.Flags().String("subject", "", "subject template (text/template syntax — SMTP only)")
cmd.Flags().String("subject", "", "subject template, Go text/template (SMTP only; see --help for variables)")
cmd.Flags().String("subject-file", "", "path to a file containing the subject template")
cmd.Flags().String("body", "", "body template (text/template syntax)")
cmd.Flags().String("body", "", "body template, Go text/template (see --help for variables)")
cmd.Flags().String("body-file", "", "path to a file containing the body template")
}
@@ -172,7 +173,9 @@ func buildAlertEditCmd() *cobra.Command {
take effect; everything else is preserved.
The type (smtp/discord) cannot be changed in place — delete and re-add
the alert if you need to switch channels.`,
the alert if you need to switch channels.
` + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
@@ -308,6 +311,7 @@ func buildSMTPAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "smtp <name>",
Short: "Add an SMTP relay alert",
Long: "Add an SMTP relay alert.\n\n" + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
@@ -365,6 +369,7 @@ func buildDiscordAddCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "discord <name>",
Short: "Add a Discord webhook alert",
Long: "Add a Discord webhook alert.\n\n" + alerts.TemplateVarsHelp(),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Second)
+7 -6
View File
@@ -12,6 +12,7 @@ import (
"github.com/charmbracelet/lipgloss"
"github.com/google/uuid"
"git.cer.sh/axodouble/quptime/internal/alerts"
"git.cer.sh/axodouble/quptime/internal/config"
"git.cer.sh/axodouble/quptime/internal/daemon"
"git.cer.sh/axodouble/quptime/internal/transport"
@@ -272,7 +273,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", "leave empty for default formatting", false),
textField("Body template", alerts.TemplateVarsHint(), false),
}
return newForm("Add Discord alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
@@ -303,8 +304,8 @@ func newAddSMTPForm() *form {
textField("To", "comma-separated recipient addresses", true),
textField("StartTLS", "yes/no — default yes", false),
textField("Default", "yes/no — attach to every check", false),
textField("Subject template", "optional", false),
textField("Body template", "optional", false),
textField("Subject template", alerts.TemplateVarsHint(), false),
textField("Body template", alerts.TemplateVarsHint(), false),
}
return newForm("Add SMTP alert", fields, func(vals []string) tea.Cmd {
return func() tea.Msg {
@@ -445,7 +446,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", "leave empty for default formatting", existing.BodyTemplate, false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
subject := existing.SubjectTemplate
@@ -483,8 +484,8 @@ func newEditSMTPForm(existing config.Alert) *form {
textFieldWithValue("To", "comma-separated recipient addresses", strings.Join(existing.SMTPTo, ","), true),
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", "optional", existing.SubjectTemplate, false),
textFieldWithValue("Body template", "optional", existing.BodyTemplate, false),
textFieldWithValue("Subject template", alerts.TemplateVarsHint(), existing.SubjectTemplate, false),
textFieldWithValue("Body template", alerts.TemplateVarsHint(), existing.BodyTemplate, false),
}
id := existing.ID
return newForm("Edit SMTP alert", fields, func(vals []string) tea.Cmd {
+7 -3
View File
@@ -58,14 +58,18 @@ var (
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 {
switch s {
case "up":
return stateUpStyle.Render("● up")
return "● up"
case "down":
return stateDownStyle.Render("● down")
return "● down"
default:
return stateUnknownStyle.Render("○ unknown")
return "○ unknown"
}
}