hub v0.51.0: DR-tier-by-default — per-customer dr_tier flag (default ON, legacy backfill from reality), cascade stages, WG-registration auto-provision hook, offsite-requires-DR guard (F-6 policy), host-page capability chips (inactive=neutral)

Claude-Session: https://claude.ai/code/session_01NptTCFtu7dz2Ru89qHRagN
This commit is contained in:
2026-07-12 20:37:00 +02:00
parent 007946faf4
commit 448a68237a
18 changed files with 824 additions and 87 deletions
+84
View File
@@ -107,6 +107,70 @@ func parseHostVitals(reportJSON string) hostVitals {
return v
}
// capabilityView is one privileged-capability chip on the host detail page (v0.51.0 — the agent
// has reported these since v0.44.0; the hub now renders them). Class maps the agent's status to
// a badge: ok → badge-ok, degraded → badge-fail (critical) / badge-warn, inactive → badge-neutral
// (disabled ≠ degraded — the DR-tier-by-default rule; agent v0.86.0 emits "inactive").
type capabilityView struct {
Name string
Feature string
Status string
Reason string
Critical bool
Class string
}
// parseHostCapabilities extracts the capabilities array from a host-report body. Missing or
// malformed → nil (the "waiting for first report" path — the section hides).
func parseHostCapabilities(reportJSON string) []capabilityView {
if reportJSON == "" {
return nil
}
var body struct {
Capabilities []struct {
Name string `json:"name"`
Feature string `json:"feature"`
Critical bool `json:"critical"`
Status string `json:"status"`
Reason string `json:"reason"`
} `json:"capabilities"`
}
if err := json.Unmarshal([]byte(reportJSON), &body); err != nil {
return nil
}
out := make([]capabilityView, 0, len(body.Capabilities))
for _, c := range body.Capabilities {
v := capabilityView{Name: c.Name, Feature: c.Feature, Status: c.Status, Reason: c.Reason, Critical: c.Critical}
switch c.Status {
case "ok":
v.Class = "badge-ok"
case "inactive":
v.Class = "badge-neutral"
default: // degraded (or an unknown future status — surface it, never hide it)
if c.Critical {
v.Class = "badge-error"
} else {
v.Class = "badge-warn"
}
}
out = append(out, v)
}
return out
}
// capabilitiesNeedDRMigration reports whether any pbsdr-* capability is degraded with the
// pre-v1.15.0 signature ("binary not found") — the box predates the uniform DR plumbing. The
// host page then surfaces the migration one-liner instead of silently pretending (§8 of the
// DR-by-default spec).
func capabilitiesNeedDRMigration(caps []capabilityView) bool {
for _, c := range caps {
if strings.HasPrefix(c.Name, "pbsdr-") && c.Status == "degraded" && c.Reason == "binary not found" {
return true
}
}
return false
}
// storageTargetView is the rich per-drive row the host-detail Storage Targets table renders:
// fill %, role/state, thin-pool, and SMART health/temp/wear. Parsed from the latest report's
// storage_targets[] (the full hostStorageTarget wire shape lives in the api package; this view
@@ -296,6 +360,24 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
vitals := parseHostVitals(reportJSON)
storageTargets := parseHostStorageTargets(reportJSON)
sort.Slice(storageTargets, func(i, j int) bool { return storageTargets[i].Name < storageTargets[j].Name })
// v0.51.0: capability chips — non-ok first (what the operator needs to see), then by name.
capabilities := parseHostCapabilities(reportJSON)
sort.SliceStable(capabilities, func(i, j int) bool {
rank := func(s string) int {
switch s {
case "degraded":
return 0
case "inactive":
return 1
default:
return 2
}
}
if a, b := rank(capabilities[i].Status), rank(capabilities[j].Status); a != b {
return a < b
}
return capabilities[i].Name < capabilities[j].Name
})
// DR / backup presence — booleans only, never the opaque blobs.
drBundle, _ := s.store.GetHostDRBundle(host.HostID)
@@ -320,6 +402,8 @@ func (s *Server) hostDetailData(host *store.Host, r *http.Request) map[string]in
"GuestRunning": guestRunning,
"GuestTotal": len(guests),
"StorageTargets": storageTargets,
"Capabilities": capabilities,
"NeedsDRMigration": capabilitiesNeedDRMigration(capabilities),
"DRPresent": drBundle != nil,
"EscrowPresent": escrow != nil,
// v0.46.0 Diagnostics: pending log pulls + received/blocked bundles (72 h TTL).