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>
6 files changed,
+134
−54
internal/console/overview.go+38 −7internal/console/overview_test.go+31 −2internal/console/server.go+19 −8internal/console/server_test.go+25 −19internal/console/templates/app_instances.html+20 −17internal/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}} <host></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}} <host></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> |