Files
felhom.eu/hub/internal/web/offsite_box.go
T
admin 4bb2df0dc4 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.
2026-07-17 20:15:34 +02:00

194 lines
6.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}