console: compute a health verdict on the app overview
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+81
−0
internal/console/overview.go+42 −0internal/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") |
| + } |
| +} |