diff --git a/controller/internal/backup/recovery_unit.go b/controller/internal/backup/recovery_unit.go index f2028e7..d48b6c0 100644 --- a/controller/internal/backup/recovery_unit.go +++ b/controller/internal/backup/recovery_unit.go @@ -416,7 +416,7 @@ func (m *Manager) floorVerdict(u *UnitSpace, estGiB float64) (*UnitSpace, floorR availAfter := u.AvailGB - estGiB usedAfter := u.UsedPercent if u.TotalGB > 0 { - usedAfter = (u.UsedGB + estGiB) / u.TotalGB * 100 + usedAfter = system.DFUsedPercent(u.UsedGB+estGiB, u.AvailGB-estGiB) // R-889: df's number after the write } if availAfter < FloorFreeGiB || usedAfter >= FloorUsedPercent { return u, floorSize diff --git a/controller/internal/i18n/locales/en.json b/controller/internal/i18n/locales/en.json index 924b321..46a5da7 100644 --- a/controller/internal/i18n/locales/en.json +++ b/controller/internal/i18n/locales/en.json @@ -630,7 +630,7 @@ "common.magyar_felulet": "Hungarian interface", "common.megsem": "Cancel", "common.pi_kompatibilis": "Runs on Pi", - "common.rendszer_gyoker": "System (/)", + "common.rendszer_gyoker": "System", "common.torles": "Delete", "common.ugyfel_azonosito": "Customer ID", "common.ujrainditas": "Restart", diff --git a/controller/internal/i18n/locales/hu.json b/controller/internal/i18n/locales/hu.json index fe539c1..d7f2fb4 100644 --- a/controller/internal/i18n/locales/hu.json +++ b/controller/internal/i18n/locales/hu.json @@ -625,7 +625,7 @@ "common.magyar_felulet": "Magyar felület", "common.megsem": "Mégsem", "common.pi_kompatibilis": "Pi kompatibilis", - "common.rendszer_gyoker": "Rendszer (/)", + "common.rendszer_gyoker": "Rendszer", "common.torles": "Törlés", "common.ugyfel_azonosito": "Ügyfél azonosító", "common.ujrainditas": "Újraindítás", diff --git a/controller/internal/system/dfpercent.go b/controller/internal/system/dfpercent.go new file mode 100644 index 0000000..b639735 --- /dev/null +++ b/controller/internal/system/dfpercent.go @@ -0,0 +1,20 @@ +package system + +// DFUsedPercent is the used percentage the way `df` prints it: used / (used + available). The blocks reserved for root +// (5 % on ext4 by default) are in neither term, so a full disk reads 100 % here. Every disk percent the box shows or +// alarms on is computed by this one function (R-889, operator ruling 2026-10-06 — `09` §3 decision 138): until then +// the box divided by the whole filesystem and read ~5 points LOW on a full disk (61.8 of 68.7 GB = 90 % where `df` +// said 95 %). `df` rounds up; this does not, so the two agree within one point. Units only need to match. +// Pinned by TestDFUsedPercent_MatchesDFOnRealNumbers and TestR889_EveryDiskPercentIsTheDFOne. +func DFUsedPercent(used, avail float64) float64 { + if used <= 0 && avail <= 0 { + return 0 + } + if used < 0 { + used = 0 + } + if avail < 0 { + avail = 0 + } + return used / (used + avail) * 100 +} diff --git a/controller/internal/system/dfpercent_test.go b/controller/internal/system/dfpercent_test.go new file mode 100644 index 0000000..e0d8ffd --- /dev/null +++ b/controller/internal/system/dfpercent_test.go @@ -0,0 +1,68 @@ +package system + +import ( + "math" + "os" + "path/filepath" + "regexp" + "strings" + "testing" +) + +// R-889 — real numbers, measured 2026-10-06 07:55 on demo-hp guest 9201 (`df -P` + `stat -f`): +// +// /mnt/sys_drive df: 72064432 1K-blocks, used 15460836, avail 52917208, Use% 23 +// stat -f: bsize=4096 blocks=18016108 free=14150899 avail=13229302 +// +// The old formula ((blocks-free)/blocks) gave 21.5 % there; df gives 23 %. And the R-516 (11) shape: 61.8 of 68.7 GB +// with 5 % reserved read 90 % where df read 95 %. +func TestDFUsedPercent_MatchesDFOnRealNumbers(t *testing.T) { + const bs = 4096.0 + blocks, free, avail := 18016108.0, 14150899.0, 13229302.0 + used := (blocks - free) * bs + got := DFUsedPercent(used, avail*bs) + if math.Ceil(got) != 23 { + t.Fatalf("demo-hp /mnt/sys_drive: DFUsedPercent = %.2f, df prints 23%% (it rounds up)", got) + } + old := (blocks - free) / blocks * 100 + if math.Abs(got-old) < 1 { + t.Fatalf("the df number (%.2f) must differ from the old used/total (%.2f) on a disk with reserved blocks", got, old) + } + // R-516 (11): a 68.7 GB filesystem with 5 % reserved, 61.8 GB used. + total, usedGB := 68.7, 61.8 + availGB := total*0.95 - usedGB + if p := DFUsedPercent(usedGB, availGB); math.Round(p) != 95 { + t.Fatalf("the R-516 shape reads %.1f %%, df read 95 %%", p) + } + if DFUsedPercent(0, 0) != 0 || DFUsedPercent(10, 0) != 100 { + t.Fatal("empty → 0, nothing available → 100") + } +} + +// Every disk percent in the controller is the df one: no production Go file divides a used/avail figure by the whole +// filesystem any more. Red-proof: put `float64(used) / float64(total) * 100` back in mounts_linux.go — this fails. +func TestR889_EveryDiskPercentIsTheDFOne(t *testing.T) { + bad := regexp.MustCompile(`(?:/\s*float64\(total\)\s*\*\s*100|/\s*[A-Za-z_.]*TotalGB\s*\*\s*100)`) + root := filepath.Join("..", "..") + var hits []string + for _, dir := range []string{"internal", "cmd"} { + _ = filepath.Walk(filepath.Join(root, dir), func(p string, info os.FileInfo, err error) error { + if err != nil || info.IsDir() || !strings.HasSuffix(p, ".go") || strings.HasSuffix(p, "_test.go") { + return nil + } + b, rerr := os.ReadFile(p) + if rerr != nil { + return nil + } + for i, line := range strings.Split(string(b), "\n") { + if bad.MatchString(line) && !strings.HasPrefix(strings.TrimSpace(line), "//") { + hits = append(hits, p+":"+itoa(uint64(i+1))+": "+strings.TrimSpace(line)) + } + } + return nil + }) + } + if len(hits) > 0 { + t.Fatalf("a disk percent divides by the whole filesystem (not df's used/(used+avail)) — use DFUsedPercent:\n%s", strings.Join(hits, "\n")) + } +} diff --git a/controller/internal/system/info_linux.go b/controller/internal/system/info_linux.go index 209771c..12a9a10 100644 --- a/controller/internal/system/info_linux.go +++ b/controller/internal/system/info_linux.go @@ -279,7 +279,7 @@ func readDiskUsage(path string, totalGB, usedGB, availGB *float64, percent *floa *usedGB = float64(used) / gb *availGB = float64(avail) / gb if total > 0 { - *percent = float64(used) / float64(total) * 100 + *percent = DFUsedPercent(float64(used), float64(avail)) // R-889: df's number } debugf("[DEBUG] [system] readDiskUsage: path=%q bsize=%d total=%.1fGB used=%.1fGB avail=%.1fGB (%.1f%%)", path, bsize, *totalGB, *usedGB, *availGB, *percent) diff --git a/controller/internal/system/mounts_linux.go b/controller/internal/system/mounts_linux.go index 18bcac5..5ce6bd6 100644 --- a/controller/internal/system/mounts_linux.go +++ b/controller/internal/system/mounts_linux.go @@ -82,7 +82,7 @@ func GetDiskUsage(path string) *DiskUsageInfo { AvailGB: float64(avail) / float64(gb), } if total > 0 { - info.UsedPercent = float64(used) / float64(total) * 100 + info.UsedPercent = DFUsedPercent(float64(used), float64(avail)) // R-889: df's number } info.TotalHuman = formatGB(info.TotalGB) info.UsedHuman = formatGB(info.UsedGB) diff --git a/controller/internal/web/handlers.go b/controller/internal/web/handlers.go index bd1b191..ce4e895 100644 --- a/controller/internal/web/handlers.go +++ b/controller/internal/web/handlers.go @@ -569,7 +569,7 @@ func (s *Server) deployHandler(w http.ResponseWriter, r *http.Request, name stri if di := system.GetDiskUsage(sp.Path); di != nil { dp.FreeHuman = formatFreeSpace(di.AvailGB) if di.TotalGB > 0 { - dp.FreePercent = di.AvailGB / di.TotalGB * 100 + dp.FreePercent = 100 - system.DFUsedPercent(di.UsedGB, di.AvailGB) // R-889: df's number } } deployPaths = append(deployPaths, dp) diff --git a/controller/internal/web/testdata/i18n_parity/backups_full.html b/controller/internal/web/testdata/i18n_parity/backups_full.html index cc92eca..70d2d69 100644 --- a/controller/internal/web/testdata/i18n_parity/backups_full.html +++ b/controller/internal/web/testdata/i18n_parity/backups_full.html @@ -223,7 +223,7 @@
- Rendszer (/) + Rendszer 42.5 GB / 100 GB (42%)
diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html b/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html index 3d476d6..0185db1 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_diskcrit.html @@ -224,7 +224,7 @@
- Rendszer (/) + Rendszer 97.0 GB / 100 GB (97%)
diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_full.html b/controller/internal/web/testdata/i18n_parity/dashboard_full.html index c4e4ce7..3b99bb6 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_full.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_full.html @@ -224,7 +224,7 @@
- Rendszer (/) + Rendszer 80.0 GB / 100 GB (80%)
diff --git a/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html b/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html index 21ad230..bf2b9b5 100644 --- a/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html +++ b/controller/internal/web/testdata/i18n_parity/dashboard_sparse.html @@ -216,7 +216,7 @@
- Rendszer (/) + Rendszer — nem ismert
diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html index c9a367e..4a6e276 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_down.html @@ -246,7 +246,7 @@
- Rendszer (/) + Rendszer 40.0 GB / 100 GB (40%)
diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html index 4cd8ceb..8f1b08c 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_off.html @@ -246,7 +246,7 @@
- Rendszer (/) + Rendszer 40.0 GB / 100 GB (40%)
diff --git a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html index a58ab0f..efd07df 100644 --- a/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html +++ b/controller/internal/web/testdata/i18n_parity/monitoring_hub_ok.html @@ -246,7 +246,7 @@
- Rendszer (/) + Rendszer 40.0 GB / 100 GB (40%)