Files
felhom.eu/hub/internal/web/rollup.go
T
admin 36c72138f1 hub: dead-host roll-up honesty - customer status folds worst expected host
Customer status (dashboard row, /configs list, detail header + strip) is
now worst(controllerDerived, hostStatusOf(each expected host)) via the ONE
staleness definition (Server.hostStatus, hosts.go - shared with the
HostStalenessChecker; no second threshold). Any host down/stale caps the
customer at WARN with a cause chip naming the host ("host down: <id>");
pending (never-reported) hosts worsen only once the customer has reported
(onboarding exclusion). The three previously-inlined controller-status
chains collapse into controllerStatus() (rollup.go). Display + derivation
only - checker alerting untouched.

Live shape pinned (drill-1 / Peti cluster): host down 23h + controller
report minutes old rendered a GREEN row - TestRollup_DeadHostMasking now
fails that exact outcome. Red-proof: short-circuiting foldHostStatus to
controller-only flips C + two D subtests red ("dashboard row is GREEN
over a 23h-dead host").
2026-07-13 14:52:04 +02:00

79 lines
3.4 KiB
Go

package web
// Dead-host roll-up honesty (v0.53.0, drill-1 observation; operator ruling 2026-07-13): a
// customer's status may never look better than its worst expected host. The customer roll-up
// derives from CONTROLLER reports, which reach the hub independently of the host agent — so a
// host DOWN for 23 hours hid behind a green customer row as long as the guest kept reporting
// (the live Peti-cluster shape: proxmox1 down 23h, fresh reports through proxmox2).
//
// foldHostStatus worsens the controller-derived status with per-host staleness via
// (*Server).hostStatus — THE single staleness definition (hosts.go; the same thresholds the
// HostStalenessChecker alerts on — no second definition anywhere). Display + derivation only:
// checker alerting is untouched.
import (
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// controllerStatus is the controller-report-derived customer status — the pre-roll-up chain the
// dashboard, the /configs list and the customer detail all inlined verbatim; this is now the
// ONE copy. Behavior-preserving: the branch order (incl. fail-after-warn) is the historical one.
func controllerStatus(c *store.CustomerSummary) string {
switch {
case c.HealthStatus == "disabled":
return "disabled"
case c.TimeSinceReport > time.Hour:
return "down"
case c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn":
return "warn"
case c.HealthStatus == "fail":
return "down"
default:
return "ok"
}
}
// hostFoldRank orders host states by badness for the worst-host pick. "ok" ranks 0 (never folds).
var hostFoldRank = map[string]int{"down": 3, "stale": 2, "pending": 1}
// hostFoldLabel is the operator-facing cause chip prefix per worst-host state.
var hostFoldLabel = map[string]string{"down": "host down", "stale": "host stale", "pending": "host pending"}
// foldHostStatus folds the customer's expected hosts into a controller-derived status:
// worst(controllerDerived, hostStatusOf(each host)). Any host down/stale caps the customer at
// WARN (a green row over a dead host is the masking bug); the returned cause names the state
// AND the host ("host down: <id>") so the detail header says WHICH host. "pending" hosts
// (enrolled, never reported) worsen only after initial onboarding — customerHasReported=false
// (the customer has never reported) excludes them, a half-installed box is not an incident.
// Statuses worse than warn (down) and administrative ones (disabled/blocked) keep their own
// token; the cause chip still surfaces the host signal. Read errors degrade to the unfolded
// status — the page must render.
func (s *Server) foldHostStatus(customerID, base string, customerHasReported bool) (status, cause string) {
hosts, err := s.store.ListHostsByCustomer(customerID)
if err != nil {
s.logger.Printf("[ERROR] roll-up: ListHostsByCustomer %s: %v", customerID, err)
return base, ""
}
worst, worstHost := "", ""
for i := range hosts {
hs := s.hostStatus(hosts[i].LastReportAt)
if hs == "pending" && !customerHasReported {
continue
}
if hostFoldRank[hs] > hostFoldRank[worst] {
worst, worstHost = hs, hosts[i].HostID
}
}
if worst == "" {
return base, ""
}
cause = hostFoldLabel[worst] + ": " + worstHost
// The fold worsens, never improves: ok / pending / no-report ("") cap at warn.
if base == "ok" || base == "pending" || base == "" {
return "warn", cause
}
return base, cause
}