diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f6c0f0..f44b60b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,17 @@ night brief, Part 2 (spike → design → build, commit only). Design: felhom.eu box owns the limit, qBittorrent's page says one line: „A qBittorrent feltöltése legfeljebb N Mbit/s, hogy a családi internet gyors maradjon. A qBittorrentben átállíthatod." - `metrics.StackTxP95Mbps` (the per-app baseline), `settings.Upwatch` (one opaque block), `notify.NotifyUnexplained`. +- **2026-10-24 (attended release run; operator rulings D41 = 50 %, D42, the line test): the TESTED line.** The night's + one-second read showed the push floor is far below the line (demo-hp „at least 10 Mbit/s"; a real test: 90.7). So the + controller now reads the host's **weekly iperf3 line test** from the agent (`agentapi.LineTest`, `GET /host/line-test`, + agent ≥ 0.164.0 — an older agent answers 404 = „not measured"; MinAgent stays 0.158.1). **qBittorrent's limit is 50 % + of the TESTED line only** — never of the push floor; no test yet → no limit. The watch judges against the higher of the + two. The value is read at start and every 15 minutes (a failed read keeps the last value). The system page names the + source: „N Mbit/s (heti sebességméréssel)" / "N Mbit/s (measured weekly)", or „legalább N … (az éjszakai távoli + mentésből)" for the floor. Tests (each red-proved): `TestService_QBLimitFromTheTestedLineOnly`, + `TestUpwatch_SystemPageNamesTheLinesSource`, `TestLineTestDecodesAndAnOlderAgentIs404`, `TestUpwatchIsWiredAtStartup` + (now pins `agentLineTest`); parity case `settings_system_failed` carries the tested line (its fixture's one row + regenerated). - Tests, each red-proved (20 mutations, each seen failing): `TestUpwatch_SeedingQBittorrentIsExplained`, `…_HackedQBittorrentAlarmsOnceAndClears`, `…_PlexWithNoViewerAlarmsWithAViewerDoesNot`, `…_NoExplainerLearnsThenNeedsTenTimesItsP95ForAnHour`, `…_CPUBurnerAlarmsTranscodeAndBoxWorkDoNot`, diff --git a/CONTEXT.md b/CONTEXT.md index a5ef181..dba1dcd 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -1,5 +1,12 @@ # CONTEXT.md — Project Memory +> **2026-10-24 (attended release run) — v0.342.0 gains the TESTED line (operator rulings D41 = 50 %, D42, the iperf3 line +> test; felhom.eu CONTEXT 07:22/07:26).** `agentapi.LineTest` reads the agent's `GET /host/line-test` (agent ≥ 0.164.0 — +> its weekly 10-second iperf3 upload to ep0 inside the tunnel; demo-hp measured 90.7 Mbit/s where the push floor said 10). +> qBittorrent's limit = 50 % of the TESTED line only; the watch uses the higher of test and floor; the system page names +> the source. MinAgent stays 0.158.1 (an older agent = „not measured" = no limit). Release order: agent 0.164.0 (vouch), +> hub 0.169.0, then this controller through the normal path. + > **2026-10-23 night — v0.342.0 on `main`, NOT released: the upload and CPU watch (`internal/upwatch`; operator ruling > 2026-10-23 19:22; felhom.eu `08` §9 [DESIGN — proposed], `audits/SPIKE-upload-watch-2026-10-23.md`).** Exact > per-container counters (Docker Engine API), per-app judging, explainers qBittorrent / Transmission / Plex, the diff --git a/REUSE.md b/REUSE.md index 89b2b1f..1663ef0 100644 --- a/REUSE.md +++ b/REUSE.md @@ -388,6 +388,7 @@ | `util.ParseVersionPre` | controller/internal/util/version.go | the same comparator, reading `X.Y.Z-rc.N` as BELOW `X.Y.Z` (v0.314.0, R-1053) — ONLY for „is this box below the floor?" | never use it to pick a tag to offer: the button must not see a test build | | `upwatch.Source` / `upwatch.Explainer` / `Watcher.Now` (v0.342.0, the upload watch) | controller/internal/upwatch/ | `DockerAPI` (Engine API on the socket, exact counters) / the qBittorrent, Transmission and Plex clients | `fakeSource`, `fakeApp` and a minute clock in watch_test.go drive the WHOLE judge path; the app clients are tested against httptest servers that behave as measured on 9202. **Exact counters come from the Engine API, never from `docker stats` text** (three significant digits) | | `upwatch.QBittorrent.SetUploadLimit` + `ApplyQBLimit` | controller/internal/upwatch/qblimit.go | the one writer of qBittorrent's global upload limit | compare against the value qBittorrent STORED (KiB/s rounding), never the requested one; a limit the household touched is theirs for good | +| `upwatch.Service.Measured` / `RefreshMeasured` / `MeasuredLine` + `agentapi.Client.LineTest` (R-1247) | controller/internal/upwatch/service.go · controller/internal/agentapi/linetest.go · cmd/controller/upwatch_wiring.go `agentLineTest` | GET /host/line-test (agent ≥ 0.164.0): the host's weekly iperf3 line test | **qBittorrent's limit reads `MeasuredLine` only — never `Line()`** (the push floor is a far lower bound; D41). `Line()` = the higher of the two, for the watch. Read at start + every 15 min; a failed read KEEPS the last value; 404 / present:false = not measured. Pinned by `TestService_QBLimitFromTheTestedLineOnly` | | `mediawire.PlexURL` | controller/internal/mediawire/plexaddr.go | Plex's base URL BY ADDRESS, resolved at each call (v0.314.0, R-1060) — every controller call to Plex | Plex refuses the Host name `plex` as non-local (401); never cache the address | | `stacks.GateCapture` / `stacks.ReadGateAtBackup` | controller/internal/stacks/restore_gate.go | the setup gate a per-app backup records, and the restore's read of it (v0.314.0, R-1057) | the unit `app.yaml` key is `setup_gate:` — the live file's keys, so either reads the same | | `agentapi.AgentVersionReporter` + `featureMinAgent` | controller/internal/agentapi/features.go | version-first Supports (v0.82.0 header channel); probe = fallback for header-less agents | a coupled feature adds BOTH a featureProbes row AND a featureMinAgent row; v0.116.0: `SupportsWithSource` also reports HOW the verdict was reached (version/probe-cache/probe) for the gate log line | diff --git a/controller/README.md b/controller/README.md index 16884a4..e469015 100644 --- a/controller/README.md +++ b/controller/README.md @@ -3531,9 +3531,11 @@ up per app and, when an app sends a lot or burns CPU for a long time, asks the a qBittorrent's and Transmission's own upload counters, Plex's playing and transcoding sessions. Explained → normal use. Not explained → one operator-only event (`app_unexplained_upload` / `app_unexplained_cpu`) and a clear when it stops. Numbers: upload ≥ max(5 Mbit/s, 25 % of the line) for 15 min; CPU ≥ max(150 %, 50 % × cores) for 30 min; an app with no -explainer only at ≥ 10 × its own p95 for an hour, after ~20 h of history. The upload line is learnt from the nightly -off-site push (one-second samples of the controller's own counter; best 10 s; a lower bound) and shown on the system -settings page. qBittorrent gets a default upload limit of 50 % of that line (never over a household's own limit), and its +explainer only at ≥ 10 × its own p95 for an hour, after ~20 h of history. The upload line comes from two places: the +host's **weekly iperf3 line test** (the agent's `GET /host/line-test`, agent ≥ 0.164.0 — a 10-second upload to ep0 inside +the tunnel, R-1247) and a floor learnt from the nightly off-site push (one-second samples of the controller's own counter; +best 10 s). The watch judges against the higher; the system settings page names the source. qBittorrent gets a default +upload limit of 50 % of the TESTED line only (D41; no test yet → no limit; never over a household's own limit), and its page says so in one line. State: settings.json `upwatch`. Spike: felhom.eu `audits/SPIKE-upload-watch-2026-10-23.md`. ### 8d. Storage that knows its physical disks — the internal place, partitions (v0.317.0 build A, v0.318.0 build B) diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index b1d8456..2389ce7 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -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) } diff --git a/controller/cmd/controller/upwatch_wiring.go b/controller/cmd/controller/upwatch_wiring.go index 0435edf..9bef31a 100644 --- a/controller/cmd/controller/upwatch_wiring.go +++ b/controller/cmd/controller/upwatch_wiring.go @@ -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" } diff --git a/controller/cmd/controller/upwatch_wiring_test.go b/controller/cmd/controller/upwatch_wiring_test.go index 80f435b..be7130f 100644 --- a/controller/cmd/controller/upwatch_wiring_test.go +++ b/controller/cmd/controller/upwatch_wiring_test.go @@ -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) diff --git a/controller/internal/agentapi/linetest.go b/controller/internal/agentapi/linetest.go new file mode 100644 index 0000000..b8fbb37 --- /dev/null +++ b/controller/internal/agentapi/linetest.go @@ -0,0 +1,38 @@ +package agentapi + +import ( + "context" + "encoding/json" + "fmt" + "time" +) + +// LineTestState mirrors the agent's GET /host/line-test (agent ≥ 0.164.0; felhom.eu R-1247): the host's weekly upload +// line test — a 10-second iperf3 upload to ep0 inside the offsite tunnel, best of three runs, as ep0's server counted +// it. Present=false: never measured (no tunnel, not yet due, or disabled). An agent before the route answers 404 (a +// *StatusError); all three mean UNKNOWN, and the controller then sets no qBittorrent limit. +type LineTestState struct { + Present bool `json:"present"` + Mbps float64 `json:"mbps,omitempty"` + MeasuredAt string `json:"measured_at,omitempty"` // RFC3339 UTC + Method string `json:"method,omitempty"` +} + +// LineTest calls GET /host/line-test. +func (c *Client) LineTest(ctx context.Context) (LineTestState, error) { + var out LineTestState + body, err := c.get(ctx, "/host/line-test") + if err != nil { + return out, err + } + if err := json.Unmarshal(body, &out); err != nil { + return out, fmt.Errorf("agentapi: decode /host/line-test: %w", err) + } + return out, nil +} + +// MeasuredAtTime parses MeasuredAt (zero time when absent or garbled). +func (s LineTestState) MeasuredAtTime() time.Time { + t, _ := time.Parse(time.RFC3339, s.MeasuredAt) + return t +} diff --git a/controller/internal/agentapi/linetest_test.go b/controller/internal/agentapi/linetest_test.go new file mode 100644 index 0000000..b3e44b4 --- /dev/null +++ b/controller/internal/agentapi/linetest_test.go @@ -0,0 +1,36 @@ +package agentapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// R-1247: GET /host/line-test decodes the agent's weekly line test, and an agent before the route is a typed 404 (the +// controller reads it as „not measured" → no qBittorrent limit). +func TestLineTestDecodesAndAnOlderAgentIs404(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /host/line-test", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(`{"ok":true,"data":{"present":true,"mbps":90.7,"measured_at":"2026-10-24T06:31:00Z","method":"iperf3-tunnel"}}`)) + }) + s := httptest.NewTLSServer(mux) + defer s.Close() + st, err := clientFor(t, s, strings.TrimPrefix(s.URL, "https://")).LineTest(context.Background()) + if err != nil { + t.Fatal(err) + } + if !st.Present || st.Mbps != 90.7 || !st.MeasuredAtTime().Equal(time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC)) { + t.Fatalf("state = %+v", st) + } + old := httptest.NewTLSServer(http.NewServeMux()) + defer old.Close() + _, err = clientFor(t, old, strings.TrimPrefix(old.URL, "https://")).LineTest(context.Background()) + var se *StatusError + if !errors.As(err, &se) || se.Code != http.StatusNotFound { + t.Fatalf("an older agent must answer a typed 404, got %v", err) + } +} diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 4368d7c..9e57d6d 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -2792,6 +2792,7 @@ "upwatch.line_measured": "Mbit/s (measured from the nightly off-site backup)", "upwatch.line_label": "Upload speed", "upwatch.line_learning": "still being measured (from the nightly off-site backup)", + "upwatch.line_tested": "Mbit/s (measured weekly)", "event.app_repair_after_crash_skipped": "%s: the repair after the unexpected stop was skipped: %s", "app_info.lost_files_title": "Photos lost in an unexpected stop", "app_info.lost_files_text": "The machine stopped unexpectedly (for example in a power cut), and these files were lost. The machine started again at:", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index 828d35b..4e723bc 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -2780,6 +2780,7 @@ "upwatch.line_measured": "Mbit/s (az éjszakai távoli mentésből mérve)", "upwatch.line_label": "Feltöltési sebesség", "upwatch.line_learning": "még mérjük (az éjszakai távoli mentésből)", + "upwatch.line_tested": "Mbit/s (heti sebességméréssel)", "event.app_repair_after_crash_skipped": "%s: a váratlan leállás utáni javítás elmaradt: %s", "app_info.lost_files_title": "Elveszett fotók egy váratlan leállás miatt", "app_info.lost_files_text": "A gép váratlanul leállt (például áramszünet miatt), és ezek a fájlok elvesztek. A gép újraindulásának ideje:", diff --git a/controller/internal/upwatch/qblimit.go b/controller/internal/upwatch/qblimit.go index a6545f3..07c8361 100644 --- a/controller/internal/upwatch/qblimit.go +++ b/controller/internal/upwatch/qblimit.go @@ -19,7 +19,8 @@ import ( // // The value compared is the one qBittorrent STORED (it keeps KiB/s: 2 500 000 reads back as 2 499 584, measured), so // the box's own rounding is never mistaken for a household's change. The limit survives a qBittorrent restart -// (measured: `Session\GlobalUPSpeedLimit` in its config). No line measured yet → nothing is set. +// (measured: `Session\GlobalUPSpeedLimit` in its config). The line is the host's weekly line TEST (the agent's +// GET /host/line-test, R-1247), never the off-site push's floor; no test yet → nothing is set (Service.QBLimitNow). // // Edge: an app reinstalled from scratch starts unlimited while the record still says „set by the box"; that reads as // the household removing the limit and is left alone. Recorded in the design (`08` §9.7), not guarded. diff --git a/controller/internal/upwatch/service.go b/controller/internal/upwatch/service.go index 8c58736..66acc70 100644 --- a/controller/internal/upwatch/service.go +++ b/controller/internal/upwatch/service.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "log" + "sync" "time" "gitea.dooplex.hu/admin/felhom-controller/internal/logx" @@ -39,12 +40,61 @@ type Service struct { QBRunning func() bool // QBShare is the share of the measured line qBittorrent may upload at (operator decide row; CC's pick 0.5). QBShare float64 - Log *log.Logger + // Measured reads the host's weekly line test (the agent's GET /host/line-test, R-1247): a 10-second iperf3 upload + // to ep0 inside the tunnel. nil or ok=false = not measured. Read every 15 minutes (RefreshMeasured), never per + // minute. + Measured func(ctx context.Context) (mbps float64, at time.Time, ok bool) + Log *log.Logger + + mu sync.Mutex + measured float64 + measuredAt time.Time + measuredOK bool } -// Line is the measured upload line in Mbit/s (a lower bound); ok=false while no push counted. +// RefreshMeasured reads the agent's line test once. A failed read keeps the last value (the agent persists it; an +// agent restart must not drop qBittorrent's limit to „unknown"). +func (s *Service) RefreshMeasured(ctx context.Context) { + if s.Measured == nil { + return + } + mbps, at, ok := s.Measured(ctx) + if !ok || mbps <= 0 { + return + } + s.mu.Lock() + changed := !s.measuredOK || s.measured != mbps + s.measured, s.measuredAt, s.measuredOK = mbps, at, true + s.mu.Unlock() + if changed { + logx.Infof(s.Log, "[upwatch] the host's weekly line test: %.1f Mbit/s (measured %s)", mbps, at.UTC().Format(time.RFC3339)) + } +} + +// MeasuredLine is the host's tested upload line (ok=false: never tested). +func (s *Service) MeasuredLine() (float64, bool) { + s.mu.Lock() + defer s.mu.Unlock() + return s.measured, s.measuredOK +} + +// Line is the upload line the watch judges against, in Mbit/s: the higher of the weekly test and the off-site push's +// floor (both are lower bounds of the real line; the test is the close one). ok=false while neither exists. func (s *Service) Line() (float64, bool) { - return LineFromRecords(decode(s.Store.GetUpwatch()).Line) + l, _, ok := s.line() + return l, ok +} + +func (s *Service) line() (mbps float64, tested, ok bool) { + passive, pok := LineFromRecords(decode(s.Store.GetUpwatch()).Line) + m, mok := s.MeasuredLine() + switch { + case mok && m >= passive: + return m, true, true + case pok: + return passive, false, true + } + return 0, false, false } // RecordPush stores one off-site push's measurement. @@ -82,7 +132,10 @@ func (s *Service) QBLimitNow(ctx context.Context) { if s.QB == nil || (s.QBRunning != nil && !s.QBRunning()) { return } - line, known := s.Line() + // D41 (operator 2026-10-24): 50 % of the TESTED line. The push floor is not used here — on demo-hp it read 10 Mbit/s + // on a 90 Mbit/s line, and half of that would have held a seeding qBittorrent at 5. No test yet → no limit. + s.RefreshMeasured(ctx) + line, known := s.MeasuredLine() cur := decode(s.Store.GetUpwatch()) var st QBLimitState if cur.QBLimit != nil { @@ -105,7 +158,7 @@ func (s *Service) QBLimitNow(ctx context.Context) { } switch act { case "set": - logx.Infof(s.Log, "[upwatch] qBittorrent upload limit set to %d B/s (%.0f %% of the measured line, %.1f Mbit/s)", ns.BoxSetBytes, s.QBShare*100, line) + logx.Infof(s.Log, "[upwatch] qBittorrent upload limit set to %d B/s (%.0f %% of the tested line, %.1f Mbit/s)", ns.BoxSetBytes, s.QBShare*100, line) case "household": if !st.Household { logx.Infof(s.Log, "[upwatch] qBittorrent's upload limit is the household's — the box leaves it alone from now on") @@ -117,6 +170,7 @@ func (s *Service) QBLimitNow(ctx context.Context) { type View struct { LineMbps float64 LineKnown bool + LineTested bool // LineMbps is the weekly test's (else the off-site push's floor, „at least") QBLimitBps int64 // the limit the box set and still owns; 0 = none shown } @@ -124,7 +178,7 @@ type View struct { func (s *Service) View() View { st := decode(s.Store.GetUpwatch()) v := View{} - v.LineMbps, v.LineKnown = LineFromRecords(st.Line) + v.LineMbps, v.LineTested, v.LineKnown = s.line() if st.QBLimit != nil && !st.QBLimit.Household { v.QBLimitBps = st.QBLimit.BoxSetBytes } diff --git a/controller/internal/upwatch/service_test.go b/controller/internal/upwatch/service_test.go new file mode 100644 index 0000000..ff76e18 --- /dev/null +++ b/controller/internal/upwatch/service_test.go @@ -0,0 +1,74 @@ +package upwatch + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" +) + +type memStore struct { + mu sync.Mutex + raw json.RawMessage +} + +func (m *memStore) GetUpwatch() json.RawMessage { m.mu.Lock(); defer m.mu.Unlock(); return m.raw } +func (m *memStore) UpdateUpwatch(fn func(json.RawMessage) (json.RawMessage, error)) error { + m.mu.Lock() + defer m.mu.Unlock() + n, err := fn(m.raw) + if err == nil { + m.raw = n + } + return err +} + +// D41 (operator 2026-10-24): qBittorrent gets 50 % of the host's TESTED line — never of the push floor, which read +// 10 Mbit/s on demo-hp's 90 Mbit/s line. The watch judges against the higher of the two. +// RED-PROOF: let QBLimitNow use s.Line() → the push floor alone sets a limit → fails; let line() ignore the test → +// Line() stays at the floor → fails. +func TestService_QBLimitFromTheTestedLineOnly(t *testing.T) { + logins, expire, limit := 0, false, int64(0) + srv := fakeQBServer(t, "pw-canary", &logins, &expire, &limit) + defer srv.Close() + st := &memStore{} + floor := LineRecord{Peak10Mbps: 10, Counts: true} + if err := st.UpdateUpwatch(func(json.RawMessage) (json.RawMessage, error) { return json.Marshal(State{Line: []LineRecord{floor}}) }); err != nil { + t.Fatal(err) + } + var tested float64 + at := time.Date(2026, 10, 24, 6, 31, 0, 0, time.UTC) + svc := &Service{Store: st, QB: &QBittorrent{Box: envBox{"qbittorrent/QBT_ADMIN_PASSWORD": "pw-canary"}, URL: srv.URL}, + QBShare: 0.5, Measured: func(context.Context) (float64, time.Time, bool) { return tested, at, tested > 0 }} + + svc.QBLimitNow(context.Background()) + if limit != 0 { + t.Fatalf("no test yet, push floor 10 Mbit/s: qBittorrent limited to %d B/s — the floor must not set a limit", limit) + } + if l, ok := svc.Line(); !ok || l != 10 { + t.Fatalf("watch line before a test = %.1f %v, want the push floor 10", l, ok) + } + if v := svc.View(); !v.LineKnown || v.LineTested || v.LineMbps != 10 { + t.Fatalf("view before a test = %+v", v) + } + + tested = 90.7 + svc.QBLimitNow(context.Background()) + if want := int64(5668750) / 1024 * 1024; limit != want { + t.Fatalf("tested 90.7 Mbit/s: limit %d B/s, want 50 %% = %d (stored in KiB/s)", limit, want) + } + if l, ok := svc.Line(); !ok || l != 90.7 { + t.Fatalf("watch line after the test = %.1f %v, want 90.7", l, ok) + } + if v := svc.View(); !v.LineTested || v.LineMbps != 90.7 || v.QBLimitBps != limit { + t.Fatalf("view after the test = %+v", v) + } + + // The agent stops answering: the last tested value stays (an agent restart must not drop the limit). + tested = 0 + svc.RefreshMeasured(context.Background()) + if l, ok := svc.MeasuredLine(); !ok || l != 90.7 { + t.Fatalf("after a failed read: %.1f %v, want the last value kept", l, ok) + } +} diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index e0cb1a6..cc13958 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -2361,6 +2361,7 @@ func (s *Server) systemPageData() map[string]interface{} { if s.upwatchView != nil { if v := s.upwatchView(); v.LineKnown { data["UplinkLine"] = upwatch.MbpsText(int64(v.LineMbps * 1e6 / 8)) + data["UplinkTested"] = v.LineTested } } data["GitRepoURL"] = s.cfg.Git.RepoURL diff --git a/controller/internal/web/i18n_cases_a_test.go b/controller/internal/web/i18n_cases_a_test.go index 4b0b1da..d092a46 100644 --- a/controller/internal/web/i18n_cases_a_test.go +++ b/controller/internal/web/i18n_cases_a_test.go @@ -251,6 +251,7 @@ func i18nCasesA() []i18nCase { d["LastUpdateState"] = map[string]interface{}{"Status": "failed", "Error": "pull failed"} d["MemorySupported"], d["MemoryReachable"], d["MemoryProcMB"] = true, false, 4096 d["PowerOffSupported"] = true + d["UplinkLine"], d["UplinkTested"] = "91", true // R-1247: the weekly test's line (_full: the push floor) return d }}, {"settings_system_pending", "settings_system", func() map[string]interface{} { diff --git a/controller/internal/web/templates/settings_system.html b/controller/internal/web/templates/settings_system.html index 3f4aaed..f154297 100644 --- a/controller/internal/web/templates/settings_system.html +++ b/controller/internal/web/templates/settings_system.html @@ -185,7 +185,7 @@
{{T "upwatch.line_label"}} - {{if .UplinkLine}}{{T "upwatch.line_at_least"}} {{.UplinkLine}} {{T "upwatch.line_measured"}}{{else}}{{T "upwatch.line_learning"}}{{end}} + {{if and .UplinkLine .UplinkTested}}{{.UplinkLine}} {{T "upwatch.line_tested"}}{{else if .UplinkLine}}{{T "upwatch.line_at_least"}} {{.UplinkLine}} {{T "upwatch.line_measured"}}{{else}}{{T "upwatch.line_learning"}}{{end}}

{{T "settings_system.ezek_az_ertekek_a_helyi"}}

diff --git a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html index 3274ba4..d075afa 100644 --- a/controller/internal/web/testdata/i18n_parity/settings_system_failed.html +++ b/controller/internal/web/testdata/i18n_parity/settings_system_failed.html @@ -305,7 +305,7 @@
Feltöltési sebesség - még mérjük (az éjszakai távoli mentésből) + 91 Mbit/s (heti sebességméréssel)

Ezek az értékek a helyi hálózattól függenek és változhatnak. Távoli hibaelhárításnál erről az oldalról olvasd fel őket.

diff --git a/controller/internal/web/upwatch_lines_test.go b/controller/internal/web/upwatch_lines_test.go index bdec806..5f343c9 100644 --- a/controller/internal/web/upwatch_lines_test.go +++ b/controller/internal/web/upwatch_lines_test.go @@ -53,3 +53,28 @@ func TestUpwatch_QBLimitLineOnQBittorrentsPageOnly(t *testing.T) { t.Fatal("no view wired, yet a line") } } + +// The system page's upload line names its source: the weekly test („heti sebességméréssel") or the off-site push's floor +// („legalább … az éjszakai távoli mentésből"), in the reader's language. +// RED-PROOF: drop `data["UplinkTested"]` in systemPageData → the tested case reads „legalább" → fails. +func TestUpwatch_SystemPageNamesTheLinesSource(t *testing.T) { + s, _ := credsHarness(t) + s.loadTemplates() + render := func(lang string) string { + rr := httptest.NewRecorder() + s.settingsHandler(rr, httptest.NewRequest("GET", "/settings?lang="+lang, nil)) + return asciiFold(html.UnescapeString(rr.Body.String())) + } + view := upwatch.View{LineMbps: 90.7, LineKnown: true, LineTested: true} + s.SetUpwatchView(func() upwatch.View { return view }) + if hu := render("hu"); !strings.Contains(hu, "heti sebessegmeressel") || strings.Contains(hu, "legalabb