carlos / platform Public

console: Instances tab becomes Running with honest one-word health

Adds InstanceRow.HealthWord (via healthWord: kind/status/hibernate/
reporting folded into one plain word — Running, Asleep, Setting up,
Not responding, Failed, or Serving for non-instance routes). The
Instances tab is relabeled Running throughout (nav label, h2, table
headers Address/Health/Version/Channel/CPU/Memory), the "inside the
app" self-report becomes a collapsed <details> disclosure, and the
empty state shows the same carlos instances create/enable hint the
Releases tab shows — lifted into a shared Server.attachHint so both
tabs name the same door instead of the box-local `carlos add`.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2c5ef7546420b9f5d2837ba91ea7818f3b681b41 parent 7ac8d15
6 files changed, +134 −54
  • internal/console/overview.go +38 −7
  • internal/console/overview_test.go +31 −2
  • internal/console/server.go +19 −8
  • internal/console/server_test.go +25 −19
  • internal/console/templates/app_instances.html +20 −17
  • internal/console/templates/appnav.html +1 −1
diff --git a/internal/console/overview.go b/internal/console/overview.go
index eb802cb93..d87ba9467 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -211,6 +211,10 @@ type InstanceRow struct {
Status string // registry status; "" means ready
Hibernate bool
Metrics InstanceMetrics // zero-valued (Reporting false) when unswept
+
+ // HealthWord is the Status enum + hibernate + reporting folded into one
+ // plain word — the column's whole job.
+ HealthWord string
}
// assembleInstanceRows joins the app's routes with the sweep's readings, in
@@ -232,14 +236,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],
+ 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()),
})
}
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 {
+ switch status {
+ case "pending", "creating", "starting", "issuing":
+ return "Setting up"
+ case "failed":
+ return "Failed"
+ }
+ if kind != "instance" {
+ return "Serving"
+ }
+ switch {
+ case reporting:
+ return "Running"
+ case hibernate:
+ return "Asleep"
+ default:
+ return "Not responding"
+ }
+}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index 25d7c6aef..0fe40cfcc 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -290,6 +290,35 @@ func TestInstanceRowsSkipOtherAccountsRoutes(t *testing.T) {
}
}
+// healthWord is the Instances table's whole Health column: provisioning
+// state and hibernation and liveness folded into the one word an owner
+// reads across the table.
+func TestInstanceRowHealthWord(t *testing.T) {
+ cases := []struct {
+ kind, status string
+ hibernate bool
+ reporting 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"},
+ }
+ for _, c := range cases {
+ got := healthWord(c.kind, c.status, c.hibernate, c.reporting)
+ 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)
+ }
+ }
+}
+
// The Overview page is the tiles, and only the tiles: the ladder moved to the
// Releases tab, and a copy left behind here would be two places to read the
// same state from.
@@ -336,8 +365,8 @@ func TestOverviewInstancesPageRendersTable(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("instances page = %d:\n%s", status, body)
}
- for _, th := range []string{"Instance", "Channel", "Running", "Status", "CPU", "Memory"} {
- if !strings.Contains(body, "<th>"+th+"</th>") {
+ for _, th := range []string{"Address", "Health", "Version", "Channel", "CPU", "Memory"} {
+ if !strings.Contains(body, `<th scope="col">`+th+"</th>") {
t.Fatalf("the instances table has no %s column:\n%s", th, body)
}
}
diff --git a/internal/console/server.go b/internal/console/server.go
index addb9a12f..cd3997492 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1540,17 +1540,27 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
p.Tags = tags
}
p.Versions = versions
- // The canary form's "how do I give this a URL" copy — this tab's data, not
- // shared chrome: the canary form lives here and nowhere else. Bounded and
- // best-effort like every other store read behind an app page: an unreadable
- // manifest costs the concrete host shape, never the page.
- mctx, mcancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
- domain, absent := s.instanceDomainFor(mctx, acct.Sqid, p.AppName)
- mcancel()
- p.AttachCmd, p.AttachEnable = attachCommands(p.AppName, "canary/<name>", domain, s.cfg.AppsDomain, acct.Sqid, absent)
+ // The "how do I give this a URL" copy — not shared chrome (it's computed
+ // per app page, not carried by appPage), but the same fill the Instances
+ // tab's empty state shows: attachHint below.
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName)
s.render(w, http.StatusOK, "app_releases", p)
}
+// attachHint is the "give this channel a URL" advice shown on both the
+// Releases tab (the canary form's copy) and the Instances tab (the empty
+// state, before anything is attached) — one bucket read and one command
+// shape, so a reader who bounces between tabs sees the same door named both
+// times. Bounded and best-effort like every other store read behind an app
+// page: an unreadable instances manifest costs the concrete host shape,
+// never the page.
+func (s *Server) attachHint(ctx context.Context, sqid, app string) (cmd, enable string) {
+ mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout)
+ defer mcancel()
+ domain, absent := s.instanceDomainFor(mctx, sqid, app)
+ return attachCommands(app, "canary/<name>", domain, s.cfg.AppsDomain, sqid, absent)
+}
+
// handleAppInstances is what the app is actually running: one row per
// instance — host, the channel it follows, what it is running right now, and
// its readings — plus the app's own self-report below, for apps that manage
@@ -1563,6 +1573,7 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics)
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName)
s.render(w, http.StatusOK, "app_instances", p)
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index bb46c3bad..d42e60f64 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -575,7 +575,7 @@ func TestInstancesTab(t *testing.T) {
if status != 200 {
t.Fatalf("GET instances tab = %d", status)
}
- if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ if !strings.Contains(body, `aria-current="page">Running</a>`) {
t.Fatal("tab not active")
}
assertNonMember404(t, fx, "/apps/kass/instances")
@@ -1073,11 +1073,11 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before the app reports its own internal instances: no "Inside the app"
- // section — the tab is exactly the route-level view (which always
- // renders its own "Instances" header, fixture routes and all).
- if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Inside the app</h2>") {
- t.Fatal("\"Inside the app\" section rendered with nothing reporting")
+ // Before the app reports its own internal instances: no "Reported by the
+ // app itself" disclosure — the tab is exactly the route-level view (which
+ // always renders its own "Running" header, fixture routes and all).
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "Reported by the app itself") {
+ t.Fatal("\"Reported by the app itself\" section rendered with nothing reporting")
}
// Kass starts reporting its three training instances.
@@ -1086,7 +1086,7 @@ func TestAppPageInstances(t *testing.T) {
Status: "ready", App: "kass"})
_, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
- "<h2>Instances</h2>", "<h2>Inside the app</h2>", "manages its own internal instances",
+ "<h2>Running</h2>", "Reported by the app itself", "tracks its own internal workers",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
@@ -1757,11 +1757,13 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
// The per-host CPU/Mem readings now live as columns on the Instances
// table itself (no separate Resources section) — each phrase below is
// unique to the one fixture route it describes, so checking presence
- // page-wide still pins it to that route.
+ // page-wide still pins it to that route. Not-reporting rows all show
+ // "no data" in the CPU/Mem cell; Asleep vs Not responding is the Health
+ // column's job, not the metrics cell's.
for _, want := range []string{
"owned.example.com", "11%", "1.0 KB",
- "asleep.example.com", "asleep — no recent readings",
- "unmetered.example.com", "no per-instance readings",
+ "asleep.example.com", "Asleep",
+ "unmetered.example.com", "Not responding", "no data",
} {
if !strings.Contains(body, want) {
t.Fatalf("app page missing %q in:\n%s", want, body)
@@ -1792,14 +1794,14 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body)
}
- if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ if !strings.Contains(body, `aria-current="page">Running</a>`) {
t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
// Content, not just chrome: CPU/Mem readings now live as columns on the
// route's own row, so a failed metrics read fails closed there — the
- // owned route still renders, with "no per-instance readings" in place
- // of numbers, not a partially-rendered page.
- if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no per-instance readings") {
+ // owned route still renders, with "no data" in place of numbers, not a
+ // partially-rendered page.
+ if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no data") {
t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body)
}
}
@@ -1882,13 +1884,13 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
// the same "just the per-route metrics chrome" scope, at its new address.
func resourcesSection(t *testing.T, body string) string {
t.Helper()
- i := strings.Index(body, "<h2>Instances</h2>")
+ i := strings.Index(body, "<h2>Running</h2>")
if i < 0 {
- t.Fatalf("no Instances section in:\n%s", body)
+ t.Fatalf("no Running section in:\n%s", body)
}
j := strings.Index(body[i:], "</main>")
if j < 0 {
- t.Fatalf("Instances section never ended:\n%s", body[i:])
+ t.Fatalf("Running section never ended:\n%s", body[i:])
}
return body[i : i+j]
}
@@ -1921,8 +1923,12 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
- if !strings.Contains(body, "asleep — no recent readings") {
- t.Fatalf("stale box did not render as no-recent-readings:\n%s", resourcesSection(t, body))
+ // The CPU/Mem cell reads the same "no data" a hibernating instance gets
+ // (staleness is not distinguishable from any other non-reporting cause at
+ // that cell); the route isn't hibernating, so the Health column must say
+ // so plainly rather than claim Asleep.
+ if !strings.Contains(body, "Not responding") || !strings.Contains(body, "no data") {
+ t.Fatalf("stale box did not render as not responding with no data:\n%s", resourcesSection(t, body))
}
for _, forbidden := range []string{"23%", "2.0 KB"} {
if strings.Contains(body, forbidden) {
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index ef50d2681..62f390e41 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -3,42 +3,44 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-<h2>Instances</h2>
+<h2>Running</h2>
{{if .InstanceRows}}
<div class="tablewrap">
<table class="instances">
- <tr><th>Instance</th><th>Channel</th><th>Running</th><th>Status</th><th>CPU</th><th>Memory</th></tr>
+ <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr>
{{range .InstanceRows}}
<tr>
- <td><a href="https://{{.Host}}">{{.Host}}</a><span class="small"> · {{.Kind}}</span></td>
+ <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>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td>
<td><code>{{.Channel}}</code></td>
- <td>{{if .Version}}<code>{{.Version}}</code>{{else}}<span title="the host has not reported an adopted release yet">—</span>{{end}}</td>
- <td>{{if and .Hibernate (not .Metrics.Reporting)}}asleep{{else}}{{.Status}}{{end}}</td>
{{if .Metrics.Reporting}}
- <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td>
- <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td>
+ <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td>
+ <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td>
{{else}}
- <td colspan="2"><span class="small">{{if .Hibernate}}asleep — no recent readings{{else}}no per-instance readings{{end}}</span></td>
+ <td colspan="2"><span class="small">no data</span></td>
{{end}}
</tr>
{{end}}
</table>
</div>
-<p class="small">Running is the release each host reports it has adopted;
-sparklines cover the last 48 hours. The memory line is drawn against that
-instance's own peak.</p>
+<p class="small">Version is what each machine reports it is running — a dash means
+it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn
+against that machine's own peak.</p>
{{else}}
-<p class="small">No instances yet — a host appears here when a route wires it
-to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
+<p>Nothing running yet. Publish a version, then give it a web address:</p>
+{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
+{{.AttachCmd}}</code></pre>{{else if .AttachCmd}}<pre><code>{{.AttachCmd}}</code></pre>{{end}}
+<p class="small">The address comes live within a couple of minutes.</p>
{{end}}
{{if and .Instances .Instances.Reporting .Instances.Instances}}
-<h2>Inside the app</h2>
-<p class="small">This app manages its own internal instances, and reports
-{{len .Instances.Instances}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
+<details>
+<summary>Reported by the app itself ({{len .Instances.Instances}})</summary>
+<p class="small">This app tracks its own internal workers and reports them to the platform.</p>
<div class="tablewrap">
<table>
- <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
+ <tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Region</th><th scope="col">Detail</th></tr>
{{range .Instances.Instances}}
<tr>
<td><code>{{.Slug}}</code></td>
@@ -49,5 +51,6 @@ to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
{{end}}
</table>
</div>
+</details>
{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
index 4f3b07c7f..ada05a6a0 100644
--- a/internal/console/templates/appnav.html
+++ b/internal/console/templates/appnav.html
@@ -6,7 +6,7 @@
<nav class="tabs" aria-label="App sections">
<a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
<a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
- <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a>
<a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
<a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
</nav>