Files
felhom-controller/controller/internal/upwatch/service.go
T
admin a9c1997fbc
gates / gates (push) Successful in 1m17s
v0.342.0 (unreleased): the upload and CPU watch — an app that sends or burns far more than its own numbers explain is one operator-only alarm; the line learnt from the off-site push; qBittorrent's default upload limit (never over the household's)
internal/upwatch: exact per-container counters from the Docker Engine API, per-app judging (upload >= max(5 Mbit/s,
25% of the line) for 15 min; CPU >= max(150%, 50% x cores) for 30 min), explainers qBittorrent / Transmission / Plex,
app_unexplained_upload / app_unexplained_cpu + _cleared (hub 0.169.0). The off-site push is sampled once a second
for the line (a lower bound). qBittorrent limit = 50% of the line, the household's limit always wins. 20 red-proofs.
Spike + design: felhom.eu audits/SPIKE-upload-watch-2026-10-23.md, architecture/08 section 9. Not released.
2026-10-23 21:26:09 +02:00

156 lines
4.4 KiB
Go

package upwatch
import (
"context"
"encoding/json"
"log"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/logx"
)
// State is what the watch keeps in settings.json (`upwatch`).
type State struct {
Line []LineRecord `json:"line,omitempty"`
QBLimit *QBLimitState `json:"qb_limit,omitempty"`
}
// Store is the settings half the service needs (settings.Settings satisfies it).
type Store interface {
GetUpwatch() json.RawMessage
UpdateUpwatch(fn func(old json.RawMessage) (json.RawMessage, error)) error
}
func decode(raw json.RawMessage) State {
var st State
if len(raw) > 0 {
_ = json.Unmarshal(raw, &st) // a damaged block reads as empty: the line is re-learnt, the limit record too
}
return st
}
// Service ties the watch together for main: the minute watcher, the push sampler, the qBittorrent limit rule and
// what the pages show.
type Service struct {
Store Store
Watcher *Watcher
QB *QBittorrent // nil when the box has no qBittorrent wiring
// QBRunning reports whether qBittorrent is installed and running now.
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
}
// Line is the measured upload line in Mbit/s (a lower bound); ok=false while no push counted.
func (s *Service) Line() (float64, bool) {
return LineFromRecords(decode(s.Store.GetUpwatch()).Line)
}
// RecordPush stores one off-site push's measurement.
func (s *Service) RecordPush(r LineRecord) {
err := s.Store.UpdateUpwatch(func(old json.RawMessage) (json.RawMessage, error) {
st := decode(old)
st.Line = AddRecord(st.Line, r)
return json.Marshal(st)
})
if err != nil {
logx.Warnf(s.Log, "[upwatch] saving the push measurement: %v", err)
return
}
l, ok := s.Line()
logx.Infof(s.Log, "[upwatch] off-site push measured: %d bytes in %d s, best 10 s %.1f Mbit/s (counts=%v); line now %s",
r.Bytes, r.Seconds, r.Peak10Mbps, r.Counts, lineWords(l, ok))
}
func lineWords(l float64, ok bool) string {
if !ok {
return "unknown (learning)"
}
return "at least " + formatMbps(l) + " Mbit/s"
}
// PushObserver is handed to the off-site backup: it is called at the push's start and returns the function that
// ends the measurement.
func (s *Service) PushObserver() func() {
stop := (&PushSampler{}).Start()
return func() { s.RecordPush(stop()) }
}
// QBLimitNow runs qBittorrent's limit rule once (main calls it every 15 minutes; a no-op without qBittorrent).
func (s *Service) QBLimitNow(ctx context.Context) {
if s.QB == nil || (s.QBRunning != nil && !s.QBRunning()) {
return
}
line, known := s.Line()
cur := decode(s.Store.GetUpwatch())
var st QBLimitState
if cur.QBLimit != nil {
st = *cur.QBLimit
}
ns, act, err := ApplyQBLimit(ctx, s.QB, st, line, known, s.QBShare, time.Now())
if err != nil {
logx.Debugf(s.Log, "[upwatch] qBittorrent limit: %v — tried again in 15 minutes", err)
return
}
if ns != st {
if err := s.Store.UpdateUpwatch(func(old json.RawMessage) (json.RawMessage, error) {
o := decode(old)
o.QBLimit = &ns
return json.Marshal(o)
}); err != nil {
logx.Warnf(s.Log, "[upwatch] saving the qBittorrent limit record: %v", err)
return
}
}
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)
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")
}
}
}
// View is what the pages show.
type View struct {
LineMbps float64
LineKnown bool
QBLimitBps int64 // the limit the box set and still owns; 0 = none shown
}
// View reads the state for the pages.
func (s *Service) View() View {
st := decode(s.Store.GetUpwatch())
v := View{}
v.LineMbps, v.LineKnown = LineFromRecords(st.Line)
if st.QBLimit != nil && !st.QBLimit.Household {
v.QBLimitBps = st.QBLimit.BoxSetBytes
}
return v
}
// MbpsText renders bytes/s as Mbit/s for a page line (one decimal under 10).
func MbpsText(bps int64) string { return formatMbps(float64(bps) * 8 / 1e6) }
func formatMbps(v float64) string {
if v < 10 {
return trimZero(v)
}
return itoa(int64(v + 0.5))
}
func trimZero(v float64) string {
x := int64(v*10 + 0.5)
if x%10 == 0 {
return itoa(x / 10)
}
return itoa(x/10) + "." + itoa(x%10)
}
func itoa(v int64) string {
b, _ := json.Marshal(v)
return string(b)
}