carlos / platform Public

console: compute a health verdict on the app overview

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 02a57f27fb45249013b627b4aae46536a0b9e53e parent 56dfee0
2 files changed, +81 −0
  • internal/console/overview.go +42 −0
  • internal/console/overview_test.go +39 −0
diff --git a/internal/console/overview.go b/internal/console/overview.go
index caa504880..65d6ede18 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -36,6 +36,21 @@ type OverviewStats struct {
// "readings unavailable", not "zero instances reporting".
MetricsUnavailable bool
+ // InstanceExpected is how many of this account's routes are lifecycle-
+ // managed processes (kind "instance") — the only kind the metrics sweep
+ // can ever hear from. Static and service routes are served without a
+ // tenant process, so they are never "silent": expecting a reading from
+ // them would flag every static site as unhealthy forever.
+ InstanceExpected int
+
+ // Verdict is the dashboard's one-word answer to "is this app healthy",
+ // computed here so templates render it as a fact. Verdict is the CSS
+ // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the
+ // words. The rule errs toward "can't tell" over a guessed "all good" —
+ // same honesty contract as the em-dash.
+ Verdict string
+ VerdictLabel string
+
// Current resource use summed across every reporting instance. Nil when
// nothing reports — the tiles render the em-dash, never zeros.
CPUSum *float64
@@ -102,6 +117,9 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
continue
}
st.InstanceTotal++
+ if rt.Kind == "instance" {
+ st.InstanceExpected++
+ }
if reporting[rt.Host] {
st.InstanceReporting++
} else if rt.Hibernate {
@@ -130,9 +148,33 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
if len(versions) > 0 {
st.LatestVersion = versions[0].Version
}
+ st.Verdict, st.VerdictLabel = healthVerdict(st)
return st
}
+// healthVerdict folds the dashboard's numbers into one word. Order matters:
+// an unreadable stable pointer means we don't know what's live, which
+// outranks everything; an app never promoted isn't unhealthy, it's not
+// deployed; and a failed sweep with real instances is "can't tell", never
+// "all good" — a verdict this page can't back is the lie the old Status
+// column told.
+func healthVerdict(st OverviewStats) (string, string) {
+ switch {
+ case st.LiveErr != "":
+ return "unknown", "Can't tell right now"
+ case !st.HasStable:
+ return "notdeployed", "Not deployed yet"
+ case st.DomainAttention > 0:
+ return "attention", "Needs attention"
+ case st.MetricsUnavailable && st.InstanceExpected > 0:
+ return "unknown", "Can't tell right now"
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected:
+ return "attention", "Needs attention"
+ default:
+ return "ok", "All good"
+ }
+}
+
// domainNeedsAttention is the dashboard's one-bit read of a Domains row:
// guidance text means DNS isn't pointing here, and a TLS status other than
// ready/issuing means issuance failed. Issuing is patience, not attention.
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index cab8018de..84de7f5e4 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -315,3 +315,42 @@ func TestOverviewInstancesPageRendersTable(t *testing.T) {
t.Fatalf("the app's routes are not listed in the table:\n%s", body)
}
}
+
+func TestOverviewVerdict(t *testing.T) {
+ cases := []struct {
+ name string
+ st OverviewStats
+ verdict string
+ label string
+ }{
+ {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"},
+ {"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"},
+ {"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
+ {"static-only app has nothing expected", OverviewStats{HasStable: true, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"},
+ }
+ for _, c := range cases {
+ v, l := healthVerdict(c.st)
+ if v != c.verdict || l != c.label {
+ t.Errorf("%s: got %q/%q, want %q/%q", c.name, v, l, c.verdict, c.label)
+ }
+ }
+}
+
+func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "static"},
+ {Host: "c", Account: "other", Kind: "instance"},
+ }}
+ st := assembleOverview(view, "bab", []InstanceMetrics{}, nil)
+ if st.InstanceExpected != 1 {
+ t.Errorf("InstanceExpected = %d, want 1 (instance-kind, this account only)", st.InstanceExpected)
+ }
+ if st.Verdict == "" || st.VerdictLabel == "" {
+ t.Error("assembleOverview must fill Verdict and VerdictLabel")
+ }
+}