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)
gates / gates (push) Successful in 1m17s

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.
This commit is contained in:
2026-10-23 21:26:09 +02:00
parent 277e99839d
commit a9c1997fbc
35 changed files with 2438 additions and 0 deletions
+14
View File
@@ -27,6 +27,7 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
"golang.org/x/crypto/bcrypt"
)
@@ -804,6 +805,13 @@ func (s *Server) appCommonData(w http.ResponseWriter, r *http.Request, slug stri
if lost, at := lostFilesNote(found.Deployed, found.AppConfig); len(lost) > 0 {
data["LostFiles"], data["LostBootAt"] = lost, at
}
// The upload watch (internal/upwatch): qBittorrent's limit, while the box owns it — one line the household can act on
// in qBittorrent itself. A limit the household set is theirs and is not described here.
if found.Meta.Slug == "qbittorrent" && found.Deployed && s.upwatchView != nil {
if v := s.upwatchView(); v.QBLimitBps > 0 {
data["QBLimitNote"] = s.msgLang(s.langFor(r), "upwatch.qb_limit_note", upwatch.MbpsText(v.QBLimitBps))
}
}
// v0.280.0 (decision 46): the setup gate's card, while the gate stands.
data["SetupGateClosed"] = found.Deployed && found.AppConfig != nil && found.AppConfig.SetupGate.Closed()
data["SetupGateHasProbe"] = found.Meta.SetupDoneProbe != nil && found.Meta.SetupDoneProbe.URL != ""
@@ -2349,6 +2357,12 @@ func (s *Server) settingsBaseData(page, title string) map[string]interface{} {
func (s *Server) systemPageData() map[string]interface{} {
data := s.settingsBaseData("settings", "Beállítások")
data["TitleKey"] = "page.title.settings" // i18n: the Hungarian title above is what hu renders
// The upload watch's measured line (a lower bound, learnt from the nightly off-site push); "" = still learning.
if s.upwatchView != nil {
if v := s.upwatchView(); v.LineKnown {
data["UplinkLine"] = upwatch.MbpsText(int64(v.LineMbps * 1e6 / 8))
}
}
data["GitRepoURL"] = s.cfg.Git.RepoURL
data["GitSyncInterval"] = s.cfg.Git.SyncInterval
data["BackupEnabled"] = s.cfg.Backup.Enabled
@@ -239,6 +239,7 @@ func i18nCasesA() []i18nCase {
d["MemorySupported"], d["MemoryReachable"] = true, true
d["MemoryAllocatedMB"], d["MemoryUsageMB"], d["MemoryMinMB"], d["MemoryMaxMB"] = 4096, 2048, 2048, 8192
d["PowerOffSupported"] = true // R-998: the power-off card with its button
d["UplinkLine"] = "47" // the upload watch's measured line (the other three cases: still learning)
return d
}},
{"settings_system_failed", "settings_system", func() map[string]interface{} {
+7
View File
@@ -36,9 +36,12 @@ import (
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"gitea.dooplex.hu/admin/felhom-controller/internal/system"
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
)
type Server struct {
// upwatchView (SetUpwatchView) feeds the two upload-watch lines; nil = not shown.
upwatchView func() upwatch.View
// R-871: the missed-backup banner's inputs (SetMissedBackupBanner); nil = no banner.
nightLedger *nightchain.Ledger
metricsRecord *metrics.MetricsStore
@@ -515,6 +518,10 @@ func (s *Server) addShellAssetData(data map[string]interface{}) {
// so a cache that already held the first build's stylesheet kept serving it — measured through
// Cloudflare (`audits/app-page-2026-10-15b/P0/VERDICT.md`). build.sh now refuses a second push of a
// version, and this makes the asset URL change even when the version does not.
// SetUpwatchView hands the pages the upload watch's state (internal/upwatch): the measured line and the qBittorrent
// limit the box set. Nil = neither line is shown.
func (s *Server) SetUpwatchView(fn func() upwatch.View) { s.upwatchView = fn }
func (s *Server) SetBuildIdentity(gitCommit, buildTime string) {
s.buildID = gitCommit + "/" + buildTime
}
@@ -79,6 +79,9 @@
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if .QBLimitNote}}
<div class="alert alert-info" style="margin-top:1rem" data-qb-limit>{{.QBLimitNote}}</div>
{{- end}}
{{if .Stack.Updating}}
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}}
@@ -183,6 +183,10 @@
<span class="settings-label">{{T "settings_system.atjaro"}}</span>
<span class="settings-value mono">{{if .NetGateway}}{{.NetGateway}}{{else}}— <span style="color:var(--text-3);">{{T "settings_system.nem_allapithato_meg"}}</span>{{end}}</span>
</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>
</div>
</div>
<p class="settings-card-desc" style="margin-top:0.5em;">{{T "settings_system.ezek_az_ertekek_a_helyi"}}</p>
</div>
@@ -303,6 +303,10 @@
<span class="settings-label">Átjáró</span>
<span class="settings-value mono">— <span style="color:var(--text-3);">nem állapítható meg</span></span>
</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>
</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>
</div>
@@ -348,6 +348,10 @@
<span class="settings-label">Átjáró</span>
<span class="settings-value mono">192.168.1.1</span>
</div>
<div class="settings-row" data-uplink>
<span class="settings-label">Feltöltési sebesség</span>
<span class="settings-value">legalább <span class="mono">47</span> Mbit/s (az éjszakai távoli mentésből mérve)</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>
</div>
@@ -260,6 +260,10 @@
<span class="settings-label">Átjáró</span>
<span class="settings-value mono">— <span style="color:var(--text-3);">nem állapítható meg</span></span>
</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>
</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>
</div>
@@ -293,6 +293,10 @@
<span class="settings-label">Átjáró</span>
<span class="settings-value mono">— <span style="color:var(--text-3);">nem állapítható meg</span></span>
</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>
</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>
</div>
@@ -0,0 +1,55 @@
package web
import (
"html"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-controller/internal/upwatch"
)
// The upload watch's household line: qBittorrent's page says the limit while the BOX owns it — in the reader's
// language — and says nothing when the household owns it (the view then carries 0), when no view is wired, or on
// another app's page.
// RED-PROOF: drop the `.QBLimitNote` card from app_shell.html → the first case fails; drop the slug check in the
// handler → crafty's page carries the line → fails.
func TestUpwatch_QBLimitLineOnQBittorrentsPageOnly(t *testing.T) {
s, _ := credsHarness(t)
sd := filepath.Join(s.cfg.Paths.StacksDir, "qbittorrent")
if err := os.MkdirAll(sd, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(sd, ".felhom.yml"), []byte("display_name: qBittorrent\nslug: qbittorrent\n"), 0o644)
os.WriteFile(filepath.Join(sd, "docker-compose.yml"), []byte("services: {}\n"), 0o644)
os.WriteFile(filepath.Join(sd, "app.yaml"), []byte("deployed: true\n"), 0o644)
_ = s.stackMgr.ScanStacks()
s.loadTemplates()
render := func(app, lang string) string {
rr := httptest.NewRecorder()
s.appPageHandler(rr, httptest.NewRequest("GET", "/apps/"+app+"?lang="+lang, nil), app, "")
return html.UnescapeString(rr.Body.String())
}
view := upwatch.View{LineMbps: 100, LineKnown: true, QBLimitBps: 6249472}
s.SetUpwatchView(func() upwatch.View { return view })
hu, en := asciiFold(render("qbittorrent", "hu")), render("qbittorrent", "en")
if !strings.Contains(hu, "data-qb-limit") || !strings.Contains(hu, "legfeljebb 50 Mbit/s") || !strings.Contains(hu, "atallithatod") {
t.Fatalf("the Hungarian qBittorrent page lacks the limit line")
}
if !strings.Contains(en, "uploads at most 50 Mbit/s") {
t.Fatalf("the English qBittorrent page lacks the limit line")
}
if strings.Contains(render("crafty", "hu"), "data-qb-limit") {
t.Fatal("another app's page carries qBittorrent's limit line")
}
view.QBLimitBps = 0 // the household owns it
if strings.Contains(render("qbittorrent", "hu"), "data-qb-limit") {
t.Fatal("a household-owned limit is described as the box's")
}
s.upwatchView = nil
if strings.Contains(render("qbittorrent", "hu"), "data-qb-limit") {
t.Fatal("no view wired, yet a line")
}
}