carlos / platform Public

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>
Paul Campbell 2c55fbac8dd60f5381c930ba82a4372816abf39d parent f9e8c25
6 files changed, +113 −44
  • internal/console/flow.go +6 −1
  • internal/console/flow_test.go +4 −0
  • internal/console/overview.go +35 −16
  • internal/console/overview_test.go +51 −14
  • internal/console/server_test.go +16 −12
  • internal/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}}