v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
gates / gates (push) Successful in 1m17s
gates / gates (push) Successful in 1m17s
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s, 25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex, app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs. Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
This commit is contained in:
@@ -14,6 +14,7 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/i18n"
|
||||
)
|
||||
@@ -882,6 +883,33 @@ func (n *Notifier) NotifyAppRepairedAfterCrash(d AppRepairDetails) {
|
||||
}
|
||||
}
|
||||
|
||||
// NotifyUnexplained sends the upload and CPU watch's events (internal/upwatch; felhom.eu `08` §9). OPERATOR-ONLY on the
|
||||
// hub (notify.operatorOnlyEvents there, registered in the same commit as the types): the household can do nothing
|
||||
// about a hacked app but wait for support (the R-388 rule). Edge-triggered by the watch: one event when the condition
|
||||
// is judged, one `_cleared` (info, recorded) when it stops.
|
||||
func (n *Notifier) NotifyUnexplained(eventType, _ string, d upwatch.Details) {
|
||||
// The severity is fixed per type (alarm = warning, clear = info) — never the caller's word, so the hub's vocabulary
|
||||
// contract (TestR329_EveryEmittedSeverityIsInTheHubVocabulary) can read every value from this function.
|
||||
switch eventType {
|
||||
case "app_unexplained_upload":
|
||||
if d.AppMbps >= 0 {
|
||||
n.pushEventMsg(eventType, "warning", "event.app_unexplained_upload", d, d.App, d.RateMbps, d.Minutes, d.AppMbps)
|
||||
} else {
|
||||
n.pushEventMsg(eventType, "warning", "event.app_unexplained_upload_noapp", d, d.App, d.RateMbps, d.Minutes)
|
||||
}
|
||||
case "app_unexplained_upload_cleared":
|
||||
n.pushEventMsg(eventType, "info", "event.app_unexplained_upload_cleared", d, d.App)
|
||||
case "app_unexplained_cpu":
|
||||
n.pushEventMsg(eventType, "warning", "event.app_unexplained_cpu", d, d.App, d.CPUPercent, d.Minutes)
|
||||
case "app_unexplained_cpu_cleared":
|
||||
n.pushEventMsg(eventType, "info", "event.app_unexplained_cpu_cleared", d, d.App)
|
||||
default:
|
||||
if n.logger != nil {
|
||||
n.logger.Printf("[ERROR] NotifyUnexplained: unknown event type %q — not sent", eventType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type OOMStormDetails struct {
|
||||
App string `json:"app"`
|
||||
StackName string `json:"stack_name"`
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
|
||||
)
|
||||
|
||||
// The upload and CPU watch's four events reach the wire with the type, the severity, the numbers in the sentence and
|
||||
// the details object (the hub mails the operator from it). An unknown type is not sent.
|
||||
// RED-PROOF: swap the AppMbps branch (use the no-number sentence always) → the first case lacks „csak 0.0" → fails.
|
||||
func TestNotifyUnexplained_TypesSentencesAndDetails(t *testing.T) {
|
||||
type got struct {
|
||||
typ, sev, msg string
|
||||
d interface{}
|
||||
}
|
||||
var gs []got
|
||||
n := &Notifier{}
|
||||
n.pushFn = func(typ, sev, msg, _ string, d interface{}) { gs = append(gs, got{typ, sev, msg, d}) }
|
||||
d := upwatch.Details{App: "qbittorrent", StackName: "qbittorrent", Kind: "upload", Minutes: 15, RateMbps: 10136.77, AppMbps: 0, Explainer: "qbittorrent"}
|
||||
n.NotifyUnexplained("app_unexplained_upload", "warning", d)
|
||||
d.AppMbps = -1
|
||||
n.NotifyUnexplained("app_unexplained_upload", "warning", d)
|
||||
n.NotifyUnexplained("app_unexplained_upload_cleared", "info", upwatch.Details{App: "qbittorrent", Cleared: true})
|
||||
n.NotifyUnexplained("app_unexplained_cpu", "warning", upwatch.Details{App: "plex", Kind: "cpu", Minutes: 30, CPUPercent: 380})
|
||||
n.NotifyUnexplained("app_unexplained_cpu_cleared", "info", upwatch.Details{App: "plex", Cleared: true})
|
||||
n.NotifyUnexplained("app_unexplained_typo", "warning", d)
|
||||
if len(gs) != 5 {
|
||||
t.Fatalf("pushed %d events, want 5 (the unknown type must not be sent): %+v", len(gs), gs)
|
||||
}
|
||||
want := []struct{ typ, sev, frag string }{
|
||||
{"app_unexplained_upload", "warning", "qbittorrent: 10136.8 Mbit/s feltöltés 15 percen át, de az alkalmazás saját adata szerint csak 0.0 Mbit/s"},
|
||||
{"app_unexplained_upload", "warning", "az alkalmazástól nincs összevethető adat"},
|
||||
{"app_unexplained_upload_cleared", "info", "qbittorrent: a megmagyarázatlan feltöltés megszűnt."},
|
||||
{"app_unexplained_cpu", "warning", "plex: 380% CPU (egy magra számolva) 30 percen át"},
|
||||
{"app_unexplained_cpu_cleared", "info", "plex: a megmagyarázatlan CPU-használat megszűnt."},
|
||||
}
|
||||
for i, w := range want {
|
||||
if gs[i].typ != w.typ || gs[i].sev != w.sev || !strings.Contains(gs[i].msg, w.frag) {
|
||||
t.Errorf("event %d = %s/%s %q, want %s/%s containing %q", i, gs[i].typ, gs[i].sev, gs[i].msg, w.typ, w.sev, w.frag)
|
||||
}
|
||||
if _, ok := gs[i].d.(upwatch.Details); !ok {
|
||||
t.Errorf("event %d carries no details object", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user