Compare commits
3 Commits
v0.0.2
..
v0.0.2-hf0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c85caabcf | |||
| 8638ab5432 | |||
| a11b31f160 |
@@ -4,6 +4,12 @@ All notable changes to this project are documented here. The format
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and
|
||||||
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [v0.0.2] — 2026-05-15
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Text template field in the TUI did not support newlines, causing multi-line templates to render as a single line and losing formatting. This has been fixed by changing the field into a textarea and escaping the `enter` key to insert newlines.
|
||||||
|
|
||||||
## [v0.0.1] — 2026-05-15
|
## [v0.0.1] — 2026-05-15
|
||||||
|
|
||||||
Initial public release.
|
Initial public release.
|
||||||
|
|||||||
@@ -25,12 +25,20 @@ type discordPayload struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendDiscord posts msg.Subject + body to the configured webhook URL.
|
// sendDiscord posts msg.Subject + body to the configured webhook URL.
|
||||||
|
// When the alert has a custom BodyTemplate, the rendered body is shipped
|
||||||
|
// verbatim — the operator has opted out of the default subject header
|
||||||
|
// and code-block wrapping in favour of their own formatting.
|
||||||
func sendDiscord(a *config.Alert, msg Message) error {
|
func sendDiscord(a *config.Alert, msg Message) error {
|
||||||
if a.DiscordWebhook == "" {
|
if a.DiscordWebhook == "" {
|
||||||
return errors.New("discord webhook url not set")
|
return errors.New("discord webhook url not set")
|
||||||
}
|
}
|
||||||
|
|
||||||
content := msg.Subject + "\n```\n" + msg.Body + "\n```"
|
var content string
|
||||||
|
if a.BodyTemplate != "" {
|
||||||
|
content = msg.Body
|
||||||
|
} else {
|
||||||
|
content = msg.Subject + "\n```\n" + msg.Body + "\n```"
|
||||||
|
}
|
||||||
raw, err := json.Marshal(discordPayload{Content: content})
|
raw, err := json.Marshal(discordPayload{Content: content})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func New(cluster *config.ClusterConfig, selfID string, logger *log.Logger) *Disp
|
|||||||
|
|
||||||
// OnTransition is wired as checks.TransitionFn.
|
// OnTransition is wired as checks.TransitionFn.
|
||||||
func (d *Dispatcher) OnTransition(check *config.Check, from, to checks.State, snap checks.Snapshot) {
|
func (d *Dispatcher) OnTransition(check *config.Check, from, to checks.State, snap checks.Snapshot) {
|
||||||
if to == checks.StateUnknown {
|
if !shouldAlert(from, to) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
alerts := d.cluster.EffectiveAlertsFor(check)
|
alerts := d.cluster.EffectiveAlertsFor(check)
|
||||||
@@ -77,6 +77,25 @@ func (d *Dispatcher) Test(alertID string) error {
|
|||||||
return d.dispatchOne(alert, msg)
|
return d.dispatchOne(alert, msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldAlert decides whether a committed state transition warrants
|
||||||
|
// firing the configured alert channels.
|
||||||
|
//
|
||||||
|
// A fresh master's aggregator starts every check at StateUnknown, so
|
||||||
|
// the first successful evaluation always commits Unknown→Up. Without
|
||||||
|
// filtering, every master failover (or daemon restart) would spam an
|
||||||
|
// "is now UP" alert for every healthy check. We treat Unknown→Up as a
|
||||||
|
// silent cold start; real recoveries (Down→Up) and any transition to
|
||||||
|
// Down still alert.
|
||||||
|
func shouldAlert(from, to checks.State) bool {
|
||||||
|
if to == checks.StateUnknown {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if from == checks.StateUnknown && to == checks.StateUp {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Dispatcher) dispatchOne(a *config.Alert, msg Message) error {
|
func (d *Dispatcher) dispatchOne(a *config.Alert, msg Message) error {
|
||||||
switch a.Type {
|
switch a.Type {
|
||||||
case config.AlertSMTP:
|
case config.AlertSMTP:
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package alerts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.cer.sh/axodouble/quptime/internal/checks"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestShouldAlertFiltersColdStartUp(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
from checks.State
|
||||||
|
to checks.State
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"cold start to up (master failover / daemon restart)", checks.StateUnknown, checks.StateUp, false},
|
||||||
|
{"cold start to down still alerts", checks.StateUnknown, checks.StateDown, true},
|
||||||
|
{"real recovery alerts", checks.StateDown, checks.StateUp, true},
|
||||||
|
{"regression alerts", checks.StateUp, checks.StateDown, true},
|
||||||
|
{"stale (up to unknown) suppressed", checks.StateUp, checks.StateUnknown, false},
|
||||||
|
{"stale (down to unknown) suppressed", checks.StateDown, checks.StateUnknown, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := shouldAlert(c.from, c.to); got != c.want {
|
||||||
|
t.Errorf("shouldAlert(%s→%s) = %v, want %v", c.from, c.to, got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user