Bug bash 2: 11 bugs in Stacks, Alerts, Docker, skins and the installer, fixed and verified (BUGS.md)
Stacks: deploy with every compose file (labels from inspect, not comma-joined ps); refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a restart without re-announcing; vanished alerts are recorded as cleared with the reason; rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer: deploy republishes; an older published build is never installed silently. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
9c4f9dfa9f
commit
eddba4250b
+74
-3
@@ -71,8 +71,18 @@ type alertSaved struct {
|
||||
Channels []AlertChannel `json:"channels"`
|
||||
Events []AlertEvent `json:"events"`
|
||||
Seeded bool `json:"seeded"`
|
||||
// Firing is what was firing when the hub last looked, so a restart (every deploy is one)
|
||||
// carries on where it was and doesn't announce the same alerts all over again.
|
||||
Firing map[string]alertKept `json:"firing,omitempty"`
|
||||
}
|
||||
|
||||
type alertKept struct {
|
||||
Since int64 `json:"since"`
|
||||
Value any `json:"value"`
|
||||
}
|
||||
|
||||
const maxRules = 200
|
||||
|
||||
type alertState struct {
|
||||
pending time.Time
|
||||
firing bool
|
||||
@@ -109,6 +119,9 @@ func newAlertEngine(s *Server) *alertEngine {
|
||||
if !e.saved.Seeded {
|
||||
e.saved.Rules, e.saved.Seeded = append([]AlertRule{}, defaultRules...), true
|
||||
}
|
||||
for key, k := range e.saved.Firing {
|
||||
e.state[key] = &alertState{pending: time.UnixMilli(k.Since), firing: true, since: time.UnixMilli(k.Since), value: k.Value}
|
||||
}
|
||||
for i, r := range e.saved.Rules { // the first day's default watched "not running", which a container stopped on purpose also is
|
||||
if r.ID == "container-down" && r.Sensor == "container.*.running" {
|
||||
e.saved.Rules[i].Name, e.saved.Rules[i].Sensor, e.saved.Rules[i].Value = "Container down", "container.*.down", true
|
||||
@@ -275,12 +288,47 @@ func (e *alertEngine) evaluate() {
|
||||
}
|
||||
}
|
||||
}
|
||||
for key := range e.state { // a sensor that is gone (a removed container, a deleted rule) takes its state with it
|
||||
if !seen[key] {
|
||||
delete(e.state, key)
|
||||
byID := map[string]AlertRule{}
|
||||
for _, r := range rules {
|
||||
byID[r.ID] = r
|
||||
}
|
||||
for key, st := range e.state { // no longer watched: the rule was paused, deleted or narrowed, or the sensor is gone
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
if st.firing { // it was on the Alerts tab and perhaps on a phone: say that it is over, and why
|
||||
part := strings.SplitN(key, "|", 3)
|
||||
r, known := byID[part[0]]
|
||||
name, why := r.Name, "no longer watched"
|
||||
if !known {
|
||||
name, why = part[0], "rule deleted"
|
||||
} else if !r.Enabled {
|
||||
why = "rule paused"
|
||||
}
|
||||
if name == "" {
|
||||
name = part[0]
|
||||
}
|
||||
events = append(events, AlertEvent{T: now.UnixMilli(), Kind: "resolved", Rule: name, RuleID: part[0], Machine: part[1], Sensor: part[2], Value: why, Severity: r.Severity})
|
||||
}
|
||||
delete(e.state, key)
|
||||
}
|
||||
e.last = now
|
||||
kept := map[string]alertKept{}
|
||||
for key, st := range e.state {
|
||||
if st.firing {
|
||||
kept[key] = alertKept{Since: st.since.UnixMilli(), Value: st.value}
|
||||
}
|
||||
}
|
||||
changed := len(kept) != len(e.saved.Firing)
|
||||
for k := range kept {
|
||||
if _, ok := e.saved.Firing[k]; !ok {
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
e.saved.Firing = kept
|
||||
if changed && len(events) == 0 {
|
||||
e.persist()
|
||||
}
|
||||
if len(events) > 0 {
|
||||
e.saved.Events = append(e.saved.Events, events...)
|
||||
if len(e.saved.Events) > maxEvents {
|
||||
@@ -390,6 +438,10 @@ func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, "bad request")
|
||||
return
|
||||
}
|
||||
if body.Rules != nil && len(*body.Rules) > maxRules {
|
||||
jsonErr(w, 400, fmt.Sprintf("at most %d rules: every one is checked on every machine every %d s", maxRules, int(alertEvery.Seconds())))
|
||||
return
|
||||
}
|
||||
if body.Rules != nil {
|
||||
ids := map[string]bool{}
|
||||
for i := range *body.Rules {
|
||||
@@ -408,6 +460,25 @@ func (s *Server) alertsPut(w http.ResponseWriter, r *http.Request) {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the comparison must be one of > >= < <= == !=", ru.Name))
|
||||
return
|
||||
}
|
||||
switch val := ru.Value.(type) {
|
||||
case float64, bool:
|
||||
case string:
|
||||
if len(val) > 200 {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the value is too long", ru.Name))
|
||||
return
|
||||
}
|
||||
if ru.Op != "==" && ru.Op != "!=" { // "cpu.usage > ninety" can never be true, and would never say so
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: %s compares numbers, and %q is not one; text can only be == or !=", ru.Name, ru.Op, val))
|
||||
return
|
||||
}
|
||||
default:
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: the value must be a number, true/false or text", ru.Name))
|
||||
return
|
||||
}
|
||||
if _, isBool := ru.Value.(bool); isBool && ru.Op != "==" && ru.Op != "!=" {
|
||||
jsonErr(w, 400, fmt.Sprintf("rule %q: true/false can only be == or !=", ru.Name))
|
||||
return
|
||||
}
|
||||
if ru.Severity != "info" && ru.Severity != "crit" {
|
||||
ru.Severity = "warn"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user