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 }