carlos / platform Public

console: only a hibernating instance may render as asleep

The owner view attributes readings by the one unit name a host can have,
carlos-app@<host>.service, and rendered ANY absence of readings as
"asleep — no recent readings". For an instance that runs outside
per-instance accounting — a bespoke unit like keymail-relay.service, or
an exec child of the agent — that copy asserts a sleep state nobody
observed: keymail.dev read "asleep" while it was serving mail.

Absence of a cgroup is not evidence of sleep. The route's Hibernate flag
is what licenses the presumption, so handleApp now copies it onto each
instance row and the template splits on it: hibernating instances keep
"asleep — no recent readings", everything else gets the state-neutral
"no per-instance readings". Still never zeros either way.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 283fb159a5f461d27bdb67c109938d93b05cadf3 parent b8e930b
4 files changed, +29 −3
  • internal/console/metricsview.go +13 −2
  • internal/console/server.go +5 −0
  • internal/console/server_test.go +7 −1
  • internal/console/templates/app.html +4 −0
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
index a6defb365..be27dcb31 100644
--- a/internal/console/metricsview.go
+++ b/internal/console/metricsview.go
@@ -403,11 +403,22 @@ type InstanceMetrics struct {
MemBytes *int64
SparkCPU, SparkMem string
+
+ // Hibernate is the route's hibernation setting, copied in by the caller
+ // (handleApp) — the sweep cannot see it. It only matters when Reporting
+ // is false, where it decides what the absence of readings may claim: a
+ // hibernating instance is presumed asleep, anything else is NOT — it may
+ // be serving right now from outside per-instance accounting (a bespoke
+ // unit like keymail-relay.service, or an exec child of the agent), and
+ // the template must not assert a sleep state nobody observed.
+ Hibernate bool
}
// Reporting is the template's one question: did any box report this
-// instance's unit in the last sweep? False is the hibernating/absent case —
-// rendered as "asleep — no recent readings", never as zeros. (Reporting with
+// instance's unit in the last sweep? False is the no-readings case, whose
+// rendering splits on Hibernate: hibernating instances read "asleep — no
+// recent readings", everything else the state-neutral "no per-instance
+// readings" — never zeros either way. (Reporting with
// nil readings is a different, honest state: the unit is up but that metric
// was unreadable, which renders as the usual em-dash.)
func (m InstanceMetrics) Reporting() bool { return m.Box != "" }
diff --git a/internal/console/server.go b/internal/console/server.go
index 5e44aa521..e25302c25 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1156,15 +1156,20 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
// with (the half-migrated state AccountMismatch describes) yields no
// metrics rather than someone else's.
hosts := make([]string, 0, len(views[0].Routes))
+ hib := make(map[string]bool, len(views[0].Routes))
for _, rt := range views[0].Routes {
if rt.Account != acct.Sqid {
continue
}
hosts = append(hosts, rt.Host)
+ hib[rt.Host] = rt.Hibernate
}
if am, err := s.OwnerAppMetrics(r.Context(), hosts); err != nil {
log.Printf("console: app metrics %s: %v", app, err)
} else {
+ for i := range am {
+ am[i].Hibernate = hib[am[i].Host]
+ }
p.AppMetrics = am
}
if events, err := s.store.Events(acct.ID, app, eventsCap); err != nil {
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 74da16298..378c826b8 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1213,6 +1213,11 @@ 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.
+ fx.addRoute(t, registry.Route{Host: "unmetered.example.com", Kind: "instance",
+ Status: "ready", App: "kass"})
ctx := context.Background()
at := time.Now().UTC().Add(-time.Minute) // inside metricsFreshWindow
@@ -1241,7 +1246,8 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
}
for _, want := range []string{
"Resources", "owned.example.com", "11%", "1.0 KB",
- "asleep.example.com", "asleep — no recent readings",
+ "asleep.example.com</code> — asleep — no recent readings",
+ "unmetered.example.com</code> — no per-instance readings",
} {
if !strings.Contains(body, want) {
t.Fatalf("app page missing %q in:\n%s", want, body)
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
index d25b31297..cb6757eb8 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app.html
@@ -77,7 +77,11 @@
</div>
</section>
{{else}}
+{{if .Hibernate}}
<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
+{{else}}
+<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
+{{end}}
{{end}}
{{end}}
{{end}}