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
+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)
}
}