Files
felhom.eu/hub/internal/web/offsite_box.go
T

196 lines
6.8 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]
// R-277: fmtBytesAuto, not fmtBytesGB — a 162 KB repo rendered "0.0 GB" and was read as an
// absent off-site tier. Above 1 GB both helpers print the same "%.1f GB".
row.UsageStr = fmtBytesAuto(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
}