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
+11
View File
@@ -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`,
+7
View File
@@ -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
+1
View File
@@ -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 |
+5 -3
View File
@@ -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)
+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)
+38
View File
@@ -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
}
@@ -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)
}
}
+1
View File
@@ -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:",
+1
View File
@@ -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:",
+2 -1
View File
@@ -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.
+60 -6
View File
@@ -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
}
@@ -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)
}
}
+1
View File
@@ -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
@@ -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{} {
@@ -185,7 +185,7 @@
</div>
<div class="settings-row" data-uplink>
<span class="settings-label">{{T "upwatch.line_label"}}</span>
<span class="settings-value">{{if .UplinkLine}}{{T "upwatch.line_at_least"}} <span class="mono">{{.UplinkLine}}</span> {{T "upwatch.line_measured"}}{{else}}<span style="color:var(--text-3);">{{T "upwatch.line_learning"}}</span>{{end}}</span>
<span class="settings-value">{{if and .UplinkLine .UplinkTested}}<span class="mono">{{.UplinkLine}}</span> {{T "upwatch.line_tested"}}{{else if .UplinkLine}}{{T "upwatch.line_at_least"}} <span class="mono">{{.UplinkLine}}</span> {{T "upwatch.line_measured"}}{{else}}<span style="color:var(--text-3);">{{T "upwatch.line_learning"}}</span>{{end}}</span>
</div>
</div>
<p class="settings-card-desc" style="margin-top:0.5em;">{{T "settings_system.ezek_az_ertekek_a_helyi"}}</p>
@@ -305,7 +305,7 @@
</div>
<div class="settings-row" data-uplink>
<span class="settings-label">Feltöltési sebesség</span>
<span class="settings-value"><span style="color:var(--text-3);">még mérjük (az éjszakai távoli mentésből)</span></span>
<span class="settings-value"><span class="mono">91</span> Mbit/s (heti sebességméréssel)</span>
</div>
</div>
<p class="settings-card-desc" style="margin-top:0.5em;">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.</p>
@@ -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 <span") {
t.Fatal("tested line: the Hungarian system page does not say „heti sebességméréssel\"")
}
if en := render("en"); !strings.Contains(en, "Mbit/s (measured weekly)") {
t.Fatal("tested line: the English system page does not say „measured weekly\"")
}
view = upwatch.View{LineMbps: 10, LineKnown: true}
if hu := render("hu"); !strings.Contains(hu, "legalabb") || !strings.Contains(hu, "ejszakai tavoli mentesbol") || strings.Contains(hu, "heti sebessegmeressel") {
t.Fatal("push floor: the Hungarian system page does not say „legalább … az éjszakai távoli mentésből\"")
}
}