v0.342.0 (unreleased): the upload watch reads the host's weekly line test (GET /host/line-test, agent >= 0.164.0); qBittorrent's limit = 50 % of the TESTED line only (D41), the watch uses the higher of test and push floor, the system page names the source; red-proved
gates / gates (push) Successful in 1m15s

This commit is contained in:
2026-10-24 08:59:57 +02:00
parent 60e487db51
commit ed1011b088
19 changed files with 300 additions and 19 deletions
+2 -1
View File
@@ -688,7 +688,8 @@ func main() {
// The upload and CPU watch (internal/upwatch; felhom.eu `08` §9, operator ruling 2026-10-23). Built here, where the
// notifier first exists, and its push measurement handed to the off-site backup BEFORE any scheduler can start a
// push. Wiring pinned by TestUpwatchIsWiredAtStartup.
upwatchSvc := newUpwatch(sett, stackMgr, backupMgr, metricsStore, notifier, logger)
upwatchSvc := newUpwatch(sett, stackMgr, backupMgr, metricsStore, notifier,
agentLineTest(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint, logger), logger)
if backupMgr != nil {
backupMgr.SetOffboxPushObserver(upwatchSvc.PushObserver)
}
+29 -3
View File
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/agentapi"
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
"gitea.dooplex.hu/admin/felhom-controller/internal/mediawire"
"gitea.dooplex.hu/admin/felhom-controller/internal/metrics"
@@ -24,10 +25,11 @@ const baselineMinMinutes = 1200
// newUpwatch builds the upload and CPU watch (internal/upwatch; felhom.eu `08` §9). It does not start it.
func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *backup.Manager, metricsStore *metrics.MetricsStore,
notifier *notify.Notifier, logger *log.Logger) *upwatch.Service {
notifier *notify.Notifier, measured func(context.Context) (float64, time.Time, bool), logger *log.Logger) *upwatch.Service {
box := mediawire.DockerBox{}
qb := &upwatch.QBittorrent{Box: box}
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare, Log: logger}
svc := &upwatch.Service{Store: sett, QB: qb, QBRunning: func() bool { return box.Running("qbittorrent") }, QBShare: qbLimitShare,
Measured: measured, Log: logger}
w := &upwatch.Watcher{
Source: &upwatch.DockerAPI{},
Explainers: map[string]upwatch.Explainer{
@@ -56,6 +58,29 @@ func newUpwatch(sett *settings.Settings, stackMgr *stacks.Manager, backupMgr *ba
return svc
}
// agentLineTest reads the host's weekly line test through the agent's local API (agent ≥ 0.164.0; an older agent
// answers 404 → not measured). nil without a local API.
func agentLineTest(endpoint, token, fingerprint string, logger *log.Logger) func(context.Context) (float64, time.Time, bool) {
if endpoint == "" || token == "" {
return nil
}
ac, err := agentapi.New(endpoint, token, fingerprint)
if err != nil {
logger.Printf("[WARN] [upwatch] agent client init failed (%v) — no line test; qBittorrent gets no limit", err)
return nil
}
return func(ctx context.Context) (float64, time.Time, bool) {
cctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
st, err := ac.LineTest(cctx)
if err != nil {
logger.Printf("[DEBUG] [upwatch] line test read: %v", err)
return 0, time.Time{}, false
}
return st.Mbps, st.MeasuredAtTime(), st.Present
}
}
// dailyBaseline caches each app's p95 for a day (a 7-day query is a few hundred thousand rows).
func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bool) {
type entry struct {
@@ -80,6 +105,7 @@ func dailyBaseline(ms *metrics.MetricsStore) func(string, []string) (float64, bo
// startUpwatch runs the minute watcher and the 15-minute qBittorrent limit rule until ctx ends.
func startUpwatch(ctx context.Context, svc *upwatch.Service, logger *log.Logger) {
svc.RefreshMeasured(ctx)
go svc.Watcher.Run(ctx, time.Minute)
go func() {
t := time.NewTicker(15 * time.Minute)
@@ -101,5 +127,5 @@ func lineText(l float64, ok bool) string {
if !ok {
return "unknown — learning from the off-site push"
}
return "at least " + upwatch.MbpsText(int64(l*1e6/8)) + " Mbit/s"
return upwatch.MbpsText(int64(l*1e6/8)) + " Mbit/s"
}
@@ -13,10 +13,11 @@ import (
// The upload watch is built, started, handed to the off-site push BEFORE the scheduler can start one, and its view
// handed to the pages — a seam built and never wired is this project's commonest defect.
// RED-PROOF: delete the SetOffboxPushObserver line → fails; move it below the scheduler's start → fails.
// RED-PROOF: delete the SetOffboxPushObserver line → fails; move it below the scheduler's start → fails; pass nil
// instead of agentLineTest(...) → the qBittorrent limit never gets a tested line → fails.
func TestUpwatchIsWiredAtStartup(t *testing.T) {
lines, _, _ := slice4CallLines(t)
for _, fn := range []string{"newUpwatch", "SetOffboxPushObserver", "startUpwatch", "SetUpwatchView"} {
for _, fn := range []string{"newUpwatch", "agentLineTest", "SetOffboxPushObserver", "startUpwatch", "SetUpwatchView"} {
if len(lines[fn]) == 0 {
t.Fatalf("main.go never calls %s", fn)
}
@@ -34,7 +35,7 @@ func TestNewUpwatch_ExplainersAndEmit(t *testing.T) {
if err != nil {
t.Fatal(err)
}
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), lg)
svc := newUpwatch(sett, nil, nil, nil, notify.New("", "", "", sett, lg, false), nil, lg)
for _, a := range []string{"qbittorrent", "transmission", "plex"} {
if _, ok := svc.Watcher.Explainers[a]; !ok {
t.Errorf("no explainer for %s", a)