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

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:
2026-10-23 21:26:09 +02:00
parent 277e99839d
commit a9c1997fbc
35 changed files with 2438 additions and 0 deletions
@@ -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)
}
}
}