console: healthWord distinguishes Not responding from No readings
expectsReadings is the one rule for whether a silent instance route's process was ever something the metrics sweep could have heard from: a provisioned instance, a carlos-app@ tenant (Backing), or a route whose unit is stamped for the sweep to look up. Anything else — a manually added route whose process some bespoke, unstamped unit owns — is honest ignorance, not an outage, and must not render red. This is the 2026-08-10 tito false alarm: 18 serving routes flagged "Not responding" because their titogo@* units matched no sweep key. healthWord takes the new expectReadings bool and picks between "Not responding" (red, an outage) and the new "No readings" (idle, honest ignorance) for a silent instance. addressTone names "No readings" explicitly alongside "Asleep" in the idle arm. app_instances.html adds a hint pointing owners at `carlos route -unit` to make a route legible. Updates two existing fixture-driven tests (TestOverviewDrawsTheFlow, TestAppPageOwnerMetricsBoundary) whose routes are unstamped and correctly move from "Not responding"/bad to "No readings"/idle under the new rule, plus a stale comment describing the old behavior.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+113
−44
internal/console/flow.go+6 −1internal/console/flow_test.go+4 −0internal/console/overview.go+35 −16internal/console/overview_test.go+51 −14internal/console/server_test.go+16 −12internal/console/templates/app_instances.html+1 −1
diff --git a/internal/console/flow.go b/internal/console/flow.go| index c7c1caea0..b0591a8da 100644 |
| --- a/internal/console/flow.go |
| +++ b/internal/console/flow.go |
| @@ -96,7 +96,10 @@ type FlowAddress struct { |
| // addressTone maps healthWord's vocabulary onto the four tones. An |
| // unrecognised word falls to idle: a health word nobody taught this function |
| // is missing information, and missing information must never render as |
| -// either reassurance or alarm. |
| +// either reassurance or alarm. "No readings" is named here rather than left |
| +// to the default for the same reason "Asleep" is: it is deliberately idle, |
| +// not a gap this function forgot to cover — a route the sweep was never told |
| +// how to sample is honest ignorance, not a problem to alarm on. |
| func addressTone(health string) string { |
| switch health { |
| case "Running", "Serving": |
| @@ -105,6 +108,8 @@ func addressTone(health string) string { |
| return "wait" |
| case "Not responding", "Failed": |
| return "bad" |
| + case "Asleep", "No readings": |
| + return "idle" |
| default: |
| return "idle" |
| } |
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go| index 6ff027368..987f99832 100644 |
| --- a/internal/console/flow_test.go |
| +++ b/internal/console/flow_test.go |
| @@ -146,6 +146,7 @@ func TestFlowTonesAddressHealth(t *testing.T) { |
| {Host: "e.dev", Channel: "stable", HealthWord: "Setting up"}, |
| {Host: "f.dev", Channel: "stable", HealthWord: "Serving"}, |
| {Host: "g.dev", Channel: "stable", HealthWord: "Something new"}, |
| + {Host: "h.dev", Channel: "stable", HealthWord: "No readings"}, |
| } |
| f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "") |
| @@ -155,6 +156,9 @@ func TestFlowTonesAddressHealth(t *testing.T) { |
| // A word this function has never been taught is missing information: |
| // neither reassurance nor alarm. |
| "g.dev": "idle", |
| + // Nobody could have sampled it either — honest ignorance reads the |
| + // same as hibernating, not as a problem. |
| + "h.dev": "idle", |
| } |
| for _, a := range stageByChannel(f, "stable").Addresses { |
| if a.Tone != want[a.Host] { |
diff --git a/internal/console/overview.go b/internal/console/overview.go| index d7738e9be..abadbea46 100644 |
| --- a/internal/console/overview.go |
| +++ b/internal/console/overview.go |
| @@ -7,6 +7,8 @@ package console |
| import ( |
| "strings" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| // OverviewStats is the Overview dashboard: the handful of numbers an owner |
| @@ -236,26 +238,41 @@ func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetric |
| status = "ready" // registry contract: empty status means ready |
| } |
| rows = append(rows, InstanceRow{ |
| - Host: rt.Host, |
| - Channel: rt.Channel, |
| - Version: rt.Version, |
| - Kind: rt.Kind, |
| - Status: status, |
| - Hibernate: rt.Hibernate, |
| - Metrics: byHost[rt.Host], |
| - HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting()), |
| + Host: rt.Host, |
| + Channel: rt.Channel, |
| + Version: rt.Version, |
| + Kind: rt.Kind, |
| + Status: status, |
| + Hibernate: rt.Hibernate, |
| + Metrics: byHost[rt.Host], |
| + HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(), |
| + expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)), |
| }) |
| } |
| return rows |
| } |
| -// healthWord folds provisioning state, hibernation and liveness into the one |
| -// word the table shows. Provisioning words win while provisioning is real |
| -// (a cert mid-issue IS the situation); once the platform says ready, the |
| -// question becomes "is the process alive", which only instance-kind routes |
| -// can answer — static and service routes are served without a tenant |
| -// process, so ready means Serving, full stop. |
| -func healthWord(kind, status string, hibernate, reporting bool) string { |
| +// expectsReadings is the one rule for whether silence from a route's process |
| +// is meaningful: the platform runs the process (a provisioned instance or a |
| +// carlos-app@ tenant) or a stamped unit tells the sweep where to look. |
| +// Everything else — a manually-added route whose process some bespoke, |
| +// unstamped unit owns — is honest ignorance, not an outage, and must not |
| +// render red (the 2026-08-10 tito false alarm: 18 serving routes flagged |
| +// "Not responding" because their titogo@* units matched no sweep key). |
| +func expectsReadings(provisioned bool, unit, backing string) bool { |
| + return provisioned || unit != "" || backing == registry.BackingUnit |
| +} |
| + |
| +// healthWord folds provisioning state, hibernation, liveness and whether a |
| +// reading was ever possible into the one word the table shows. Provisioning |
| +// words win while provisioning is real (a cert mid-issue IS the situation); |
| +// once the platform says ready, the question becomes "is the process alive", |
| +// which only instance-kind routes can answer — static and service routes are |
| +// served without a tenant process, so ready means Serving, full stop. Among |
| +// silent instances, expectReadings decides whether the silence is an outage |
| +// ("Not responding") or honest ignorance about a route the sweep was never |
| +// told how to sample ("No readings") — see expectsReadings. |
| +func healthWord(kind, status string, hibernate, reporting, expectReadings bool) string { |
| switch status { |
| case "pending", "creating", "starting", "issuing": |
| return "Setting up" |
| @@ -270,7 +287,9 @@ func healthWord(kind, status string, hibernate, reporting bool) string { |
| return "Running" |
| case hibernate: |
| return "Asleep" |
| - default: |
| + case expectReadings: |
| return "Not responding" |
| + default: |
| + return "No readings" |
| } |
| } |
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go| index cfc5175a3..ad25bede5 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -16,6 +16,8 @@ import ( |
| "strings" |
| "testing" |
| "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| ) |
| // f64 and i64 are the pointer literals InstanceMetrics carries — nil is a |
| @@ -298,23 +300,53 @@ func TestInstanceRowHealthWord(t *testing.T) { |
| kind, status string |
| hibernate bool |
| reporting bool |
| + expect bool |
| want string |
| }{ |
| - {"instance", "ready", false, true, "Running"}, |
| - {"instance", "ready", true, false, "Asleep"}, |
| - {"instance", "ready", false, false, "Not responding"}, |
| - {"instance", "starting", false, false, "Setting up"}, |
| - {"instance", "pending", false, false, "Setting up"}, |
| - {"instance", "creating", false, false, "Setting up"}, |
| - {"instance", "issuing", false, false, "Setting up"}, |
| - {"instance", "failed", false, false, "Failed"}, |
| - {"static", "ready", false, false, "Serving"}, |
| - {"service", "ready", false, false, "Serving"}, |
| + {"instance", "ready", false, true, true, "Running"}, |
| + {"instance", "ready", true, false, true, "Asleep"}, |
| + {"instance", "ready", false, false, true, "Not responding"}, |
| + {"instance", "starting", false, false, true, "Setting up"}, |
| + {"instance", "pending", false, false, true, "Setting up"}, |
| + {"instance", "creating", false, false, true, "Setting up"}, |
| + {"instance", "issuing", false, false, true, "Setting up"}, |
| + {"instance", "failed", false, false, true, "Failed"}, |
| + {"static", "ready", false, false, true, "Serving"}, |
| + {"service", "ready", false, false, true, "Serving"}, |
| + // silent + expected to report stays the red word |
| + {kind: "instance", status: "ready", hibernate: false, reporting: false, expect: true, want: "Not responding"}, |
| + // silent + nobody could sample it = honest ignorance, not an outage |
| + {kind: "instance", status: "ready", hibernate: false, reporting: false, expect: false, want: "No readings"}, |
| + // hibernation still wins over both |
| + {kind: "instance", status: "ready", hibernate: true, reporting: false, expect: false, want: "Asleep"}, |
| } |
| for _, c := range cases { |
| - got := healthWord(c.kind, c.status, c.hibernate, c.reporting) |
| + got := healthWord(c.kind, c.status, c.hibernate, c.reporting, c.expect) |
| if got != c.want { |
| - t.Errorf("healthWord(%q,%q,hib=%v,rep=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, got, c.want) |
| + t.Errorf("healthWord(%q,%q,hib=%v,rep=%v,expect=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, c.expect, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// expectsReadings is the one rule for whether silence from a route's process |
| +// means something: the platform runs the process (provisioned, or a |
| +// carlos-app@ tenant) or a stamped unit tells the sweep where to look. |
| +// Anything else — a manually-added route whose process some bespoke, |
| +// unstamped unit owns — is honest ignorance, not an outage. |
| +func TestExpectsReadings(t *testing.T) { |
| + cases := []struct { |
| + provisioned bool |
| + unit, backing string |
| + want bool |
| + }{ |
| + {true, "", "", true}, // instances-API-minted: activator-run |
| + {false, "titogo@x.service", "", true}, // stamped: the sweep samples it |
| + {false, "", registry.BackingUnit, true}, // carlos-app@ tenant |
| + {false, "", "", false}, // manual exec-shaped row: can't tell |
| + } |
| + for _, c := range cases { |
| + if got := expectsReadings(c.provisioned, c.unit, c.backing); got != c.want { |
| + t.Fatalf("expectsReadings(%v,%q,%q) = %v", c.provisioned, c.unit, c.backing, got) |
| } |
| } |
| } |
| @@ -412,8 +444,13 @@ func TestOverviewDrawsTheFlow(t *testing.T) { |
| if !strings.Contains(body, `<a href="https://kass.carlosapp.dev">kass.carlosapp.dev</a>`) { |
| t.Fatalf("the flow does not show the app's address as a link:\n%s", body) |
| } |
| - if !strings.Contains(body, `class="flow-addr flow-tone-bad"`) { |
| - t.Fatalf("the fixture's silent host should be toned as needing attention:\n%s", body) |
| + // The fixture's silent host carries no Backing/Unit/Provisioned — a |
| + // manually-added-shaped route the sweep was never told how to sample — |
| + // so its silence is honest ignorance ("No readings"/idle), not an |
| + // outage ("Not responding"/bad); TestFlowTonesAddressHealth in |
| + // flow_test.go pins the "Not responding" -> bad mapping directly. |
| + if !strings.Contains(body, `class="flow-addr flow-tone-idle"`) { |
| + t.Fatalf("the fixture's silent, unstamped host should be toned idle (honest ignorance):\n%s", body) |
| } |
| // The fixture ships v2 but only has v1 live, so nothing has travelled the |
| // whole path: no step may claim it has. |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index b8ff01e2c..3d57f1c67 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1809,9 +1809,12 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| Status: "ready", App: "kass", Backing: registry.BackingUnit}) |
| fx.addRoute(t, registry.Route{Host: "asleep.example.com", Kind: "instance", |
| Status: "ready", App: "kass", Backing: registry.BackingUnit, Hibernate: true}) |
| - // The keymail.dev shape: awake and serving, but from a process no |
| - // carlos-app@ cgroup covers (bespoke unit / exec child), so the sweep |
| - // has nothing for it. Non-hibernating, so "asleep" would be a lie. |
| + // The manually-added shape: awake and serving, but from a process no |
| + // carlos-app@ cgroup covers and no stamped unit names either (bespoke |
| + // unit / exec child), so the sweep has nothing for it AND nowhere to |
| + // look. Non-hibernating, so "asleep" would be a lie; unprovisioned and |
| + // unstamped, so "Not responding" would be too — expectsReadings can't |
| + // tell, so the row must read "No readings" (2026-08-10 tito false alarm). |
| fx.addRoute(t, registry.Route{Host: "unmetered.example.com", Kind: "instance", |
| Status: "ready", App: "kass"}) |
| @@ -1843,11 +1846,11 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| // The per-host CPU/Mem readings now live as columns on the Instances |
| // table itself (no separate Resources section) — "11%"/"1.0 KB" are |
| // unique to owned.example.com's own row, so checking presence page-wide |
| - // still pins them to that route. Asleep vs Not responding is the Health |
| - // column's job, not the metrics cell's, and the fixture always carries |
| - // other instance-kind rows (its own bystanders), so those two words are |
| - // scoped to their own row below rather than checked page-wide — a |
| - // page-wide Contains would pass even if the wrong row said it. |
| + // still pins them to that route. Asleep vs Not responding vs No readings |
| + // is the Health column's job, not the metrics cell's, and the fixture |
| + // always carries other instance-kind rows (its own bystanders), so those |
| + // words are scoped to their own row below rather than checked page-wide |
| + // — a page-wide Contains would pass even if the wrong row said it. |
| for _, want := range []string{ |
| "owned.example.com", "11%", "1.0 KB", |
| "asleep.example.com", "unmetered.example.com", |
| @@ -1859,8 +1862,8 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| if row := instanceRow(t, body, "asleep.example.com"); !strings.Contains(row, "Asleep") { |
| t.Fatalf("asleep.example.com's own row did not read Asleep:\n%s", row) |
| } |
| - if row := instanceRow(t, body, "unmetered.example.com"); !strings.Contains(row, "Not responding") || !strings.Contains(row, "no data") { |
| - t.Fatalf("unmetered.example.com's own row did not read Not responding / no data:\n%s", row) |
| + if row := instanceRow(t, body, "unmetered.example.com"); !strings.Contains(row, "No readings") || !strings.Contains(row, "no data") { |
| + t.Fatalf("unmetered.example.com's own row did not read No readings / no data:\n%s", row) |
| } |
| // The boundary: nothing about the other tenant, the host, or the box. |
| for _, forbidden := range []string{ |
| @@ -2042,8 +2045,9 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) { |
| // that cell); the route isn't hibernating, so the Health column must say |
| // so plainly rather than claim Asleep. Scoped to owned.example.com's own |
| // row: the fixture also carries kass.carlosapp.dev, an instance-kind |
| - // route that is never metered and never hibernating, which renders "Not |
| - // responding" on every run of this page regardless of what this test |
| + // route that is never metered and never hibernating, which renders "No |
| + // readings" (it carries no Backing/Unit/Provisioned — expectsReadings |
| + // can't tell) on every run of this page regardless of what this test |
| // does — a page-wide Contains would pass even with the wrong route |
| // hibernating. |
| row := instanceRow(t, body, "owned.example.com") |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index be566d224..24222c9b4 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -11,7 +11,7 @@ |
| {{range .InstanceRows}} |
| <tr> |
| <td><a href="https://{{.Host}}">{{.Host}}</a></td> |
| - <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}</td> |
| + <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}{{if eq .HealthWord "No readings"}}<span class="small"> · stamp its unit (carlos route -unit) to see health here</span>{{end}}</td> |
| <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td> |
| <td><code>{{.Channel}}</code></td> |
| {{if .Metrics.Reporting}} |