console: Running tile separates not-tracked copies; Web addresses says what it measures
OverviewStats gains InstanceNoReadings and InstanceCounted (Expected minus the unknowable). assembleOverview's route loop uses Task 2's expectsReadings to classify silent instance-kind routes as no-readings rather than folding them into the shortfall count, and sets InstanceCounted before healthVerdict runs. healthVerdict's shortfall case now compares against InstanceCounted, not InstanceExpected, so a route the sweep was never told how to sample (the 2026-08-10 tito false alarm) can't trip "Needs attention". Running tile shows "N of Counted" and names the no-readings copies instead of silently baking them into a denominator nobody could reach. Web addresses tile's "all working" becomes "DNS & certs OK" — says what it measures instead of a vague absolute. TestOverviewSeparatesNoReadings and TestVerdictIgnoresNoReadings pin both. TestOverviewVerdict's existing struct-literal cases gain InstanceCounted (equal to InstanceExpected, since none of those cases have no-readings rows) now that the shortfall case reads from it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+52
−8
internal/console/overview.go+12 −1internal/console/overview_test.go+37 −4internal/console/templates/app_overview.html+3 −3
diff --git a/internal/console/overview.go b/internal/console/overview.go| index abadbea46..5a635f281 100644 |
| --- a/internal/console/overview.go |
| +++ b/internal/console/overview.go |
| @@ -33,6 +33,14 @@ type OverviewStats struct { |
| InstanceTotal int |
| InstanceReporting int |
| InstanceAsleep int |
| + // InstanceNoReadings counts instance-kind routes nobody could sample: not |
| + // provisioned, no carlos-app@ backing, no stamped unit. They are honest |
| + // unknowns — the tile names them separately and the verdict must not |
| + // treat them as down (the 2026-08-10 tito false alarm). |
| + InstanceNoReadings int |
| + // InstanceCounted is the Running tile's denominator: InstanceExpected |
| + // minus the unknowable. Templates render facts, never compute them. |
| + InstanceCounted int |
| // MetricsUnavailable: the sweep itself failed (nil metrics), which is |
| // "readings unavailable", not "zero instances reporting". |
| @@ -129,9 +137,12 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver |
| st.InstanceReporting++ |
| } else if rt.Hibernate { |
| st.InstanceAsleep++ |
| + } else if !expectsReadings(rt.Provisioned, rt.Unit, rt.Backing) { |
| + st.InstanceNoReadings++ |
| } |
| } |
| } |
| + st.InstanceCounted = st.InstanceExpected - st.InstanceNoReadings |
| // Routes and Domains are parallel slices — Assemble appends one of each |
| // per registry route, in the same loop — which is what lets the domain |
| // rows be account-filtered here at all (DomainView carries no account of |
| @@ -181,7 +192,7 @@ func healthVerdict(st OverviewStats) (string, string) { |
| return "attention", "Needs attention" |
| case st.MetricsUnavailable && st.InstanceExpected > 0: |
| return "unknown", "Can't tell right now" |
| - case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected: |
| + case st.InstanceReporting+st.InstanceAsleep < st.InstanceCounted: |
| return "attention", "Needs attention" |
| default: |
| return "ok", "All good" |
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go| index ad25bede5..d58d08d09 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -141,6 +141,39 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) { |
| } |
| } |
| +// TestOverviewSeparatesNoReadings pins InstanceNoReadings/InstanceCounted: |
| +// a silent instance route the sweep was never told how to sample (no |
| +// Provisioned, Unit or Backing) is honest ignorance, not an outage, and |
| +// must not count against the Running tile's denominator. |
| +func TestOverviewSeparatesNoReadings(t *testing.T) { |
| + view := &AppView{Routes: []RouteView{ |
| + {Host: "a.x", Kind: "instance", Account: "acct1", Provisioned: true}, // reporting |
| + {Host: "b.x", Kind: "instance", Account: "acct1", Provisioned: true}, // silent, expected |
| + {Host: "c.x", Kind: "instance", Account: "acct1"}, // silent, bespoke-unstamped |
| + {Host: "d.x", Kind: "instance", Account: "acct1", Hibernate: true}, // asleep |
| + }, Domains: []DomainView{{}, {}, {}, {}}} |
| + metrics := []InstanceMetrics{{Host: "a.x", Box: "box1"}} |
| + st := assembleOverview(view, "acct1", metrics, nil) |
| + if st.InstanceExpected != 4 || st.InstanceReporting != 1 || st.InstanceAsleep != 1 || |
| + st.InstanceNoReadings != 1 || st.InstanceCounted != 3 { |
| + t.Fatalf("counts: %+v", st) |
| + } |
| +} |
| + |
| +// TestVerdictIgnoresNoReadings pins that healthVerdict's shortfall case |
| +// compares against InstanceCounted, not InstanceExpected — a no-readings |
| +// row must not trip "attention" (the 2026-08-10 tito false alarm). |
| +func TestVerdictIgnoresNoReadings(t *testing.T) { |
| + // 1 reporting + 1 asleep + 1 no-readings out of 3 counted... construct an |
| + // OverviewStats where Reporting+Asleep == InstanceCounted and assert the |
| + // verdict is NOT "attention" on account of the no-readings row. |
| + st := OverviewStats{HasStable: true, InstanceExpected: 3, InstanceCounted: 2, |
| + InstanceReporting: 1, InstanceAsleep: 1, InstanceNoReadings: 1} |
| + if v, _ := healthVerdict(st); v != "ok" { |
| + t.Fatalf("no-readings rows must not trip the verdict: %s", v) |
| + } |
| +} |
| + |
| // PrimaryHost is the apex of this account's OWN routes — Task 2's apexHost |
| // reused here, so the headline's "live at" link names the same host |
| // summarize's account table does (same fixture shape as |
| @@ -487,10 +520,10 @@ func TestOverviewVerdict(t *testing.T) { |
| 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"}, |
| + {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 2}, "ok", "All good"}, |
| + {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"}, |
| + {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1}, "attention", "Needs attention"}, |
| + {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceCounted: 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"}, |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index 81689f40d..c2212bd7a 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -36,8 +36,8 @@ |
| routinely share a box. */}} |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Running</h2> |
| - <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else}}{{.InstanceReporting}} of {{.InstanceExpected}}{{end}}</p> |
| - <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else}}copies responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else if eq .InstanceCounted 0}}?{{else}}{{.InstanceReporting}} of {{.InstanceCounted}}{{end}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceCounted 0}}no readings for {{.InstanceNoReadings}} copies — stamp their units to see health{{else}}copies reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{if .InstanceNoReadings}} · {{.InstanceNoReadings}} no readings{{end}}{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Resources</h2> |
| @@ -47,7 +47,7 @@ |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings"> |
| <h2>Web addresses</h2> |
| <p class="tile-value">{{.DomainTotal}}</p> |
| - <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p> |
| + <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}DNS & certs OK{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases"> |
| <h2>Versions</h2> |