4bb2df0dc4
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.
194 lines
6.6 KiB
Go
194 lines
6.6 KiB
Go
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
|
||
}
|