controller: fix-3 dead-app alerting + fix-6 ring cap/spill/spam (WIP, pre-build)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017CDMFpFx84pfviCTVuGGhf
This commit is contained in:
2026-07-12 10:07:47 +02:00
parent a6da64da15
commit d8f6069b46
10 changed files with 581 additions and 10 deletions
@@ -0,0 +1,68 @@
package notify
import (
"log"
"testing"
)
// fix-3: app_start_failed fires ONCE per running→down transition. down→down cycles are silent (the
// anti-spam guarantee); down→running clears the tracker so a later re-failure re-notifies.
func TestNotifyAppStartFailures_OneEventPerTransition(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(nil, "", 0), false)
var events []string
n.pushFn = func(eventType, _, msg string, _ interface{}) {
if eventType == "app_start_failed" {
events = append(events, msg)
}
}
up := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: false}}
down := []AppRunState{{Name: "radarr", DisplayName: "Radarr", Down: true}}
// running → down: exactly one event.
n.NotifyAppStartFailures(up)
n.NotifyAppStartFailures(down)
if len(events) != 1 {
t.Fatalf("running→down must fire exactly one event, got %d: %v", len(events), events)
}
// down → down (two more cycles): SILENT (companion: drop the n.appDown tracking → fires each cycle → fail).
n.NotifyAppStartFailures(down)
n.NotifyAppStartFailures(down)
if len(events) != 1 {
t.Fatalf("down→down must be silent, got %d events: %v", len(events), events)
}
// down → running → down: a fresh transition re-notifies.
n.NotifyAppStartFailures(up)
n.NotifyAppStartFailures(down)
if len(events) != 2 {
t.Fatalf("a fresh down transition must re-notify, got %d: %v", len(events), events)
}
}
// An app that is down on the FIRST evaluation (the F11 dead-at-boot case, after the boot grace) still
// fires — it is a running→down transition from the tracker's empty initial state.
func TestNotifyAppStartFailures_FirstSeenDownFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(nil, "", 0), false)
var count int
n.pushFn = func(eventType, _, _ string, _ interface{}) {
if eventType == "app_start_failed" {
count++
}
}
n.NotifyAppStartFailures([]AppRunState{{Name: "jellyfin", DisplayName: "Jellyfin", Down: true}})
if count != 1 {
t.Fatalf("a first-seen dead app (dead-at-boot) must fire once, got %d", count)
}
}
// A deployed app that is up never fires.
func TestNotifyAppStartFailures_HealthyNeverFires(t *testing.T) {
n := New("http://hub", "key", "cust", nil, log.New(nil, "", 0), false)
var count int
n.pushFn = func(string, string, string, interface{}) { count++ }
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
n.NotifyAppStartFailures([]AppRunState{{Name: "radarr", Down: false}})
if count != 0 {
t.Fatalf("a healthy app must never fire, got %d", count)
}
}
+66
View File
@@ -39,6 +39,16 @@ type Notifier struct {
mu sync.Mutex
prevHealthStatus string // tracks previous health check status for change detection
// appDown tracks which deployed apps are currently in the DOWN state so app_start_failed fires
// ONCE per running→down transition, not every health cycle (fix-3 anti-spam). In-memory: a
// controller restart re-notifies once (acceptable — better than missing). The hub owns the real
// cooldown; the controller must not add its own timer.
appDown map[string]bool
// pushFn is a test seam for the transition-emitting notifiers (fix-3). nil → the real async
// PushEvent; tests inject a synchronous recorder.
pushFn func(eventType, severity, message string, details interface{})
// Event history ring buffer (debug page)
historyMu sync.RWMutex
history [50]EventHistoryEntry
@@ -362,6 +372,62 @@ func (n *Notifier) NotifyAppDeployed(stackName, displayName string) {
AppDetails{StackName: stackName, DisplayName: displayName})
}
// AppRunState is one deployed app's running state for the fix-3 start-failure notifier: Down=true
// when the app is deployed but its containers are not running.
type AppRunState struct {
Name string
DisplayName string
Down bool
}
// NotifyAppStartFailures fires an `app_start_failed` hub event ONCE per running→down transition
// (fix-3). It is called each health cycle with the CURRENT deployed-app run states; the per-app
// transition tracking (n.appDown) makes down→down cycles silent, so a persistently-dead app does not
// spam. down→running clears the tracker (no event — the dashboard banner self-clears; a recovery
// event is deliberately omitted to keep the operator inbox quiet). The hub applies its own cooldown.
func (n *Notifier) NotifyAppStartFailures(apps []AppRunState) {
n.mu.Lock()
if n.appDown == nil {
n.appDown = map[string]bool{}
}
var newlyDown []AppRunState
seen := map[string]bool{}
for _, a := range apps {
seen[a.Name] = true
was := n.appDown[a.Name]
if a.Down && !was {
newlyDown = append(newlyDown, a) // running→down (or first-seen-down after the boot grace)
}
n.appDown[a.Name] = a.Down
}
// Forget apps no longer reported (removed/undeployed) so a later redeploy re-notifies cleanly.
for name := range n.appDown {
if !seen[name] {
delete(n.appDown, name)
}
}
n.mu.Unlock()
for _, a := range newlyDown {
name := a.DisplayName
if name == "" {
name = a.Name
}
n.emit("app_start_failed", "warn",
fmt.Sprintf("Telepített alkalmazás nem fut: %s", name),
AppDetails{StackName: a.Name, DisplayName: a.DisplayName})
}
}
// emit sends an event through the test seam if set, else the real async PushEvent.
func (n *Notifier) emit(eventType, severity, message string, details interface{}) {
if n.pushFn != nil {
n.pushFn(eventType, severity, message, details)
return
}
n.PushEvent(eventType, severity, message, details)
}
// NotifyAppRemoved sends an app removal event.
func (n *Notifier) NotifyAppRemoved(stackName, displayName string) {
n.PushEvent("app_removed", "info",