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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user