hub v0.64.0 — offsite pool-box aggregate: fill, oversubscription, per-customer bars, operator alert (R-5)

The operator sees the shared pool box's real state on the hub: total box fill vs
capacity, Σ(shared soft quotas) vs capacity (the oversubscription ratio), per-customer
usage/quota bars, and a box-level operator alert (fill % + oversub ratio) on the existing
dispatcher's operator channel. Per-customer fill alerts already existed; the box-level
aggregate was the gap. READ-ONLY against Hetzner (GET only).

Phase-0 probe (gate PASSED): the live pool box 611714 returns capacity via
storage_box_type.size (1 TiB / bx11) and usage via a stats object (size/size_data/
size_snapshots), all bytes; our token reads it (200).

- hetznerapi: additive StorageBoxType + StorageBoxStats on StorageBox (no existing field/
  method changed); fake carries them + a GetBoxCalls counter; golden decode test.
- monitor.OffsiteBoxChecker: OffsiteChecker-sibling for the box; fetch-throttled (1 GET/
  15min), cached BoxSnapshot, escalation-only + recovery re-arm. FILL (used/capacity 80/90)
  + OVERSUB (Σ shared+enabled quotas / capacity, 2.0x) — independent. Σ from the ConfigJSON
  Descriptor (offsite.ReadDescriptor, new), never the report echo; dedicated+disabled
  excluded. Scope "pool-box" -> operator channel only, no SaveEvent. Failed fetch keeps the
  last snapshot degraded; missing data never becomes 0% and never transitions a band.
- config: Alerting.OffsiteBoxFill{Warn,Crit}Percent + OffsiteOversubWarnRatio (80/90/2.0
  defaults; thresholds pending Viktor's ruling). Constructed in the HETZNER_TOKEN branch,
  60s sweep, snapshot handed to the web server.
- web: Offsite-tab panel (fill bar, Σ+ratio, per-customer usage/quota rows) + a compact
  dashboard tile; reads the cached snapshot only, never fetches; nil -> "not configured".

Tests: 10 new + 4 red-proofs (throttle, Σ filter, escalation-only, failed-fetch honesty),
all confirmed red then restored. go build/vet/test all pass; hub confirm gate OK.
This commit is contained in:
2026-07-17 20:15:34 +02:00
parent 85a14192e7
commit 4bb2df0dc4
17 changed files with 1034 additions and 17 deletions
@@ -0,0 +1,85 @@
package web
import (
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/monitor"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
const gib = int64(1) << 30
// Not configured (no HETZNER_TOKEN) → honest "not configured", never an error or fake zeros.
// (Uses renderOffsite from offsite_test.go — the real handler via httptest.)
func TestOffsiteBoxPanel_NotConfigured(t *testing.T) {
s, _ := newRenderServer(t)
// s.offsiteBox left nil
body := renderOffsite(t, s)
if !strings.Contains(body, "Offsite pool metrics not configured") {
t.Fatalf("unconfigured panel must say 'not configured':\n%s", body)
}
if strings.Contains(body, "0.0 GB") {
t.Fatal("unconfigured panel must not render fake zeros")
}
}
// With data + per-customer rows: the panel renders capacity/fill/ratio; a shared customer with a report
// shows usage, a customer with NO report shows 'no usage reported yet' (never an asserted 0 GB).
func TestOffsiteBoxPanel_WithData(t *testing.T) {
s, st := newRenderServer(t)
s.SetOffsiteBox(func() (monitor.BoxSnapshot, bool) {
return monitor.BoxSnapshot{
CapacityBytes: 1 << 40, UsedBytes: 300 * gib, DataBytes: 300 * gib,
FillPercent: 27, FillBand: "ok", SumQuotaGB: 1200, Ratio: 1.17, OversubBand: "ok",
BoxType: "bx11", FetchedAt: time.Now().UTC(),
}, true
})
// "acme": shared, enabled, 500 GB, WITH a report (200 GiB used). "newbie": shared, enabled, no report.
saveCfg(t, st, "acme", `{"offsite":{"enabled":true,"type":"shared","quota_gb":500}}`)
saveCfg(t, st, "newbie", `{"offsite":{"enabled":true,"type":"shared","quota_gb":300}}`)
if err := st.SaveReport("acme", []byte(`{"customer_id":"acme","offsite":{"enabled":true,"repo_size_bytes":`+itoa(200*gib)+`}}`)); err != nil {
t.Fatal(err)
}
body := renderOffsite(t, s)
for _, want := range []string{"Offsite pool box", "1.00 TB", "1.17×", "acme", "newbie", "no usage reported yet"} {
if !strings.Contains(body, want) {
t.Fatalf("panel missing %q:\n%s", want, body)
}
}
if strings.Contains(body, "not configured") {
t.Fatal("a configured panel must not show the not-configured message")
}
}
func saveCfg(t *testing.T, st *store.Store, id, cfgJSON string) {
t.Helper()
if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: id, CustomerName: id, APIKey: "k-" + id, RetrievalPassword: "p", ConfigJSON: cfgJSON}); err != nil {
t.Fatalf("save config %s: %v", id, err)
}
}
// itoa avoids importing strconv just for the report body.
func itoa(v int64) string {
if v == 0 {
return "0"
}
neg := v < 0
if neg {
v = -v
}
var b [20]byte
i := len(b)
for v > 0 {
i--
b[i] = byte('0' + v%10)
v /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}