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
gates / gates (push) Successful in 1m15s
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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:",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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\"")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user