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
+193
View File
@@ -0,0 +1,193 @@
package web
// Offsite pool-box aggregate surfaces (v0.64.0, R-5): the Offsite-tab panel + the Dashboard tile.
// The web layer NEVER fetches from Hetzner — it reads the checker's cached snapshot (s.offsiteBox) and
// composes per-customer rows from state the hub already holds (quota from the ConfigJSON Descriptor,
// usage from the controller report echo).
import (
"encoding/json"
"fmt"
"sort"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/offsite"
)
// offsiteBoxView is the pool-box panel model. Every display value is composed handler-side; band
// strings ("ok"/"warning"/"critical") drive the bar color via templates.
type offsiteBoxView struct {
Configured bool // s.offsiteBox wired (HETZNER_TOKEN + box id present)
Pending bool // configured but the first refresh hasn't landed yet
Degraded bool // last fetch failed / zero capacity — values shown stale
CapacityStr string
UsedStr string
DataStr string
SnapshotStr string
FillPercent float64
FillBand string
SumQuotaStr string
RatioStr string // "1.17×"
OversubBand string
BoxType string
FetchedAt time.Time
}
// offsiteCustomerRow is one per-customer usage/quota row under the panel.
type offsiteCustomerRow struct {
CustomerName string
Dedicated bool
QuotaStr string // "" for dedicated
UsageStr string // "" when NoUsage
UsageBytes int64 // sort key
UsagePercent float64 // usage/quota (shared only)
UsageBand string // ok|warning|critical for the bar
HasBar bool // shared + quota>0 + has usage → render a bar
NoUsage bool // no offsite object in the report yet
}
// fmtBytesGB renders bytes as GB (one decimal) below 1 TB, TB (two decimals) above.
func fmtBytesGB(b int64) string {
if b >= 1<<40 {
return fmt.Sprintf("%.2f TB", float64(b)/float64(int64(1)<<40))
}
return fmt.Sprintf("%.1f GB", float64(b)/float64(int64(1)<<30))
}
// pctBand maps a percent to a band with the given warn/crit (web-side twin of monitor.bandForPercent —
// unexported there; a 3-line copy keeps web from depending on monitor internals).
func pctBand(pct, warn, crit float64) string {
switch {
case pct >= crit:
return "critical"
case pct >= warn:
return "warning"
default:
return "ok"
}
}
// offsiteUsageBytes reads repo_size_bytes from a customer's report `offsite` object — the ONLY place the
// live repo size exists. ok=false when the report carries no offsite object (never reported). Quota is
// NEVER taken from here (the report echo would undercount promises) — only usage. Twin of monitor's
// parseOffsite, scoped to the one field the rows need.
func offsiteUsageBytes(reportJSON string) (int64, bool) {
var r struct {
Offsite *struct {
RepoSizeBytes int64 `json:"repo_size_bytes"`
} `json:"offsite"`
}
if json.Unmarshal([]byte(reportJSON), &r) != nil || r.Offsite == nil {
return 0, false
}
return r.Offsite.RepoSizeBytes, true
}
// offsiteTile is the compact Dashboard tile: fill% + ratio, band-colored, linking to /offsite.
type offsiteTile struct {
FillPercent float64
RatioStr string
Band string // worst of fill/oversub → tile color
Degraded bool
}
// offsiteBoxTile builds the Dashboard tile, or nil when there is nothing to show (no provider, or the
// first refresh hasn't landed) — the caller then renders NO tile (never a zeroed one).
func (s *Server) offsiteBoxTile() *offsiteTile {
if s.offsiteBox == nil {
return nil
}
snap, ok := s.offsiteBox()
if !ok {
return nil // configured but no snapshot yet — absent, not a zeroed tile
}
band := "ok"
switch {
case snap.FillBand == "critical":
band = "critical"
case snap.FillBand == "warning" || snap.OversubBand == "warning":
band = "warning"
}
return &offsiteTile{
FillPercent: snap.FillPercent,
RatioStr: fmt.Sprintf("%.2f×", snap.Ratio),
Band: band,
Degraded: snap.Degraded,
}
}
// offsiteBoxData builds the panel view + per-customer rows. Nil provider → Configured:false (the panel
// renders the honest "not configured"). Never fetches.
func (s *Server) offsiteBoxData() (offsiteBoxView, []offsiteCustomerRow) {
if s.offsiteBox == nil {
return offsiteBoxView{Configured: false}, nil
}
view := offsiteBoxView{Configured: true}
if snap, ok := s.offsiteBox(); ok {
view.Pending = false
view.Degraded = snap.Degraded
view.CapacityStr = fmtBytesGB(snap.CapacityBytes)
view.UsedStr = fmtBytesGB(snap.UsedBytes)
view.DataStr = fmtBytesGB(snap.DataBytes)
view.SnapshotStr = fmtBytesGB(snap.SnapshotBytes)
view.FillPercent = snap.FillPercent
view.FillBand = snap.FillBand
view.SumQuotaStr = fmt.Sprintf("%d GB", snap.SumQuotaGB)
view.RatioStr = fmt.Sprintf("%.2f×", snap.Ratio)
view.OversubBand = snap.OversubBand
view.BoxType = snap.BoxType
view.FetchedAt = snap.FetchedAt
} else {
view.Pending = true // configured, first fetch pending
}
return view, s.offsiteCustomerRows()
}
// offsiteCustomerRows composes the per-customer rows for every ENABLED-offsite customer: quota from the
// authoritative ConfigJSON Descriptor, usage from the report echo. Shared customers get a usage/quota
// bar; dedicated customers are listed without one. Sorted by usage descending.
func (s *Server) offsiteCustomerRows() []offsiteCustomerRow {
cfgs, err := s.store.ListCustomerConfigs()
if err != nil {
s.logger.Printf("[WARN] offsite box: list configs: %v", err)
return nil
}
usage := map[string]int64{}
hasReport := map[string]bool{}
if custs, cerr := s.store.GetCustomers(); cerr == nil {
for _, c := range custs {
if b, ok := offsiteUsageBytes(c.ReportJSON); ok {
usage[c.CustomerID], hasReport[c.CustomerID] = b, true
}
}
}
var rows []offsiteCustomerRow
for _, cfg := range cfgs {
d, derr := offsite.ReadDescriptor(cfg.ConfigJSON)
if derr != nil || d == nil || !d.Enabled {
continue // only customers actually using an offsite tier appear
}
name := cfg.CustomerName
if name == "" {
name = cfg.CustomerID
}
row := offsiteCustomerRow{CustomerName: name, Dedicated: d.Type == "dedicated"}
if d.Type == "shared" && d.QuotaGB > 0 {
row.QuotaStr = fmt.Sprintf("%d GB", d.QuotaGB)
}
if hasReport[cfg.CustomerID] {
row.UsageBytes = usage[cfg.CustomerID]
row.UsageStr = fmtBytesGB(row.UsageBytes)
if d.Type == "shared" && d.QuotaGB > 0 {
row.UsagePercent = float64(row.UsageBytes) * 100 / float64(int64(d.QuotaGB)<<30)
row.UsageBand = pctBand(row.UsagePercent, 90, 95) // matches the per-customer OffsiteChecker bands
row.HasBar = true
}
} else {
row.NoUsage = true
}
rows = append(rows, row)
}
sort.SliceStable(rows, func(i, j int) bool { return rows[i].UsageBytes > rows[j].UsageBytes })
return rows
}