carlos / platform Public

Merge origin/main into fleet-api (post-#144 catch-up)

Paul Campbell 946085371018614d029d87830212c4d6740e319d parent 14b9263parent fb59056
32 files changed, +3255 −153
  • apimode.go +37 −9
  • apimode_test.go +41 −0
  • deploy.go +1 −1
  • docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md +1328 −0
  • docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md +145 −0
  • instances_cli.go +25 −4
  • internal/console/apiinstances.go +168 −5
  • internal/console/apiinstances_test.go +225 −0
  • internal/console/appsettings.go +46 −11
  • internal/console/appsettings_test.go +57 −0
  • internal/console/flow.go +64 −3
  • internal/console/flow_test.go +98 −15
  • internal/console/listquery.go +151 −0
  • internal/console/listquery_test.go +72 −0
  • internal/console/metricsview.go +16 −5
  • internal/console/metricsview_test.go +61 −6
  • internal/console/overview.go +47 −17
  • internal/console/overview_test.go +136 −18
  • internal/console/server.go +27 −4
  • internal/console/server_test.go +16 −12
  • internal/console/templates/app_instances.html +17 −1
  • internal/console/templates/app_overview.html +17 −6
  • internal/console/templates/app_releases.html +16 −3
  • internal/console/templates/flow.html +13 −0
  • internal/console/templates/layout.html +4 −1
  • internal/console/view.go +53 −21
  • internal/console/view_test.go +148 −0
  • internal/host/agent.go +37 −1
  • internal/host/agent_test.go +44 −0
  • internal/host/metrics_loop.go +35 −9
  • internal/host/metrics_probes.go +60 −1
  • internal/host/metrics_probes_test.go +50 −0
diff --git a/apimode.go b/apimode.go
index edf525550..a26cd81bb 100644
--- a/apimode.go
+++ b/apimode.go
@@ -217,7 +217,11 @@ func apiAccount(raw, rawID *string) apiAcct {
func apiPath(a apiAcct, app, tail string) string {
p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail
if a.Exact {
- p += "?exact=sqid"
+ sep := "?"
+ if strings.Contains(p, "?") {
+ sep = "&"
+ }
+ p += sep + "exact=sqid"
}
return p
}
@@ -1258,6 +1262,12 @@ type apiInstanceRecord struct {
// response, since no box has adopted the record before this call
// returns it.
StagedVersion string `json:"staged_version"`
+ // Health is the console's best-effort read of the instance's live state
+ // (Task 9) — "running", "serving", "setting-up", "asleep", "no-readings",
+ // "not-responding", "failed", or empty when talking to a console that
+ // predates the field. Always empty on a create response, same reason
+ // StagedVersion is.
+ Health string `json:"health"`
}
// instanceCreate asks the console to create one instance record — the
@@ -1285,24 +1295,42 @@ func (s *apiSession) instanceCreate(ctx context.Context, a apiAcct, app, host, r
return out, nil
}
-// instanceList reads the app's instance records through the console,
-// honest about the server's 500-record cap.
-func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app string) ([]apiInstanceRecord, bool, error) {
- status, body, err := s.getJSON(ctx, apiPath(a, app, "/instances"))
+// instanceList reads the app's instance records through the console, honest
+// about the server's 500-record cap. match and health are the server-side
+// filters Task 9 added (q and health query params) — empty strings send
+// neither, and the console applies no filter. total is the server's count
+// of ALL matching records (not just the page returned here); a console that
+// predates Task 9 sends no "total" field at all, which decodes as 0 — the
+// CLI printer treats that as "unknown" rather than "zero matches", since a
+// non-empty recs slice from the same response proves it isn't really zero.
+func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, health string) ([]apiInstanceRecord, bool, int, error) {
+ tail := "/instances"
+ v := url.Values{}
+ if match != "" {
+ v.Set("q", match)
+ }
+ if health != "" {
+ v.Set("health", health)
+ }
+ if enc := v.Encode(); enc != "" {
+ tail += "?" + enc
+ }
+ status, body, err := s.getJSON(ctx, apiPath(a, app, tail))
if err != nil {
- return nil, false, err
+ return nil, false, 0, err
}
if status != http.StatusOK {
- return nil, false, apiErrorFromBody(status, body)
+ return nil, false, 0, apiErrorFromBody(status, body)
}
var res struct {
Instances []apiInstanceRecord `json:"instances"`
Truncated bool `json:"truncated"`
+ Total int `json:"total"`
}
if err := json.Unmarshal(body, &res); err != nil {
- return nil, false, fmt.Errorf("instances list: unreadable response: %w", err)
+ return nil, false, 0, fmt.Errorf("instances list: unreadable response: %w", err)
}
- return res.Instances, res.Truncated, nil
+ return res.Instances, res.Truncated, res.Total, nil
}
// apiLogsResponse is the console's logs answer (internal/console/apilogs.go)
diff --git a/apimode_test.go b/apimode_test.go
index aee51a0f1..3c3f98f69 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -12,6 +12,7 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "net/url"
"os"
"path/filepath"
"strings"
@@ -605,6 +606,46 @@ func TestAPISessionChannels(t *testing.T) {
}
}
+// TestInstanceListSendsFilters pins two things at once (Task 10): instanceList's
+// own request construction (q/health land as query params, total/truncated
+// decoded from the response) and apiPath's "?"+"&" join fix — an -account-id
+// (Exact) session must still produce a well-formed URL, with BOTH exact=sqid
+// and the filters, when the tail already carries a query string of its own.
+func TestInstanceListSendsFilters(t *testing.T) {
+ var gotPath string
+ var gotQuery url.Values
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.Path
+ gotQuery = r.URL.Query()
+ json.NewEncoder(w).Encode(map[string]any{"instances": []map[string]any{}, "truncated": false, "total": 0})
+ }))
+ defer ts.Close()
+
+ s := newTestSession(ts.URL)
+ _, truncated, total, err := s.instanceList(context.Background(), apiAcct{Seg: "bah", Exact: true}, "app", "api", "not-responding")
+ if err != nil {
+ t.Fatalf("instanceList: %v", err)
+ }
+ if truncated {
+ t.Fatal("truncated = true, want false")
+ }
+ if total != 0 {
+ t.Fatalf("total = %d, want 0", total)
+ }
+ if gotPath != "/api/cli/apps/bah/app/instances" {
+ t.Fatalf("path = %q, want /api/cli/apps/bah/app/instances", gotPath)
+ }
+ if got := gotQuery.Get("q"); got != "api" {
+ t.Fatalf("q = %q, want %q", got, "api")
+ }
+ if got := gotQuery.Get("health"); got != "not-responding" {
+ t.Fatalf("health = %q, want %q", got, "not-responding")
+ }
+ if got := gotQuery.Get("exact"); got != "sqid" {
+ t.Fatalf("exact = %q, want sqid — the account-id join must survive alongside q/health", got)
+ }
+}
+
// ---- Task 5: the four verbs wired to resolveMode --------------------------
// loginTo saves one live credential for origin/address and returns it, so a
diff --git a/deploy.go b/deploy.go
index 209930a4c..9b1693322 100644
--- a/deploy.go
+++ b/deploy.go
@@ -62,7 +62,7 @@ func runDeploy(args []string) {
acct := apiAccount(acctRaw, acctIDRaw)
printUsing(stdout, sess) // printed HERE, once: runShipAPI's own banner is suppressed below
ctx := context.Background()
- recs, _, err := sess.instanceList(ctx, acct, *app)
+ recs, _, _, err := sess.instanceList(ctx, acct, *app, "", "")
if err != nil {
log.Fatalf("deploy: list instances: %v", err)
}
diff --git a/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md
new file mode 100644
index 000000000..7489137fd
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md
@@ -0,0 +1,1328 @@
+# Dashboard Truth + Scale Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the app dashboard's health reporting truthful (readings for bespoke-unit routes, an honest "No readings" state, a route-granular account-mismatch banner) and scale the page to 100s of instances (flow-diagram rollup, Running-tab search/filter/pagination, API + CLI parity).
+
+**Architecture:** Box side, the 60s metrics sweep additionally samples the cgroup of any registry route with a stamped `Unit` (the #110 column). Console side, `OwnerAppMetrics` learns that third match key, `healthWord` gains an `expectReadings` distinction, the mismatch banner is computed per-route, and a shared list-query helper (q / health / opaque cursor) drives the Running tab, the instances API, and the CLI.
+
+**Tech Stack:** Go, html/template server-rendered console, SQLite registry, bucket release store. No JS added.
+
+**Spec:** `docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md` — read it first.
+
+## Global Constraints
+
+- Branch: `dashboard-truth-and-scale`, based on origin/main `db76025`. Rebase onto origin/main before opening the PR (a parallel session was rolling PR #140 onto main).
+- Gate before EVERY commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, plus `gofmt -l` clean on touched files. Sandbox: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled ("read-only file system" is the tell).
+- CI also cross-compiles `GOOS=darwin go build ./...` — no Linux-only type assumptions in anything linked into the CLI (`internal/host` is).
+- Wire shapes are additive only: readings chunks (`metrics/boxes/<box>/raw/*.json`), the instances API response, and the registry schema all have live producers/consumers. New JSON fields only; never rename, move, or re-type an existing field. When a storage shape changes, the consumer list is everything that PARSES the shape.
+- Production channel names are pinned in `internal/release/laddercheck_test.go` — do not touch channel semantics.
+- Any test that shells out to the carlos binary builds env through `carlosTestEnv` AND sets `cmd.Dir` to a scratch dir.
+- Health-word vocabulary (exact strings, used in templates, filters, and URLs): `Running`, `Serving`, `Setting up`, `Asleep`, `No readings`, `Not responding`, `Failed`.
+- Comment style: comments state constraints the code can't show; match the codebase's density (it is high and deliberate).
+- Commit messages end with `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+- Delegation (Paul's rule): suggested executor model is noted per task; name it explicitly when dispatching. Opus reviews Tasks 4, 5, and 9 (authority/production-touching).
+
+---
+
+### Task 1: RouteView carries Unit, Backing, Provisioned
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `internal/console/view.go` (RouteView struct ~line 112-133; Assemble's RouteView literal ~line 377-383)
+- Test: `internal/console/view_test.go`
+
+**Interfaces:**
+- Consumes: `registry.Route.Unit`, `.Backing`, `.Provisioned` (already scanned by `registry.List()`).
+- Produces: `RouteView.Unit string`, `RouteView.Backing string`, `RouteView.Provisioned bool` — Tasks 2, 3, 6, 9 read these.
+
+- [ ] **Step 1: Write the failing test**
+
+In `internal/console/view_test.go`, add (follow `buildFixtures`' existing registry-seeding pattern in this file — it builds a real SQLite registry; use `registry.Open` + the same route-insert helper it uses, adding a route with `Unit`, `Backing`, and `Provisioned` set — check the exact insert call `buildFixtures` makes and mirror it):
+
+```go
+func TestAssembleCarriesProcessOwnership(t *testing.T) {
+ // Seed one route with Unit "titogo@demo.service", Backing
+ // registry.BackingUnit, Provisioned true (via buildFixtures' insert
+ // path), then Assemble and find its RouteView.
+ views := assembleFixtureViews(t) // or inline Assemble(...) as sibling tests do
+ rt := findRoute(t, views, "unit-stamped.example.test")
+ if rt.Unit != "titogo@demo.service" || rt.Backing != registry.BackingUnit || !rt.Provisioned {
+ t.Fatalf("RouteView lost process ownership: %+v", rt)
+ }
+}
+```
+
+(If `view_test.go` has no `findRoute` helper, write the loop inline — sibling tests show the pattern.)
+
+- [ ] **Step 2: Run it, verify it fails** — `go test ./internal/console/ -run TestAssembleCarriesProcessOwnership -count=1` → FAIL (fields don't exist / are zero).
+
+- [ ] **Step 3: Implement** — add to `RouteView` (after `Caps`):
+
+```go
+ // Unit is the bespoke systemd unit bound to this route
+ // (registry.Route.Unit, #110): set when an operator-run service owns the
+ // route's process. The sweep samples stamped units (Task 5), so a stamped
+ // route's silence is meaningful — see expectsReadings.
+ Unit string
+ // Backing mirrors registry.Route.Backing: "" = an exec child the
+ // activator spawns, registry.BackingUnit = a carlos-app@<host> unit.
+ Backing string
+ // Provisioned mirrors registry.Route.Provisioned: minted from a bucket
+ // instance record, so the activator runs its process by construction.
+ Provisioned bool
+```
+
+and to Assemble's `RouteView{...}` literal: `Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,`.
+
+- [ ] **Step 4: Run the test, verify PASS**, then the full package: `go test ./internal/console/ -count=1`.
+
+- [ ] **Step 5: Commit** — `git add internal/console/view.go internal/console/view_test.go && git commit` — `console: RouteView carries Unit/Backing/Provisioned`.
+
+---
+
+### Task 2: healthWord tells "Not responding" from "No readings"
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `internal/console/overview.go` (`healthWord` ~line 258, `assembleInstanceRows` ~line 246, new `expectsReadings`)
+- Modify: `internal/console/flow.go` (`addressTone` ~line 100)
+- Modify: `internal/console/templates/app_instances.html` (Health cell, line 14)
+- Test: `internal/console/overview_test.go`
+
+**Interfaces:**
+- Consumes: Task 1's `RouteView.Unit/Backing/Provisioned`.
+- Produces: `func expectsReadings(provisioned bool, unit, backing string) bool` and `func healthWord(kind, status string, hibernate, reporting, expectReadings bool) string` — Tasks 3, 8, 9 call these. New health word `"No readings"`.
+
+- [ ] **Step 1: Write the failing tests** — extend the existing healthWord table test in `overview_test.go` (it exists; the a332e7f commit row-scoped it). Add cases:
+
+```go
+// 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"},
+```
+
+And a direct test of the rule:
+
+```go
+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)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run, verify FAIL** (new parameter doesn't exist).
+
+- [ ] **Step 3: Implement** in `overview.go`:
+
+```go
+// 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
+}
+```
+
+(add `"github.com/carlosframework/platform/internal/registry"` to overview.go's imports). Change `healthWord`:
+
+```go
+func healthWord(kind, status string, hibernate, reporting, expectReadings 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"
+ case expectReadings:
+ return "Not responding"
+ default:
+ return "No readings"
+ }
+}
+```
+
+In `assembleInstanceRows`, the call becomes:
+
+```go
+HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(),
+ expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)),
+```
+
+In `flow.go` `addressTone`, add `"No readings"` explicitly to the idle arm (it already falls there via default; naming it documents that it is deliberate):
+
+```go
+ case "Asleep", "No readings":
+ return "idle"
+```
+
+(Adjust: today `Asleep` is in the default; keep whatever structure is there, just ensure both words yield `"idle"` and the comment covers "No readings".) In `app_instances.html`'s Health cell add after the Asleep hint:
+
+```html
+{{if eq .HealthWord "No readings"}}<span class="small"> · stamp its unit (carlos route -unit) to see health here</span>{{end}}
+```
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (fix any other healthWord call sites the compiler finds — `grep -rn "healthWord(" internal/console/`).
+
+- [ ] **Step 5: Commit** — `console: healthWord distinguishes Not responding from No readings`.
+
+---
+
+### Task 3: Overview tiles stop counting the unknowable
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `internal/console/overview.go` (`OverviewStats`, `assembleOverview`, `healthVerdict`)
+- Modify: `internal/console/templates/app_overview.html` (Running tile lines 37-41, Web addresses tile line 50)
+- Test: `internal/console/overview_test.go`
+
+**Interfaces:**
+- Consumes: Task 2's `expectsReadings`.
+- Produces: `OverviewStats.InstanceNoReadings int`, `OverviewStats.InstanceCounted int` (= Expected − NoReadings). Templates and Task 8's header read these.
+
+- [ ] **Step 1: Failing test** — in `overview_test.go`, next to existing `assembleOverview` tests (mirror their AppView/metrics construction):
+
+```go
+func TestOverviewSeparatesNoReadings(t *testing.T) {
+ view := &AppView{Routes: []RouteView{
+ {Host: "a.x", Kind: "instance", Account: "acct1", Provisioned: true}, // reporting
+ {Host: "b.x", Kind: "instance", Account: "acct1", Provisioned: true}, // silent, expected
+ {Host: "c.x", Kind: "instance", Account: "acct1"}, // silent, bespoke-unstamped
+ {Host: "d.x", Kind: "instance", Account: "acct1", Hibernate: true}, // asleep
+ }, Domains: []DomainView{{}, {}, {}, {}}}
+ metrics := []InstanceMetrics{{Host: "a.x", Box: "box1"}}
+ st := assembleOverview(view, "acct1", metrics, nil)
+ if st.InstanceExpected != 4 || st.InstanceReporting != 1 || st.InstanceAsleep != 1 ||
+ st.InstanceNoReadings != 1 || st.InstanceCounted != 3 {
+ t.Fatalf("counts: %+v", st)
+ }
+}
+
+func TestVerdictIgnoresNoReadings(t *testing.T) {
+ // 1 reporting + 1 asleep + 1 no-readings out of 3 counted... construct an
+ // OverviewStats where Reporting+Asleep == InstanceCounted and assert the
+ // verdict is NOT "attention" on account of the no-readings row.
+ st := OverviewStats{HasStable: true, InstanceExpected: 3, InstanceCounted: 2,
+ InstanceReporting: 1, InstanceAsleep: 1, InstanceNoReadings: 1}
+ if v, _ := healthVerdict(st); v != "ok" {
+ t.Fatalf("no-readings rows must not trip the verdict: %s", v)
+ }
+}
+```
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement** — `OverviewStats` gains (after `InstanceAsleep`):
+
+```go
+ // InstanceNoReadings counts instance-kind routes nobody could sample: not
+ // provisioned, no carlos-app@ backing, no stamped unit. They are honest
+ // unknowns — the tile names them separately and the verdict must not
+ // treat them as down (the 2026-08-10 tito false alarm).
+ InstanceNoReadings int
+ // InstanceCounted is the Running tile's denominator: InstanceExpected
+ // minus the unknowable. Templates render facts, never compute them.
+ InstanceCounted int
+```
+
+`assembleOverview`'s route loop:
+
+```go
+ if rt.Kind == "instance" {
+ st.InstanceExpected++
+ if reporting[rt.Host] {
+ st.InstanceReporting++
+ } else if rt.Hibernate {
+ st.InstanceAsleep++
+ } else if !expectsReadings(rt.Provisioned, rt.Unit, rt.Backing) {
+ st.InstanceNoReadings++
+ }
+ }
+```
+
+then before the `healthVerdict` call: `st.InstanceCounted = st.InstanceExpected - st.InstanceNoReadings`. In `healthVerdict`, the shortfall case becomes:
+
+```go
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceCounted:
+ return "attention", "Needs attention"
+```
+
+Template — Running tile value/detail lines become:
+
+```html
+ <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else if eq .InstanceCounted 0}}?{{else}}{{.InstanceReporting}} of {{.InstanceCounted}}{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceCounted 0}}no readings for {{.InstanceNoReadings}} copies — stamp their units to see health{{else}}copies reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{if .InstanceNoReadings}} · {{.InstanceNoReadings}} no readings{{end}}{{end}}</p>
+```
+
+Web addresses tile: replace `all working` with `DNS &amp; certs OK`.
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS. Fix any existing test asserting the old copy (`grep -rn "all working\|copies responding" internal/console/`).
+
+- [ ] **Step 5: Commit** — `console: Running tile separates not-tracked copies; Web addresses says what it measures`.
+
+---
+
+### Task 4: Account-mismatch banner at route granularity
+
+**Model:** sonnet — **Opus reviews** (this banner gates deploy-confidence messaging).
+
+**Files:**
+- Modify: `internal/console/view.go` (`AppView`, `AccountMismatch`, `Assemble` ~lines 345-425)
+- Modify: `internal/console/templates/app_overview.html` (lines 5-15) and `internal/console/templates/app_releases.html` (its twin banner, lines 5-12)
+- Test: `internal/console/view_test.go` (assembly), `internal/console/overview_test.go` (rendering)
+
+**Interfaces:**
+- Produces: `type MismatchedRoute struct{ Host, Account string }`, `AppView.Mismatched []MismatchedRoute`, `AppView.AllMismatched bool`. `AppView.RouteAccount` keeps its meaning (first mismatched account, `""` when none) so `AccountMismatch()` semantics survive for any other caller.
+
+- [ ] **Step 1: Failing tests** — in `view_test.go`, three cases via the fixture registry (seed routes stamped to another account; mirror how buildFixtures stamps accounts — `release.RouteAccount` reads the row's sqid):
+
+```go
+func TestMismatchListsRoutes(t *testing.T) {
+ // App with 3 non-platform routes, 1 stamped to "otheracct":
+ // Mismatched == [{host1, otheracct}], AllMismatched == false.
+}
+func TestMismatchAllRoutes(t *testing.T) {
+ // All 3 stamped elsewhere: len(Mismatched) == 3, AllMismatched == true.
+}
+func TestMismatchIgnoresPlatformRoutes(t *testing.T) {
+ // A platform-minted route stamped elsewhere contributes nothing.
+}
+```
+
+Write real assertions (the sketches above name the expected values); construct via the same fixture path Task 1 used.
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement** — in `view.go`:
+
+```go
+// MismatchedRoute is one route whose stamped account disagrees with the
+// account whose page is being viewed: host plus where it actually reads
+// releases from. The banner names these — a warning that says WHICH routes
+// is a diagnostic; one that says "this app" is an alarm, and on tito
+// (2026-08-10) it was a false one.
+type MismatchedRoute struct{ Host, Account string }
+```
+
+`AppView` gains:
+
+```go
+ // Mismatched is every non-platform route stamped to another account;
+ // AllMismatched is whether that is ALL of them — only then is "this app
+ // is not deploying" a claim the page can back.
+ Mismatched []MismatchedRoute
+ AllMismatched bool
+```
+
+`AccountMismatch()` becomes `return len(v.Mismatched) > 0`. In `Assemble`, replace the `routeAccount` map with:
+
+```go
+ mismatchedByApp := map[string][]MismatchedRoute{}
+ routableByApp := map[string]int{}
+```
+
+and in the route loop (replacing the `routeAccount[rt.App]` block):
+
+```go
+ if rt.App != "" && !rt.Platform {
+ routableByApp[rt.App]++
+ if a := release.RouteAccount(rt); a != acct {
+ mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a})
+ }
+ }
+```
+
+In the per-app assembly (replacing the `mismatch :=` block):
+
+```go
+ mm := mismatchedByApp[app]
+ mismatch := ""
+ if len(mm) > 0 {
+ mismatch = mm[0].Account
+ }
+```
+
+and the `AppView{...}` literal gains `Mismatched: mm, AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],` (keeping `RouteAccount: mismatch`). Templates — in BOTH `app_overview.html` and `app_releases.html`, replace the banner block with:
+
+```html
+{{if .Detail.AccountMismatch}}
+{{if .Detail.AllMismatched}}
+<p class="banner error">
+ <strong>This app is not deploying.</strong> Every one of its addresses reads
+ releases from account <code>{{.Detail.RouteAccount}}</code>, but this page
+ writes them to <code>{{.Account.Sqid}}</code> — so a promote here succeeds
+ and reaches nothing. Affected: {{range .Detail.Mismatched}}<code>{{.Host}}</code> {{end}}
+ Finish the move on this app's
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
+ <span class="small">If it is the routes that are in the wrong account, move
+ those back instead — whichever of the two is right, they have to agree.</span>
+</p>
+{{else}}
+<p class="banner">
+ {{len .Detail.Mismatched}} of this app's addresses read releases from another
+ account and won't follow promotes made here:
+ {{range .Detail.Mismatched}}<code>{{.Host}}</code> (reads <code>{{.Account}}</code>) {{end}}
+ — the rest deploy normally. Fix on
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
+</p>
+{{end}}
+{{end}}
+```
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (existing banner tests will need the new copy — update assertions to match the new strings, not the other way around).
+
+- [ ] **Step 5: Commit** — `console: account-mismatch banner names routes, claims "not deploying" only when true`.
+
+---
+
+### Task 5: Box sweep samples stamped units
+
+**Model:** opus (root-side, fleet-wide blast radius) — **Opus reviews.**
+
+**Files:**
+- Modify: `internal/host/metrics_probes.go` (new `listStampedUnits`)
+- Modify: `internal/host/metrics_loop.go` (`realUnitReadings` ~line 639, `realSampleProbes` ~line 621, `metricsLoop` ~line 705 — thread a `stampedUnits func() []string` through)
+- Modify: `internal/host/agent.go` (~line 194, the `go metricsLoop(cgm.Root())` call; new `stampedUnitNames`)
+- Test: `internal/host/metrics_probes_test.go` (or wherever `listCarlosUnits`' tests live — find with `grep -rn "listCarlosUnits" internal/host/*_test.go`)
+
+**Interfaces:**
+- Consumes: `registry.Route.Unit` via `reg.List()`; `unitCgroup{name, dir}`; `readUnitRaw`.
+- Produces: readings for stamped units appear in `metrics.Sample.Units` keyed by the REAL unit name (e.g. `titogo@vicky.service`) — the same additive wire shape; Task 6 matches on it.
+
+- [ ] **Step 1: Failing test** — temp-dir fixture, same style as the existing `listCarlosUnits` test:
+
+```go
+func TestListStampedUnits(t *testing.T) {
+ root := t.TempDir()
+ // flat bespoke unit
+ mustMkdir(t, filepath.Join(root, "console.service"))
+ // template instance inside its implicit per-template slice
+ mustMkdir(t, filepath.Join(root, "system-titogo\\x2dcanary.slice", "titogo-canary@demo.service"))
+ // present on disk but NOT stamped — must not be returned
+ mustMkdir(t, filepath.Join(root, "postgres.service"))
+
+ got := listStampedUnits(root, []string{"console.service", "titogo-canary@demo.service", "absent@x.service"})
+ want := map[string]bool{"console.service": true, "titogo-canary@demo.service": true}
+ if len(got) != 2 {
+ t.Fatalf("got %d units: %+v", len(got), got)
+ }
+ for _, u := range got {
+ if !want[u.name] {
+ t.Fatalf("unexpected unit %q", u.name)
+ }
+ if _, err := os.Stat(u.dir); err != nil {
+ t.Fatalf("dir %q not the real cgroup dir: %v", u.dir, err)
+ }
+ }
+}
+```
+
+(`mustMkdir` = `os.MkdirAll` + `t.Fatal` on error; add if absent. Note the literal directory name contains `\x2d` — systemd's escaping — written in Go as `"system-titogo\\x2dcanary.slice"`.)
+
+- [ ] **Step 2: Run, verify FAIL** (`listStampedUnits` undefined).
+
+- [ ] **Step 3: Implement** in `metrics_probes.go`, after `listCarlosUnits`:
+
+```go
+// listStampedUnits finds the cgroup directories of operator-stamped bespoke
+// units — registry Route.Unit (#110), the routes the carlos- naming
+// convention can't see (tito's titogo@*, an operator's console.service).
+// Same two-level walk as listCarlosUnits, for the same reason: flat units
+// sit under root, template instances inside their implicit system-*.slice.
+// A stamped unit with no cgroup dir simply isn't returned — no reading is
+// the honest report for a unit that isn't running, and the console renders
+// its absence, never a guessed zero.
+func listStampedUnits(root string, stamped []string) []unitCgroup {
+ if len(stamped) == 0 {
+ return nil
+ }
+ want := make(map[string]bool, len(stamped))
+ for _, u := range stamped {
+ want[u] = true
+ }
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil
+ }
+ var units []unitCgroup
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if want[name] {
+ units = append(units, unitCgroup{name: name, dir: filepath.Join(root, name)})
+ continue
+ }
+ if !strings.HasPrefix(name, "system-") || !strings.HasSuffix(name, ".slice") {
+ continue
+ }
+ sliceDir := filepath.Join(root, name)
+ nested, err := os.ReadDir(sliceDir)
+ if err != nil {
+ continue // an unreadable slice loses its units, never the whole scan
+ }
+ for _, child := range nested {
+ if child.IsDir() && want[child.Name()] {
+ units = append(units, unitCgroup{name: child.Name(), dir: filepath.Join(sliceDir, child.Name())})
+ }
+ }
+ }
+ return units
+}
+```
+
+- [ ] **Step 4: Run the new test → PASS.**
+
+- [ ] **Step 5: Thread it through the loop.** Read `metrics_loop.go`'s `realUnitReadings` (line ~639), `realSampleProbes` (~621), `metricsLoop` (~705) and the `sampler`/`sampleProbes` types they build, then:
+ - `realUnitReadings(execRoot string)` → `realUnitReadings(execRoot string, stamped []string)`; its unit list becomes `listCarlosUnits(cgroupSliceDir)`, then `append(units, listStampedUnits(cgroupSliceDir, stamped)...)`, then `append(units, listExecCgroups(execRoot)...)` (the `out[u.name] =` map write already dedupes a stamped unit that happens to match the carlos convention).
+ - `realSampleProbes` and `metricsLoop` gain the parameter: `metricsLoop(execRoot string, stampedUnits func() []string)`, calling `stampedUnits()` once per tick and passing the slice down. A nil func is treated as none (guard: `var stamped []string; if stampedUnits != nil { stamped = stampedUnits() }`).
+ - Update every test double / call site the compiler flags (`go build ./internal/host/`).
+
+- [ ] **Step 6: Registry read + agent wiring.** In `agent.go`, next to the other loops:
+
+```go
+// stampedUnitNames is the sweep's one registry read: every distinct bespoke
+// unit stamped on a route (Route.Unit). Called once per 60s tick; a read
+// failure costs this tick's stamped units, never the sweep or the agent.
+func stampedUnitNames(reg *registry.Registry) []string {
+ routes, err := reg.List()
+ if err != nil {
+ return nil
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, rt := range routes {
+ if rt.Unit == "" || seen[rt.Unit] {
+ continue
+ }
+ seen[rt.Unit] = true
+ out = append(out, rt.Unit)
+ }
+ sort.Strings(out)
+ return out
+}
+```
+
+and change the launch (line ~194): `go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })`. Add a small test for `stampedUnitNames` against a fixture registry (dedupe + empty-unit skip) if `internal/host` has registry-fixture tests; if none exist, the `listStampedUnits` + loop-plumbing coverage suffices — note it in the commit message.
+
+- [ ] **Step 7: Full gate** — `go build ./... && go vet ./... && go test ./... -count=1` and `GOOS=darwin go build ./...` (host package links into the CLI). → all PASS.
+
+- [ ] **Step 8: Commit** — `host: metrics sweep samples registry-stamped bespoke units`.
+
+---
+
+### Task 6: Console matches stamped units
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `internal/console/metricsview.go` (`OwnerAppMetrics` ~line 709)
+- Modify: `internal/console/server.go` (`appResourceMetrics` line 1475)
+- Test: `internal/console/metricsview_test.go` (find `OwnerAppMetrics`' existing tests: `grep -rn "OwnerAppMetrics" internal/console/*_test.go`)
+
+**Interfaces:**
+- Consumes: Task 1's `RouteView.Unit`; sweep entries keyed by real unit names (Task 5).
+- Produces: `OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error)` — Task 9 also calls this.
+
+- [ ] **Step 1: Failing test** — mirror the existing OwnerAppMetrics test's fixture (it fabricates `BoxMetricsView`/sweep data); add:
+
+```go
+func TestOwnerAppMetricsMatchesStampedUnit(t *testing.T) {
+ // Sweep contains a unit row named "titogo@vicky.service" on box "b1".
+ // OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"},
+ // map[string]string{"vicky.go.tito.io": "titogo@vicky.service"})
+ // must return that host with Box "b1" (Reporting true).
+ // And WITHOUT the unitByHost entry, the same host must come back
+ // Reporting false — the third key is opt-in per host, not a wildcard.
+}
+```
+
+Write the real body against the fixture helpers the sibling test uses.
+
+- [ ] **Step 2: Run, verify FAIL** (signature).
+
+- [ ] **Step 3: Implement** — `OwnerAppMetrics` gains the param and one block in the allowlist loop:
+
+```go
+ want[activator.UnitName(h)] = h
+ want[activator.ExecName(h)] = h
+ // The third legal key: an operator-stamped bespoke unit
+ // (registry Route.Unit, #110). Opt-in per host — the caller maps
+ // host → stamped unit from the route rows it already holds; an
+ // empty map costs nothing and matches nothing new.
+ if u := unitByHost[h]; u != "" {
+ want[u] = h
+ }
+```
+
+(A nil map indexes fine in Go — no guard needed.) `appResourceMetrics` builds it from the fields Task 1 added:
+
+```go
+ unitByHost := make(map[string]string, len(view.Routes))
+ for _, rt := range view.Routes {
+ if rt.Account != acct.Sqid || rt.Unit == "" {
+ continue
+ }
+ unitByHost[rt.Host] = rt.Unit
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
+```
+
+Fix every other `OwnerAppMetrics` call site the compiler flags (pass `nil` where no route rows are in hand).
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS.
+
+- [ ] **Step 5: Commit** — `console: OwnerAppMetrics matches operator-stamped units`.
+
+---
+
+### Task 7: Flow diagram rolls up above 8 addresses
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `internal/console/flow.go` (`FlowStage`, `assembleFlow` ~line 126)
+- Modify: `internal/console/server.go` (`assembleFlow` call in `handleApp`, ~line 1517 — `grep -n "assembleFlow" internal/console/server.go`)
+- Modify: `internal/console/templates/flow.html` (`flow_stage` template)
+- Test: `internal/console/flow_test.go`
+
+**Interfaces:**
+- Consumes: `InstanceRow.HealthWord` vocabulary (Task 2).
+- Produces: `FlowStage.RolledUp bool, Total, Attention, Hidden int, MoreURL string`; `assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView`.
+
+- [ ] **Step 1: Failing tests** (pure function — exact code, adapt only the AppView/rows construction to sibling tests' style):
+
+```go
+func TestFlowRollsUpBigStages(t *testing.T) {
+ rows := make([]InstanceRow, 0, 12)
+ for i := 0; i < 12; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ w := "Running"
+ if i == 3 || i == 7 {
+ w = "Not responding"
+ }
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0] // edge
+ if !st.RolledUp || st.Total != 12 || st.Attention != 2 || st.Hidden != 0 {
+ t.Fatalf("rollup: %+v", st)
+ }
+ if len(st.Addresses) != 2 || st.Addresses[0].Health != "Not responding" {
+ t.Fatalf("exceptions: %+v", st.Addresses)
+ }
+ if st.MoreURL != "/a/x/apps/app/instances" {
+ t.Fatalf("MoreURL: %q", st.MoreURL)
+ }
+}
+
+func TestFlowSmallStagesUnchanged(t *testing.T) {
+ // 8 addresses exactly: RolledUp false, all 8 listed.
+}
+
+func TestFlowRollupCapsExceptions(t *testing.T) {
+ // 20 addresses, 9 "Not responding": Attention 9, len(Addresses) 5, Hidden 4.
+}
+```
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement** — constants + filter in `flow.go`:
+
+```go
+// flowAddrMax is the most addresses a stage lists in full. Below it the
+// diagram looks exactly as it always has; above it, a page anticipating
+// hundreds of per-customer instances (2026-08-10 design) shows counts and
+// problems, never an unbounded list.
+const flowAddrMax = 8
+
+// flowExceptionMax caps the needs-attention list inside a rolled-up stage;
+// the Running tab is where the full story lives.
+const flowExceptionMax = 5
+
+// needsAttention is the rollup's filter: the words worth naming when there
+// are too many addresses to name them all. Asleep and No readings are
+// deliberately not here — counted, not listed (hibernation is the system
+// working; no-readings is ignorance, not alarm).
+func needsAttention(health string) bool {
+ switch health {
+ case "Not responding", "Failed", "Setting up":
+ return true
+ }
+ return false
+}
+```
+
+`FlowStage` gains the fields (comment block from Interfaces above). `assembleFlow` gains the `instancesPath string` param; inside `stage()`, after `Addresses: byChannel[channel]`:
+
+```go
+ if addrs := s.Addresses; len(addrs) > flowAddrMax {
+ s.RolledUp = true
+ s.Total = len(addrs)
+ s.MoreURL = instancesPath
+ var exceptions []FlowAddress
+ for _, a := range addrs {
+ if !needsAttention(a.Health) {
+ continue
+ }
+ s.Attention++
+ if len(exceptions) < flowExceptionMax {
+ exceptions = append(exceptions, a)
+ }
+ }
+ s.Hidden = s.Attention - len(exceptions)
+ s.Addresses = exceptions
+ }
+```
+
+`handleApp`'s call site passes `p.Nav+"/apps/"+p.AppName+"/instances"` (check the actual field expressions used two lines up in the same handler for Nav/AppName spelling). `flow_stage` template — wrap the addresses block:
+
+```html
+ {{if .RolledUp}}
+ <p class="flow-note">{{.Total}} addresses{{if .Attention}} · <span class="error">{{.Attention}} need{{if eq .Attention 1}}s{{end}} attention</span>{{end}}</p>
+ {{if .Addresses}}
+ <ul class="flow-addrs">
+ {{range .Addresses}}
+ <li class="flow-addr flow-tone-{{.Tone}}">
+ <a href="https://{{.Host}}">{{.Host}}</a>
+ <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{end}}
+ <p class="flow-note"><a href="{{.MoreURL}}">{{if .Hidden}}+{{.Hidden}} more · {{end}}all addresses</a></p>
+ {{else}}{{if .Addresses}}
+ <ul class="flow-addrs">
+ ... (the existing block, unchanged)
+ </ul>
+ {{end}}{{end}}
+```
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS (other `assembleFlow` call sites/tests get the new arg — compiler finds them).
+
+- [ ] **Step 5: Commit** — `console: flow diagram rolls up stages above 8 addresses`.
+
+---
+
+### Task 8: Running tab — search, health filter, cursor pagination
+
+**Model:** sonnet
+
+**Files:**
+- Create: `internal/console/listquery.go`
+- Modify: `internal/console/server.go` (`handleAppInstances` ~line 1599; `page` struct ~line 526)
+- Modify: `internal/console/templates/app_instances.html`
+- Test: Create `internal/console/listquery_test.go`; extend `internal/console/overview_test.go` (page rendering)
+
+**Interfaces:**
+- Consumes: `InstanceRow` (host-ordered), health vocabulary.
+- Produces: `parseInstanceQuery(url.Values) instanceQuery`, `pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage`, `encodeInstanceCursor/decodeInstanceCursor`, `healthSlug/healthFromSlug`, `healthWords` — Task 9 reuses the query/cursor/slug pieces. `const instancePageSize = 50`.
+
+- [ ] **Step 1: Failing tests** in `listquery_test.go` (pure — exact code):
+
+```go
+package console
+
+import (
+ "net/url"
+ "testing"
+)
+
+func rowsFor(hosts ...string) []InstanceRow {
+ out := make([]InstanceRow, len(hosts))
+ for i, h := range hosts {
+ out[i] = InstanceRow{Host: h, HealthWord: "Running"}
+ }
+ return out
+}
+
+func TestHealthSlugRoundTrips(t *testing.T) {
+ for _, w := range healthWords {
+ if got := healthFromSlug(healthSlug(w)); got != w {
+ t.Fatalf("%q -> %q -> %q", w, healthSlug(w), got)
+ }
+ }
+ if healthFromSlug("nonsense") != "" {
+ t.Fatal("unknown slug must mean no filter, not an error")
+ }
+}
+
+func TestPageFiltersAndCounts(t *testing.T) {
+ rows := rowsFor("a.x", "ab.x", "b.x")
+ rows[2].HealthWord = "Asleep"
+ p := pageInstanceRows(rows, instanceQuery{Q: "a"}, 50)
+ if p.Total != 2 || len(p.Rows) != 2 || p.NextCursor != "" {
+ t.Fatalf("%+v", p)
+ }
+ if p.Counts["Running"] != 2 || p.Counts["Asleep"] != 1 {
+ t.Fatalf("counts are over ALL rows, filtered or not: %+v", p.Counts)
+ }
+ p = pageInstanceRows(rows, instanceQuery{Health: "Asleep"}, 50)
+ if p.Total != 1 || p.Rows[0].Host != "b.x" {
+ t.Fatalf("%+v", p)
+ }
+}
+
+func TestPageCursors(t *testing.T) {
+ rows := rowsFor("a.x", "b.x", "c.x", "d.x", "e.x")
+ p1 := pageInstanceRows(rows, instanceQuery{}, 2)
+ if len(p1.Rows) != 2 || p1.NextCursor == "" || p1.Rows[1].Host != "b.x" {
+ t.Fatalf("page1: %+v", p1)
+ }
+ p2 := pageInstanceRows(rows, instanceQuery{Cursor: p1.NextCursor}, 2)
+ if len(p2.Rows) != 2 || p2.Rows[0].Host != "c.x" {
+ t.Fatalf("page2: %+v", p2)
+ }
+ p3 := pageInstanceRows(rows, instanceQuery{Cursor: p2.NextCursor}, 2)
+ if len(p3.Rows) != 1 || p3.Rows[0].Host != "e.x" || p3.NextCursor != "" {
+ t.Fatalf("page3: %+v", p3)
+ }
+}
+
+func TestPageGarbageCursorStartsOver(t *testing.T) {
+ rows := rowsFor("a.x", "b.x")
+ p := pageInstanceRows(rows, instanceQuery{Cursor: "!!not-base64!!"}, 50)
+ if len(p.Rows) != 2 {
+ t.Fatalf("bad cursor must degrade to page one: %+v", p)
+ }
+}
+
+func TestParseInstanceQuery(t *testing.T) {
+ q := parseInstanceQuery(url.Values{"q": {" api "}, "health": {"not-responding"}, "cursor": {"abc"}})
+ if q.Q != "api" || q.Health != "Not responding" || q.Cursor != "abc" {
+ t.Fatalf("%+v", q)
+ }
+}
+```
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement `listquery.go`** (exact):
+
+```go
+// The Running tab's and the instances API's shared list controls: a
+// substring match, a health-word filter, and an opaque after-this-host
+// cursor. GET-param driven and server-computed on purpose — a filter the
+// server applies is one the API and CLI can apply identically (every carlos
+// command works for a user with no box access), and a bookmarked filtered
+// URL keeps meaning the same thing.
+package console
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/url"
+ "strings"
+)
+
+// instancePageSize is the Running tab's page length.
+const instancePageSize = 50
+
+// healthWords is every word healthWord can emit, in the order the header
+// lists them. A new word joins healthWord, this list, and nothing else —
+// slugs, filters and links all derive from here.
+var healthWords = []string{"Running", "Serving", "Setting up", "Asleep", "No readings", "Not responding", "Failed"}
+
+// instanceQuery is one request's list controls. Zero value = everything,
+// first page.
+type instanceQuery struct {
+ Q string // substring match on host, trimmed
+ Health string // canonical health word ("" = all)
+ Cursor string // opaque; list resumes after the host inside it
+}
+
+func parseInstanceQuery(v url.Values) instanceQuery {
+ return instanceQuery{
+ Q: strings.TrimSpace(v.Get("q")),
+ Health: healthFromSlug(v.Get("health")),
+ Cursor: v.Get("cursor"),
+ }
+}
+
+// healthSlug is a health word's URL form: "Not responding" ↔
+// "not-responding".
+func healthSlug(word string) string {
+ return strings.ReplaceAll(strings.ToLower(word), " ", "-")
+}
+
+// healthFromSlug inverts healthSlug. An unknown slug returns "" — no filter:
+// a mistyped URL shows everything rather than a mysteriously empty list.
+func healthFromSlug(slug string) string {
+ for _, w := range healthWords {
+ if healthSlug(w) == slug {
+ return w
+ }
+ }
+ return ""
+}
+
+type instanceCursor struct {
+ Host string `json:"host"`
+}
+
+func encodeInstanceCursor(host string) string {
+ b, _ := json.Marshal(instanceCursor{Host: host})
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// decodeInstanceCursor degrades on any malformed input to "no cursor" —
+// page one, never an error page (applog.DecodeCursor's rule).
+func decodeInstanceCursor(s string) (string, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return "", false
+ }
+ var c instanceCursor
+ if json.Unmarshal(b, &c) != nil || c.Host == "" {
+ return "", false
+ }
+ return c.Host, true
+}
+
+// healthLink is one header chip: a word, its count over ALL the app's rows,
+// and the link that filters to it. Precomputed — templates render facts.
+type healthLink struct {
+ Word, Slug string
+ Count int
+ Active bool
+}
+
+// instancePage is one screen of the list: the rows to render plus the
+// numbers and links the header states.
+type instancePage struct {
+ Rows []InstanceRow
+ Counts map[string]int // health word -> count over ALL rows
+ Total int // rows matching Q+Health, across all pages
+ Links []healthLink // non-zero words, healthWords order
+ NextCursor string // "" on the last page
+ NextURL string // the whole next-page href; "" on the last page
+}
+
+// pageInstanceRows applies q to rows (which arrive in host order and stay in
+// it) and cuts one page. Counts cover every row regardless of filter — the
+// header's chips are the app's whole health story, and double as filters.
+func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage {
+ p := instancePage{Counts: map[string]int{}}
+ var matched []InstanceRow
+ for _, r := range rows {
+ p.Counts[r.HealthWord]++
+ if q.Q != "" && !strings.Contains(r.Host, q.Q) {
+ continue
+ }
+ if q.Health != "" && r.HealthWord != q.Health {
+ continue
+ }
+ matched = append(matched, r)
+ }
+ p.Total = len(matched)
+ start := 0
+ if after, ok := decodeInstanceCursor(q.Cursor); ok {
+ for i, r := range matched {
+ if r.Host > after {
+ start = i
+ break
+ }
+ start = i + 1
+ }
+ }
+ end := start + limit
+ if end < len(matched) {
+ p.NextCursor = encodeInstanceCursor(matched[end-1].Host)
+ } else {
+ end = len(matched)
+ }
+ p.Rows = matched[start:end]
+ for _, w := range healthWords {
+ if p.Counts[w] == 0 {
+ continue
+ }
+ p.Links = append(p.Links, healthLink{Word: w, Slug: healthSlug(w), Count: p.Counts[w], Active: q.Health == w})
+ }
+ if p.NextCursor != "" {
+ v := url.Values{}
+ if q.Q != "" {
+ v.Set("q", q.Q)
+ }
+ if q.Health != "" {
+ v.Set("health", healthSlug(q.Health))
+ }
+ v.Set("cursor", p.NextCursor)
+ p.NextURL = "?" + v.Encode()
+ }
+ return p
+}
+```
+
+- [ ] **Step 4: Run listquery tests → PASS.**
+
+- [ ] **Step 5: Wire the handler and template.** `page` struct gains `InstancePage instancePage` and `InstanceQuery instanceQuery`. `handleAppInstances` becomes:
+
+```go
+ q := parseInstanceQuery(r.URL.Query())
+ rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics)
+ pg := pageInstanceRows(rows, q, instancePageSize)
+ p.InstanceRows = pg.Rows
+ p.InstancePage = pg
+ p.InstanceQuery = q
+ if len(rows) == 0 {
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable")
+ }
+```
+
+(NOTE: the empty-state check moves to `len(rows)` — a filter that matches nothing must show "no matches", not the attach onboarding.) Template, between `<h2>Running</h2>` and the table (and only when the app has rows at all):
+
+```html
+{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health}}
+<form method="get" class="listctl">
+ <input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses">
+ {{if .InstanceQuery.Health}}<input type="hidden" name="health" value="{{healthSlug .InstanceQuery.Health}}">{{end}}
+ <button>Filter</button>
+</form>
+<p class="small">
+ <a href="?{{if .InstanceQuery.Q}}q={{.InstanceQuery.Q}}{{end}}"{{if not .InstanceQuery.Health}} aria-current="true"{{end}}>All</a>
+ {{range .InstancePage.Links}}
+ · <a href="?health={{.Slug}}{{if $.InstanceQuery.Q}}&amp;q={{$.InstanceQuery.Q}}{{end}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a>
+ {{end}}
+</p>
+{{end}}
+```
+
+Register `healthSlug` in the template FuncMap (find it: `grep -n "FuncMap" internal/console/*.go`; add `"healthSlug": healthSlug`). After the table, the pager and the filtered-empty state:
+
+```html
+{{if .InstancePage.NextURL}}<p class="small"><a href="{{.InstancePage.NextURL}}">Next {{len .InstanceRows}} shown of {{.InstancePage.Total}} →</a></p>{{end}}
+```
+
+and change the `{{if .InstanceRows}}` empty branch: when `.InstanceQuery.Q` or `.InstanceQuery.Health` is set render `<p>No addresses match.</p>` instead of the attach hint.
+
+- [ ] **Step 6: Handler test** — extend `overview_test.go` next to `TestOverviewInstancesPageRendersTable` (same fixture helpers `newConsoleFixture`/`signInPaul`/`get`/`fx.u`):
+
+```go
+func TestInstancesPageFilters(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances?q=zzz-no-such-host"))
+ if status != http.StatusOK || !strings.Contains(body, "No addresses match.") {
+ t.Fatalf("filtered-empty state missing (%d):\n%s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/kass/instances?health=running"))
+ if status != http.StatusOK {
+ t.Fatalf("health filter = %d", status)
+ }
+}
+```
+
+- [ ] **Step 7: Run** `go test ./internal/console/ -count=1` → PASS.
+
+- [ ] **Step 8: Commit** — `console: Running tab gains search, health filter and cursor pagination`.
+
+---
+
+### Task 9: Instances API — q/health/limit/cursor + health field
+
+**Model:** opus (auth-gated API surface) — **Opus reviews.**
+
+**Files:**
+- Modify: `internal/console/apiinstances.go` (`handleAPIInstanceList` line ~227, `instanceListRecord` line ~156, new `instanceHealth`)
+- Test: `internal/console/apiinstances_test.go`
+
+**Interfaces:**
+- Consumes: `parseInstanceQuery`, cursor + slug helpers (Task 8); `healthWord`/`expectsReadings` (Task 2); `OwnerAppMetrics` with `unitByHost` (Task 6); `openRegistry(s.cfg.Registry)`, `registry.Route`.
+- Produces (wire, ADDITIVE): each record gains `"health"`; the response object gains `"total"`, `"counts"`, `"next_cursor"`. Existing fields untouched; a request with no new params returns every record it did before (default limit stays `instanceListMax`).
+
+- [ ] **Step 1: Failing tests** in `apiinstances_test.go` (reuse its `instanceFixture` + `rawReq` bearer-request helper; create a few records through the same seeding path existing list tests use):
+
+```go
+func TestAPIInstanceListHealthAndFilters(t *testing.T) {
+ // Seed 3 records: hosts "a.app.x", "ab.app.x", "b.app.x".
+ // 1) GET /instances → 3 records, each with a "health"
+ // string field; body contains "total":3 and a "counts" object.
+ // 2) GET /instances?q=a → total 2, hosts a.app.x, ab.app.x.
+ // 3) GET /instances?limit=1 → 1 record + non-empty next_cursor;
+ // following the cursor returns the next host, no overlap.
+ // 4) GET /instances?health=setting-up → only records whose health is
+ // "Setting up" (a record with no registry route yet reads as that).
+ // 5) No params → response still parses into the OLD client shape
+ // (instances + truncated) — additive check.
+}
+```
+
+Write the real body: unmarshal into `map[string]any` / small local structs, assert exactly.
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement.** `instanceListRecord` gains `Health string \`json:"health"\``. New helper in `apiinstances.go`:
+
+```go
+// instanceHealth computes each record host's health word the same way the
+// Running tab does — registry row joined with the metrics sweep — so the
+// API, the CLI and the page can never disagree about an instance's state.
+// A record with no registry row yet is still provisioning: "Setting up".
+// Failures degrade to that same word rather than erroring the list: health
+// here is an annotation on the record, not the record.
+func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) map[string]string {
+ out := make(map[string]string, len(recs))
+ for _, rec := range recs {
+ out[rec.Host] = "Setting up"
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return out
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return out
+ }
+ byHost := make(map[string]registry.Route, len(routes))
+ for _, rt := range routes {
+ byHost[rt.Host] = rt
+ }
+ var hosts []string
+ unitByHost := map[string]string{}
+ for _, rec := range recs {
+ rt, ok := byHost[rec.Host]
+ if !ok {
+ continue
+ }
+ hosts = append(hosts, rec.Host)
+ if rt.Unit != "" {
+ unitByHost[rec.Host] = rt.Unit
+ }
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
+ if err != nil {
+ am = nil
+ }
+ rep := make(map[string]bool, len(am))
+ for _, m := range am {
+ if m.Reporting() {
+ rep[m.Host] = true
+ }
+ }
+ for _, rec := range recs {
+ rt, ok := byHost[rec.Host]
+ if !ok {
+ continue
+ }
+ status := rt.Status
+ if status == "" {
+ status = "ready" // registry contract: empty status means ready
+ }
+ out[rec.Host] = healthWord(rt.Kind, status, rt.Hibernate, rep[rec.Host],
+ expectsReadings(rt.Provisioned, rt.Unit, rt.Backing))
+ }
+ return out
+}
+```
+
+`handleAPIInstanceList`, after the existing `recs, truncated` + `versions` reads (keep gate, timeout, error paths byte-identical):
+
+```go
+ q := parseInstanceQuery(r.URL.Query())
+ limit := instanceListMax
+ if raw := r.URL.Query().Get("limit"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 && n < instanceListMax {
+ limit = n
+ }
+ }
+ health := s.instanceHealth(ctx, recs)
+ counts := map[string]int{}
+ matched := make([]instanceListRecord, 0, len(recs))
+ for _, rec := range recs {
+ h := health[rec.Host]
+ counts[h]++
+ if q.Q != "" && !strings.Contains(rec.Host, q.Q) {
+ continue
+ }
+ if q.Health != "" && h != q.Health {
+ continue
+ }
+ matched = append(matched, instanceListRecord{Record: rec, StagedVersion: versions[rec.Host], Health: h})
+ }
+ total := len(matched)
+ start := 0
+ if after, ok := decodeInstanceCursor(q.Cursor); ok {
+ for i, rec := range matched {
+ if rec.Host > after {
+ start = i
+ break
+ }
+ start = i + 1
+ }
+ }
+ next := ""
+ end := start + limit
+ if end < total {
+ next = encodeInstanceCursor(matched[end-1].Host)
+ } else {
+ end = total
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "instances": matched[start:end], "truncated": truncated,
+ "total": total, "counts": counts, "next_cursor": next,
+ })
+```
+
+(`provision.ListRecords` already returns records host-sorted — the cursor leans on that; do not re-sort.)
+
+- [ ] **Step 4: Run** `go test ./internal/console/ -count=1` → PASS.
+
+- [ ] **Step 5: Commit** — `console: instances API reports health, filters and paginates (additive)`.
+
+---
+
+### Task 10: CLI — `carlos instances list -match -health`
+
+**Model:** sonnet
+
+**Files:**
+- Modify: `instances_cli.go` (`runInstancesList` ~line 255)
+- Modify: `apimode.go` (`instanceList` ~line 1066, `apiInstanceRecord` ~line 1019, `apiPath` ~line 217)
+- Test: extend the CLI's existing tests near `instances` (find: `grep -rn "instanceList\|instances list" *_test.go`); use `carlosTestEnv` + scratch `cmd.Dir` for any subprocess test.
+
+**Interfaces:**
+- Consumes: API params + fields from Task 9.
+- Produces: `-match <substr>`, `-health <slug>` flags; a HEALTH column; `instanceList(ctx, a, app, match, health string) (recs []apiInstanceRecord, truncated bool, total int, err error)`.
+
+- [ ] **Step 1: Failing test** — unit-test the request construction (an `httptest.Server` capturing the query; construct the `apiSession` the way the nearest apimode test does — check `apimode_test.go` / `auth_e2e_test.go` for the pattern):
+
+```go
+func TestInstanceListSendsFilters(t *testing.T) {
+ // Fake console returns {"instances":[],"truncated":false,"total":0}.
+ // Call instanceList with match "api", health "not-responding" and assert
+ // the request URL had q=api and health=not-responding, and that an
+ // -account-id session (a.Exact) still produces a well-formed URL with
+ // BOTH exact=sqid and the filters (the "?"+"&" join).
+}
+```
+
+- [ ] **Step 2: Run, verify FAIL.**
+
+- [ ] **Step 3: Implement.** `apiPath` learns to join (this is the bug the test above pins):
+
+```go
+func apiPath(a apiAcct, app, tail string) string {
+ p := "/api/cli/apps/" + url.PathEscape(a.Seg) + "/" + url.PathEscape(app) + tail
+ if a.Exact {
+ sep := "?"
+ if strings.Contains(p, "?") {
+ sep = "&"
+ }
+ p += sep + "exact=sqid"
+ }
+ return p
+}
+```
+
+`apiInstanceRecord` gains `Health string \`json:"health"\``. `instanceList` gains params and returns total:
+
+```go
+func (s *apiSession) instanceList(ctx context.Context, a apiAcct, app, match, health string) ([]apiInstanceRecord, bool, int, error) {
+ tail := "/instances"
+ v := url.Values{}
+ if match != "" {
+ v.Set("q", match)
+ }
+ if health != "" {
+ v.Set("health", health)
+ }
+ if enc := v.Encode(); enc != "" {
+ tail += "?" + enc
+ }
+ status, body, err := s.getJSON(ctx, apiPath(a, app, tail))
+ ...
+ var res struct {
+ Instances []apiInstanceRecord `json:"instances"`
+ Truncated bool `json:"truncated"`
+ Total int `json:"total"`
+ }
+ ...
+ return res.Instances, res.Truncated, res.Total, nil
+}
+```
+
+(keep the existing error paths verbatim; a pre-Task-9 console returns no `total` → 0, which the printer treats as "unknown" and omits). `runInstancesList` gains:
+
+```go
+ match := fs.String("match", "", "only hosts containing this substring")
+ health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
+```
+
+table header becomes `HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tCREATED\tBY`, the row printf gains `r.Health` in that position (em-dash `—` when empty — an old console), and after the flush:
+
+```go
+ if (*match != "" || *health != "") && total > len(recs) {
+ mutedf(stdout, "(showing %d of %d matches)", len(recs), total)
+ }
+```
+
+Update `instancesRecordsUsage` to document both flags.
+
+- [ ] **Step 4: Run** `go test ./... -count=1` (CLI tests live at the repo root package) → PASS.
+
+- [ ] **Step 5: Commit** — `cli: instances list -match/-health, server-side, with HEALTH column`.
+
+---
+
+### Task 11: Whole-branch gate, rebase, PR
+
+**Model:** run in the main session (Fable) — whole-branch final review per the delegation policy.
+
+- [ ] **Step 1:** `git fetch origin && git rebase origin/main` (a parallel session merged PR #140 onto main mid-branch — resolve conflicts toward main's shapes).
+- [ ] **Step 2:** Full gate: `go build ./... && go vet ./... && go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l` on every touched file.
+- [ ] **Step 3:** Re-read the spec end to end against the diff — every section must map to landed code (the self-review checklist at the bottom of this plan).
+- [ ] **Step 4:** Push and open the PR: title `Dashboard truth + scale: honest health, route-granular mismatch banner, instance list controls`, body summarizing the spec's Diagnosis + Design sections, `Closes` nothing automatically (the tito stamping and 404 investigation are operational follow-ups), ending with the standard generation footer.
+
+---
+
+## Rollout runbook (operator / main session — NOT a subagent task)
+
+Order matters; each step verifies against the thing it changed with the binary it built.
+
+1. Merge the PR. Deploy **boxes first** (`update-platform-binary.sh` on flagship + `carlos-tito-ie`) — sampling stamped units is harmless while nothing is stamped. Then both consoles (`roll-console.sh`; console = the `cmd/console` build, NOT the root binary — both boxes are arm64). Coordinate on the current deploy lock discipline (another session was rolling PR #140; check open PRs and parallel-session deploys BEFORE deploying).
+2. Stamp tito's bespoke-unit routes, as user carlos on `carlos-tito-ie`, one per route (registry `-registry /data/titogo/registry.db` — CHECK which registry the tito console reads; the box has two: `/data/registry.db` platform, `/data/titogo/registry.db` titogo):
+ `carlos route -registry <path> -host vicky.go.tito.io -unit titogo@vicky.service` (canaries use `titogo-canary@<name>.service` — confirm each unit's real name with `systemctl list-units 'titogo*'` first; a wrong stamp yields no reading, not an error).
+ Before stamping each unit, check where its cgroup lives: `systemctl show -p Slice <unit>` must report `Slice=system.slice` (or a `system-*.slice` child). A unit with a custom `Slice=` sits outside the root the sweep walks, so it yields no reading at all — which renders exactly the red "Not responding" false alarm this branch exists to retire. Leave such a unit unstamped (it reads "No readings") until its slice is moved.
+3. Verify: the titogo Overview on the tito console shows the stamped routes **Running with real CPU/memory** within ~2 sweep ticks; the Running tile reads `N of N reporting`; `curl -sI --resolve` untouched — this change is console-content only.
+4. Confirm the banner: after the roll it must name the actual `bab`-stamped rows (if any remain) instead of the app-wide alarm. Fix those rows (re-stamp/migrate) as a separate operational step.
+5. Investigate `titogo.bae.platform.tito.io` (404, no version header) — genuinely broken, pre-existing, not addressed by this branch.
+6. Website/tap batch per the standing rule if any member-facing docs mention the instances list.
+
+## Self-Review (run after writing all code)
+
+1. **Spec coverage:** §1 readings+vocabulary → Tasks 1,2,5,6; §1 tiles → Task 3; §2 banner → Task 4; §3 rollup → Task 7; §3 Running tab → Task 8; §3 API → Task 9; §3 CLI → Task 10; §4 rollout → runbook; §5 tests → inside each task.
+2. **Placeholder scan:** none of "TBD/TODO/similar to Task N"; the only "check the actual X" notes point at named files for signatures this plan could not verify (fixture seeding helpers, apiSession construction) — resolve them by reading the named file, not by inventing.
+3. **Type consistency:** `expectsReadings(provisioned bool, unit, backing string)`; `healthWord(kind, status string, hibernate, reporting, expectReadings bool)`; `OwnerAppMetrics(ctx, hosts []string, unitByHost map[string]string)`; `assembleFlow(view, rows, latest, latestAt, instancesPath string)`; `pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage`; `instanceList(ctx, a, app, match, health string) ([]apiInstanceRecord, bool, int, error)` — used identically everywhere above.
diff --git a/docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md b/docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md
new file mode 100644
index 000000000..d34b186e0
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md
@@ -0,0 +1,145 @@
+# App dashboard: truthful status + scale to 100s of instances
+
+2026-08-10. Prompted by the titogo Overview on the tito console showing 18
+false "Not responding" flags, a false app-wide "This app is not deploying"
+banner, and a "2 of 20 responding" tile beside "20 all working" — while every
+flagged address served 2xx/3xx in under a second with the correct
+`X-Carlos-Version`. The page must also anticipate Tito's model: one app
+minting per-customer/per-event instances into the hundreds.
+
+Scope decision (Paul): both halves in one design, truth first — search and
+filter over lying statuses just scales the lie. Growth shape: 100s of
+*dynamic instances* on one app; channels and named routes stay dozens-at-most.
+
+## Diagnosis (verified live + in code)
+
+- The health word comes from a 48h metrics sweep matched to routes only by
+ the managed unit conventions — `carlos-app@<host>.service` or the
+ delegated-exec cgroup name (`internal/console/metricsview.go`,
+ `OwnerAppMetrics`). Routes running under custom units (tito's `titogo@*`,
+ `titogo-canary@*`) never match, so `Reporting()` is false and `healthWord`
+ (`internal/console/overview.go`) falls through to "Not responding" for
+ instance-kind routes. The route rows can carry their real unit name since
+ #110 (`unit` column, used only for restart binding); the sweep ignores it.
+- `AppView.AccountMismatch` collapses route-account stamps to one app-wide
+ boolean (last-writer-wins per app name, `internal/console/view.go`) and
+ renders "this app is not deploying". On tito, live hosts serve `bae`'s
+ edge version — promotes made on the page demonstrably reach the fleet, so
+ the app-wide claim is false; some subset of route rows is stamped `bab`.
+- The Web addresses tile measures DNS-pointing + TLS issuance per domain
+ (`domainNeedsAttention`), not liveness; its "all working" copy invites the
+ liveness reading, colliding with the Running tile.
+- Real defect found alongside: `titogo.bae.platform.tito.io` serves 404 with
+ no version header — operational, tracked separately (see Rollout).
+- The console has zero search/filter/pagination machinery anywhere; every
+ list is unbounded or hard-capped. This design introduces the pattern.
+
+## Design
+
+### 1. Real readings (box) + honest vocabulary (console)
+
+Box metrics sweep: in addition to `carlos-app@*` and exec cgroups, sample
+the cgroup of any registry route whose `unit` column is stamped. Emit into
+the existing readings shape keyed by host, exactly like managed units —
+additive; nothing that parses readings today changes. A stamped unit that
+does not exist or has no cgroup emits no reading and never fails the sweep
+(`ValidUnitName` already rejects malformed names at stamp time).
+
+Console `healthWord`, for a silent, non-hibernating instance-kind route:
+
+- Route is expected to report — the box itself runs its process (managed
+ `carlos-app@` unit or delegated exec child) or the route has a stamped
+ unit — → **"Not responding"**, bad tone, as today. Now true.
+- Otherwise → **"No readings"**, neutral tone (like "Asleep"), with
+ instance-page guidance: stamp the unit (`carlos route … -unit`) to get
+ health here.
+
+Tiles:
+
+- Running: no-readings routes stop counting against the app —
+ "2 of 2 reporting · 18 no readings" (the "· N no readings" clause only
+ when non-zero), not "2 of 20".
+- Web addresses: "20 — DNS & certs OK" instead of "all working".
+- Resources: unchanged (already sums reporting copies only; gains real
+ numbers as routes are stamped).
+
+### 2. Account-mismatch banner at route granularity
+
+Replace the app-wide boolean with the actual list of mismatched routes
+(host + stamped account).
+
+- **All** non-platform routes mismatched → keep the strong "not deploying"
+ banner, now naming the account and the hosts.
+- **Some** mismatched → scoped warning, no "not deploying" claim:
+ "N addresses are stamped to account `X` and won't follow promotes made
+ here: <hosts> — fix on Settings."
+
+On tito this converts a false alarm into a precise pointer at the stale
+`bab`-stamped rows, which is also the diagnostic for the cleanup.
+
+### 3. Scale: rollup + exceptions on Overview, workbench on Running
+
+Overview flow diagram — per column (EDGE, BETA, LIVE, each canary):
+
+- ≤8 addresses: list all, exactly as today. Small apps are unchanged.
+- >8: rollup line "142 addresses · 3 need attention", then only the
+ needs-attention addresses (Not responding / Failed / stuck Setting up),
+ capped at 5, then "+K more → Running". Healthy and Asleep are counted,
+ not listed.
+
+Running tab — server-rendered, GET-param driven (bookmarkable, no JS
+dependency):
+
+- `q=` substring match on host; `health=` filter by health word;
+- cursor pagination ordered by host, page size 50 (`cursor=` opaque,
+ offset-free);
+- header shows per-health-word totals, which double as filter links.
+
+API parity (the page is a view of the API): the instances API gains the
+same `q`, `health`, `limit`, `cursor` params; the response additively gains
+`total`, per-health counts, and `next_cursor` — additive per the wire-shape
+rule: everything that parses today's shape keeps parsing (new fields only,
+no field renamed, moved, or re-typed).
+
+CLI parity: `carlos instances list -match <substr> -health <word>` with the
+same server-side semantics, printing totals — no local-disk registry walk,
+works with zero box access.
+
+Channels grid: structurally unchanged (bounded by team practice); its
+per-column address lists get the same ≥8 rollup for consistency.
+
+### 4. Rollout
+
+1. Branch off origin/main `db76025`; land box sweep + console + API + CLI.
+2. Deploy boxes first (sampling stamped units is harmless while nothing is
+ stamped), then consoles.
+3. Stamp tito's ~18 custom-unit routes (`carlos route … -unit
+ titogo@….service` / `titogo-canary@….service`, run as carlos on the
+ box, one-time). Route records (PR #137) makes stamps durable once
+ merged; not a gate.
+4. Verify against the thing changed: titogo Overview shows canaries
+ Running with real CPU/memory; Running tile reads "N of N reporting";
+ verify with the deployed console, not a cached page.
+5. Follow-ups, precisely named by the new banner/tiles: re-stamp or migrate
+ the `bab`-stamped route rows; investigate the
+ `titogo.bae.platform.tito.io` 404.
+
+Filed as a gap, not built: edge-observed liveness (router-reported
+per-route last-success as a truth source) — the truest signal, deferred for
+its status wire-shape cost.
+
+### 5. Testing
+
+- `healthWord` table tests gain the "No readings" state and the
+ expected-to-report distinction.
+- Overview-stats tests pin the new Running-tile arithmetic and the
+ Web-addresses copy semantics.
+- Banner tests: none / some / all routes mismatched, hosts named in output.
+- API handler tests: `q`/`health`/`cursor` combinations, additive-response
+ compatibility (no new params → prior shape).
+- Box sweep test: stamped unit with fake cgroup dir; stamped-but-absent
+ unit emits nothing and does not fail the sweep.
+- CLI list tests build env through `carlosTestEnv` with `cmd.Dir` in a
+ scratch dir.
+- Gate: `go build ./... && go vet ./... && go test ./... -count=1`, gofmt
+ on touched files, darwin cross-compile.
diff --git a/instances_cli.go b/instances_cli.go
index b7541092d..04b37bca0 100644
--- a/instances_cli.go
+++ b/instances_cli.go
@@ -197,7 +197,7 @@ func instancesEnable(ctx context.Context, s release.Store, acct, app string, m p
}
const instancesRecordsUsage = `usage: carlos instances create -app <a> -host <h> [-region <r>] [-channel <c>]
- carlos instances list -app <a>
+ carlos instances list -app <a> [-match <substr>] [-health <slug>]
carlos instances delete -app <a> -host <h>
Signed-in only (carlos auth login): instance records are console-authorized
@@ -208,6 +208,12 @@ box's reconcile pass materializes the route. Common flags:
-account-id <id> account sqid, matched as a sqid ONLY
-console <origin> console to act through (default: the only logged-in one)
+list-only flags:
+
+ -match <substr> only hosts containing this substring
+ -health <slug> only this state: running, serving, setting-up, asleep,
+ no-readings, not-responding, failed
+
Operators placing routes by hand on a box use carlos add, not this.
`
@@ -255,6 +261,8 @@ func runInstancesCreate(args []string) {
func runInstancesList(args []string) {
fs := flag.NewFlagSet("instances list", flag.ExitOnError)
app := fs.String("app", "", "app whose instance records to list")
+ match := fs.String("match", "", "only hosts containing this substring")
+ health := fs.String("health", "", "only this state: running, serving, setting-up, asleep, no-readings, not-responding, failed")
consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
acctRaw := fs.String("account", "ops", "account (sqid or name) the app belongs to")
acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
@@ -263,19 +271,32 @@ func runInstancesList(args []string) {
log.Fatal(instancesRecordsUsage)
}
sess := requireAPISession("list", *consoleFlag)
- recs, truncated, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app)
+ recs, truncated, total, err := sess.instanceList(context.Background(), apiAccount(acctRaw, acctIDRaw), *app, *match, *health)
if err != nil {
log.Fatalf("instances list: %v", err)
}
w := newTable(stdout)
- fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tCREATED\tBY")
+ fmt.Fprintln(w, "HOST\tREGION\tCHANNEL\tPOOL\tVERSION\tHEALTH\tCREATED\tBY")
for _, r := range recs {
- fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, r.CreatedAt, r.CreatedBy)
+ rowHealth := r.Health
+ if rowHealth == "" {
+ rowHealth = "—" // an old console: the field predates it, not "unhealthy"
+ }
+ fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", r.Host, r.Region, r.Channel, r.Pool, r.StagedVersion, rowHealth, r.CreatedAt, r.CreatedBy)
}
w.Flush()
if truncated {
mutedf(stdout, "(truncated at 500 records)")
}
+ // total is the server's count of every record matching -match/-health, not
+ // just the page returned above — worth a line only when a filter is
+ // active AND the console actually reported one (a pre-Task-9 console
+ // sends no "total" field, which decodes as 0; treated as unknown here
+ // rather than "zero matches", since len(recs) itself already proves that
+ // wrong whenever recs is non-empty).
+ if (*match != "" || *health != "") && total > len(recs) {
+ mutedf(stdout, "(showing %d of %d matches)", len(recs), total)
+ }
}
func runInstancesDelete(args []string) {
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index 68bd42843..52e72dc27 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -26,10 +26,12 @@ import (
"net/http"
"os"
"slices"
+ "strconv"
"strings"
"time"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
)
@@ -173,6 +175,112 @@ type instanceListRecord struct {
// rather than going on serving that box's last-known version as if it
// were current. The deploy watch does NOT read this field.
StagedVersion string `json:"staged_version"`
+ // Health is the same one-word state the Running tab's column shows,
+ // computed by instanceHealth below from the registry row joined with the
+ // metrics sweep. Additive on the wire (a client that never heard of it
+ // keeps parsing the record it always parsed) and annotation-grade: a
+ // record whose health could not be computed still lists, reading
+ // "Setting up" rather than failing the call.
+ Health string `json:"health"`
+}
+
+// instanceHealth computes each record host's health word the same way the
+// Running tab does — registry row joined with the metrics sweep — so the
+// API, the CLI and the page can never disagree about an instance's state.
+// A record with no registry row yet is still provisioning: "Setting up".
+// Failures degrade to that same word rather than erroring the list: health
+// here is an annotation on the record, not the record.
+//
+// The tenant boundary is the recs slice and nothing else. recs arrives
+// already scoped to one account+app (provision.ListRecords under that app's
+// own prefix), and every lookup below is keyed on a host drawn FROM recs —
+// the registry read returns the whole box's routes, including other tenants',
+// and none of them can enter hosts, unitByHost or the answer. That matters
+// twice over: OwnerAppMetrics' allowlist is exactly the hosts and stamped
+// units it is handed, so a foreign unit name reaching unitByHost would widen
+// the metrics view past the caller's own app.
+func (s *Server) instanceHealth(ctx context.Context, recs []provision.Record) map[string]string {
+ out := make(map[string]string, len(recs))
+ for _, rec := range recs {
+ out[rec.Host] = "Setting up"
+ }
+ if s.cfg.Registry == "" {
+ // A console with no registry configured cannot know any route's
+ // state; opening "" would hand sqlite a throwaway temp DB and answer
+ // the same "Setting up" a page later, having created a file for it.
+ return out
+ }
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ log.Printf("console: api instances health: registry %s: %v", s.cfg.Registry, err)
+ return out
+ }
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("console: api instances health: registry list: %v", err)
+ return out
+ }
+ byHost := make(map[string]registry.Route, len(routes))
+ for _, rt := range routes {
+ byHost[rt.Host] = rt
+ }
+ // routeFor is the join reduced to the routes that ANSWER for these
+ // records, built once and used by both loops so the two can never drift.
+ routeFor := make(map[string]registry.Route, len(recs))
+ var hosts []string
+ unitByHost := map[string]string{}
+ for _, rec := range recs {
+ rt, ok := byHost[rec.Host]
+ if !ok {
+ continue // no route minted yet: the record stays "Setting up"
+ }
+ // A row stamped to another app or account is not this record's route.
+ // The box's own reconcile pass says so — it refuses to converge such
+ // a row ("exists but was not minted by this pass — leaving it
+ // alone", reconcile.go) — and reporting it here would be worse than
+ // useless twice: the caller would read a neighbour's health word as
+ // its own instance's, and the neighbour's stamped Unit would enter
+ // unitByHost, widening OwnerAppMetrics' allowlist past this app's own
+ // units. An UNSTAMPED row (either column empty: hand-added, or minted
+ // before the column existed) still joins — refusing those would read
+ // live instances as forever "Setting up".
+ if (rt.App != "" && rt.App != rec.App) || (rt.Account != "" && rt.Account != rec.Account) {
+ continue
+ }
+ routeFor[rec.Host] = rt
+ hosts = append(hosts, rec.Host)
+ if rt.Unit != "" {
+ unitByHost[rec.Host] = rt.Unit
+ }
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
+ if err != nil {
+ // An unreadable sweep is not knowledge that these instances are
+ // down: every host reads as not-reporting below, which healthWord
+ // turns into "No readings" or "Not responding" per expectsReadings —
+ // the same answer the page gives when the sweep is unavailable.
+ log.Printf("console: api instances health: metrics: %v", err)
+ am = nil
+ }
+ rep := make(map[string]bool, len(am))
+ for _, m := range am {
+ if m.Reporting() {
+ rep[m.Host] = true
+ }
+ }
+ for _, rec := range recs {
+ rt, ok := routeFor[rec.Host]
+ if !ok {
+ continue
+ }
+ status := rt.Status
+ if status == "" {
+ status = "ready" // registry contract: empty status means ready
+ }
+ out[rec.Host] = healthWord(rt.Kind, status, rt.Hibernate, rep[rec.Host],
+ expectsReadings(rt.Provisioned, rt.Unit, rt.Backing))
+ }
+ return out
}
// stagedVersions builds a host -> version map from every NON-STALE box's own
@@ -223,7 +331,16 @@ func stagedVersions(ctx context.Context, s release.Store, now time.Time) map[str
// handleAPIInstanceList answers `carlos instances list`: this app's records,
// capped and honest about the cap, each carrying its best-effort
// staged_version (Task 8, issue #78) — fresh-ish while some box keeps
-// reporting the host, dropped rather than stale once none does.
+// reporting the host, dropped rather than stale once none does — and its
+// health word, plus the same q/health/limit/cursor controls the Running tab
+// has, applied SERVER-side so the CLI and the page filter identically.
+//
+// Every addition is additive on the wire and defaults to today's answer: no
+// params means the full `instanceListMax` page, `instances` and `truncated`
+// exactly as before, with `health` on each record and `total`/`counts`/
+// `next_cursor` alongside. Counts cover every record regardless of the
+// filter — they are the whole app's health story, and a filtered count could
+// not lead anywhere.
func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
_, acct, app, ok := s.instanceGate(w, r)
if !ok {
@@ -238,11 +355,57 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
return
}
versions := stagedVersions(ctx, s.cfg.Releases, time.Now())
- out := make([]instanceListRecord, len(recs))
- for i, rec := range recs {
- out[i] = instanceListRecord{Record: rec, StagedVersion: versions[rec.Host]}
+ params := r.URL.Query()
+ q := parseInstanceQuery(params)
+ // The default stays the whole cap, so a client that sends no limit gets
+ // the answer it always got. A limit outside (0, instanceListMax) is
+ // ignored rather than refused — a bad page size is not worth an error
+ // where the honest full page is right there.
+ limit := instanceListMax
+ if raw := params.Get("limit"); raw != "" {
+ if n, err := strconv.Atoi(raw); err == nil && n > 0 && n < instanceListMax {
+ limit = n
+ }
+ }
+ health := s.instanceHealth(ctx, recs)
+ counts := map[string]int{}
+ matched := make([]instanceListRecord, 0, len(recs))
+ for _, rec := range recs {
+ h := health[rec.Host]
+ counts[h]++
+ if q.Q != "" && !strings.Contains(rec.Host, q.Q) {
+ continue
+ }
+ if q.Health != "" && h != q.Health {
+ continue
+ }
+ matched = append(matched, instanceListRecord{Record: rec, StagedVersion: versions[rec.Host], Health: h})
+ }
+ total := len(matched)
+ // provision.ListRecords returns records host-sorted and nothing above
+ // re-orders them, which is the whole basis of the cursor: "resume after
+ // this host" only means one page in a stable order.
+ start := 0
+ if after, ok := decodeInstanceCursor(q.Cursor); ok {
+ for i, rec := range matched {
+ if rec.Host > after {
+ start = i
+ break
+ }
+ start = i + 1
+ }
+ }
+ next := ""
+ end := start + limit
+ if end < total {
+ next = encodeInstanceCursor(matched[end-1].Host)
+ } else {
+ end = total
}
- writeJSON(w, http.StatusOK, map[string]any{"instances": out, "truncated": truncated})
+ writeJSON(w, http.StatusOK, map[string]any{
+ "instances": matched[start:end], "truncated": truncated,
+ "total": total, "counts": counts, "next_cursor": next,
+ })
}
// handleAPIInstanceGet answers one record. A host with no record is the
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index b394ad5ad..fd2454918 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -11,17 +11,24 @@ package console
// AFTER the gate so a 429 can never confirm what a 404 hides.
import (
+ "bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
+ "maps"
"net/http"
"net/http/httptest"
+ "net/url"
+ "slices"
+ "sort"
"strings"
"testing"
"time"
+ "github.com/carlosframework/platform/internal/activator"
+ "github.com/carlosframework/platform/internal/metrics"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
@@ -663,6 +670,224 @@ func TestAPIInstanceListStagedVersion(t *testing.T) {
}
}
+// TestAPIInstanceListHealthAndFilters (Task 9): the list door grows the same
+// controls the Running tab has — a health word per record, a substring
+// filter, a health filter, and an opaque cursor — computed server-side so the
+// CLI, the API and the page can never disagree about an instance's state. The
+// growth is ADDITIVE: a client that knows only `instances` + `truncated`
+// still parses this body, every pre-existing field byte-identical, and a
+// request carrying none of the new params still returns every record it did
+// before.
+func TestAPIInstanceListHealthAndFilters(t *testing.T) {
+ fx, token := instanceFixture(t, "code-inst-health")
+ enableInstances(t, fx, "titogo", titoManifest())
+ u := instancesURL(fx, "bab", "titogo")
+ // "a" and "ab" share a prefix so q=a proves a SUBSTRING match rather than
+ // an accidental equality, and "b" proves it excludes.
+ for _, h := range []string{"a.go.tito.io", "ab.go.tito.io", "b.go.tito.io", "run.go.tito.io"} {
+ if code, _, out := rawReq(t, http.MethodPost, u, token,
+ fmt.Sprintf(`{"host":%q,"region":"ie"}`, h)); code != http.StatusCreated {
+ t.Fatalf("create %s = %d %s", h, code, out)
+ }
+ }
+ // a's record has been materialized into a route: ready, instance-kind,
+ // not hibernating, no stamped unit and not provisioned — nothing the
+ // sweep was ever told how to sample, so its silence is honest ignorance
+ // ("No readings"), not an outage.
+ fx.addRoute(t, registry.Route{Host: "a.go.tito.io", Kind: "instance", Status: "ready",
+ App: "titogo", Account: fx.sqid, Channel: "stable"})
+ // ab has no registry row at all: still provisioning, "Setting up".
+ //
+ // b's host carries a route stamped to ANOTHER account and app, with a
+ // bespoke unit that IS reporting in the sweep below. That row is not this
+ // record's route — the box's reconcile pass refuses to converge it too —
+ // so b must read "Setting up", and the neighbour's unit must never enter
+ // the metrics allowlist. If the tenant check were dropped, b would read
+ // "Running" off someone else's process.
+ fx.addRoute(t, registry.Route{Host: "b.go.tito.io", Kind: "instance", Status: "ready",
+ App: "neighbour", Account: "zzz", Unit: "neighbour-canary@b.service"})
+ // run is the live case: provisioned, and its carlos-app@ unit is in a
+ // fresh sweep — "Running", which is what proves the sweep join is wired
+ // rather than every word coming from the registry alone.
+ fx.addRoute(t, registry.Route{Host: "run.go.tito.io", Kind: "instance", Status: "ready",
+ App: "titogo", Account: fx.sqid, Channel: "stable", Provisioned: true})
+ at := time.Now().UTC().Add(-time.Minute)
+ if err := metrics.WriteRaw(context.Background(), fx.rel, metrics.RawChunk{
+ V: 1, Box: "box-health", Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{At: at, Units: map[string]metrics.UnitSample{
+ activator.UnitName("run.go.tito.io"): {},
+ "neighbour-canary@b.service": {},
+ }}},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ type listBody struct {
+ Instances []struct {
+ Host string `json:"host"`
+ Health string `json:"health"`
+ StagedVersion string `json:"staged_version"`
+ } `json:"instances"`
+ Truncated bool `json:"truncated"`
+ Total int `json:"total"`
+ Counts map[string]int `json:"counts"`
+ NextCursor string `json:"next_cursor"`
+ }
+ get := func(t *testing.T, query string) (listBody, string) {
+ t.Helper()
+ code, _, out := rawReq(t, http.MethodGet, u+query, token, "")
+ if code != http.StatusOK {
+ t.Fatalf("list%s = %d %s, want 200", query, code, out)
+ }
+ var body listBody
+ if err := json.Unmarshal([]byte(out), &body); err != nil {
+ t.Fatalf("list%s body %s: %v", query, out, err)
+ }
+ return body, out
+ }
+ hostsOf := func(b listBody) []string {
+ var out []string
+ for _, r := range b.Instances {
+ out = append(out, r.Host)
+ }
+ return out
+ }
+
+ order := []string{"a.go.tito.io", "ab.go.tito.io", "b.go.tito.io", "run.go.tito.io"}
+ wantCounts := map[string]int{"No readings": 1, "Setting up": 2, "Running": 1}
+
+ // 1) No params: every record, each carrying its health word, plus the
+ // whole-app totals the header states.
+ all, _ := get(t, "")
+ if got := hostsOf(all); !slices.Equal(got, order) {
+ t.Fatalf("unfiltered list = %v, want all four in host order", got)
+ }
+ wantHealth := map[string]string{
+ "a.go.tito.io": "No readings",
+ "ab.go.tito.io": "Setting up",
+ "b.go.tito.io": "Setting up", // a neighbour's route answers for nobody
+ "run.go.tito.io": "Running",
+ }
+ for _, r := range all.Instances {
+ if r.Health != wantHealth[r.Host] {
+ t.Errorf("%s health = %q, want %q", r.Host, r.Health, wantHealth[r.Host])
+ }
+ }
+ if all.Total != 4 {
+ t.Errorf("total = %d, want 4", all.Total)
+ }
+ if !maps.Equal(all.Counts, wantCounts) {
+ t.Errorf("counts = %v, want %v", all.Counts, wantCounts)
+ }
+ if all.NextCursor != "" {
+ t.Errorf("next_cursor = %q on a single-page list, want empty", all.NextCursor)
+ }
+
+ // 2) q is a substring match on the host.
+ q, _ := get(t, "?q=a")
+ if got := hostsOf(q); !slices.Equal(got, []string{"a.go.tito.io", "ab.go.tito.io"}) {
+ t.Errorf("q=a = %v, want a and ab", got)
+ }
+ if q.Total != 2 {
+ t.Errorf("q=a total = %d, want 2", q.Total)
+ }
+ // Counts are the app's WHOLE health story regardless of the filter —
+ // they are the header's chips, and a filtered count would make the chips
+ // unable to lead anywhere.
+ if !maps.Equal(q.Counts, wantCounts) {
+ t.Errorf("q=a counts = %v, want the unfiltered %v", q.Counts, wantCounts)
+ }
+
+ // 3) limit + cursor: one record a page, in host order, no overlap, and
+ // the cursor ends on the last page.
+ cursor := ""
+ for i, want := range order {
+ page, _ := get(t, "?limit=1&cursor="+url.QueryEscape(cursor))
+ if got := hostsOf(page); !slices.Equal(got, []string{want}) {
+ t.Fatalf("limit=1 page %d = %v, want %q alone", i+1, got, want)
+ }
+ if page.Total != 4 {
+ t.Errorf("limit=1 page %d total = %d, want 4 (the match count, not the page length)", i+1, page.Total)
+ }
+ last := i == len(order)-1
+ if last && page.NextCursor != "" {
+ t.Errorf("next_cursor = %q on the last page, want empty", page.NextCursor)
+ }
+ if !last && page.NextCursor == "" {
+ t.Fatalf("limit=1 page %d has no next_cursor with records left", i+1)
+ }
+ cursor = page.NextCursor
+ }
+
+ // 4) health filters on the computed word — and a record with no route of
+ // its own reads as "Setting up".
+ h, _ := get(t, "?health=setting-up")
+ if got := hostsOf(h); !slices.Equal(got, []string{"ab.go.tito.io", "b.go.tito.io"}) {
+ t.Errorf("health=setting-up = %v, want the two records with no route of their own", got)
+ }
+ if h.Total != 2 {
+ t.Errorf("health=setting-up total = %d, want 2", h.Total)
+ }
+ running, _ := get(t, "?health=running")
+ if got := hostsOf(running); !slices.Equal(got, []string{"run.go.tito.io"}) {
+ t.Errorf("health=running = %v, want only the reporting instance", got)
+ }
+ // A mistyped word is no filter at all (healthFromSlug), never an empty
+ // list a caller would read as "you have no instances".
+ unknown, _ := get(t, "?health=nonsense")
+ if got := hostsOf(unknown); !slices.Equal(got, order) {
+ t.Errorf("health=nonsense = %v, want everything — an unknown word is no filter", got)
+ }
+
+ // 5) ADDITIVE: the OLD client's shape still parses, and every field a
+ // record already had is byte-identical. The only key the wire gained
+ // is "health".
+ _, raw := get(t, "")
+ var old struct {
+ Instances []provision.Record `json:"instances"`
+ Truncated bool `json:"truncated"`
+ }
+ if err := json.Unmarshal([]byte(raw), &old); err != nil {
+ t.Fatalf("old client shape no longer parses %s: %v", raw, err)
+ }
+ if len(old.Instances) != len(order) || old.Truncated {
+ t.Fatalf("old shape = %d records truncated=%v, want %d and false",
+ len(old.Instances), old.Truncated, len(order))
+ }
+ var wire struct {
+ Instances []map[string]json.RawMessage `json:"instances"`
+ }
+ if err := json.Unmarshal([]byte(raw), &wire); err != nil {
+ t.Fatal(err)
+ }
+ for i, rec := range old.Instances {
+ b, err := json.Marshal(rec)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var want map[string]json.RawMessage
+ if err := json.Unmarshal(b, &want); err != nil {
+ t.Fatal(err)
+ }
+ for k, v := range want {
+ if !bytes.Equal(wire.Instances[i][k], v) {
+ t.Errorf("%s field %q = %s, want the record's own %s",
+ rec.Host, k, wire.Instances[i][k], v)
+ }
+ }
+ var extra []string
+ for k := range wire.Instances[i] {
+ if _, ok := want[k]; !ok {
+ extra = append(extra, k)
+ }
+ }
+ sort.Strings(extra)
+ if !slices.Equal(extra, []string{"health", "staged_version"}) {
+ t.Errorf("%s carries extra keys %v, want exactly health and staged_version", rec.Host, extra)
+ }
+ }
+}
+
// TestAPIInstanceDelete: deletion is idempotent (the caller's goal is "not
// there"), survives the manifest being retired, and still refuses a host
// that isn't one.
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 8b0fe44b4..a77a78324 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -108,7 +108,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
sv.CanRename = true
}
sv.Store = s.storeCard(ctx, acct, p.AppName)
- if ra := p.Detail.RouteAccount; ra != "" {
+ if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
sv.TargetKnown = true
@@ -574,11 +574,23 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
s.setFlash(w, kind, msg)
http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
}
- target, err := s.routeAccountFor(app)
+ target, agreed, err := s.routeAccountFor(app)
if err != nil {
back("err", "Could not read the routes — nothing was moved. Try again.")
return
}
+ // The action-time equivalent of the card's AllMismatched gate. The card is
+ // only offered when EVERY non-platform route disagrees with this account,
+ // but a direct POST — or a tab opened before the routes changed — reaches
+ // here regardless, and target is only the FIRST route's account. Under a
+ // partial mismatch that first route can be the minority: confirming would
+ // then move the whole record to where a minority of hosts deploy from,
+ // turning a partial disagreement into a total one the other way. Refuse
+ // before the card is rendered and before anything is written.
+ if !agreed {
+ back("err", "This app's routes disagree among themselves — nothing was moved; fix the routes first.")
+ return
+ }
if target == "" || target == acct.Sqid {
back("err", "This app's routes agree with this account — there is nothing to move.")
return
@@ -644,22 +656,45 @@ func moveAdvice(err error, destName string) string {
return "Try again."
}
-// routeAccountFor reads what the ROUTES say the app's account is — the same
-// rule Assemble applies (first non-platform route's stamped account), read
-// fresh at action time so the move never trusts a stale page.
-func (s *Server) routeAccountFor(app string) (string, error) {
+// routeAccountFor reads what the ROUTES say the app's account is, and whether
+// they say it with one voice: the first non-platform route's stamped account,
+// plus agreed=false the moment any other non-platform route names a different
+// one. Both are read fresh at action time, so the move never trusts a stale
+// page.
+//
+// The Settings card only offers the move when AllMismatched (assembleSettings),
+// and under AllMismatched every non-platform route disagrees with the viewed
+// account — so the first route is provably also the first MISMATCHED one, and
+// the target here is the same account Assemble's mismatchedByApp[app][0].Account
+// is. But a target alone cannot police the door: outside that gate the first
+// route may be the ONLY one disagreeing, and moving to it would re-home the
+// record away from where most of the app's hosts actually deploy from. agreed
+// is what the handler refuses on — an app whose routes contradict each other
+// has no single account to move to, and the routes are the repair, not the
+// record. An app with no non-platform routes at all agrees vacuously (target
+// ""), which the handler's own nothing-to-move refusal then catches.
+func (s *Server) routeAccountFor(app string) (target string, agreed bool, err error) {
reg, err := openRegistry(s.cfg.Registry)
if err != nil {
- return "", err
+ return "", false, err
}
routes, err := reg.List()
if err != nil {
- return "", err
+ return "", false, err
}
+ var seen bool
for _, rt := range routes {
- if rt.App == app && !rt.Platform {
- return release.RouteAccount(rt), nil
+ if rt.App != app || rt.Platform {
+ continue
+ }
+ acct := release.RouteAccount(rt)
+ if !seen {
+ target, seen = acct, true
+ continue
+ }
+ if acct != target {
+ return target, false, nil
}
}
- return "", nil
+ return target, true, nil
}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index 94fb98ec3..38b10b441 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -596,6 +596,63 @@ func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) {
}
}
+// A PARTIAL mismatch is not a move. The Settings card is only offered when
+// every non-platform route disagrees (AllMismatched), but a direct POST — or a
+// tab left open across a re-stamp — reaches the handler regardless, and the
+// target it derives is only the FIRST route's account. Here that first route
+// (docs.* sorts before kass.*, and List orders by host) is the ONLY one
+// stamped elsewhere: acting on it would move the whole record to where a
+// minority of the app's hosts deploy from, converting a partial disagreement
+// into a total one the other way. The routes are the repair, so the handler
+// says so — before the card, and before anything is written.
+func TestAppMoveRefusesAPartialMismatch(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const destID = 4444
+ // A destination that would otherwise pass every later check — known to the
+ // console, and one paul belongs to — so the refusal can only be the
+ // disagreement itself.
+ if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(destID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // One of kass's two routes re-stamped, and it is the one that sorts first.
+ fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
+ Version: "v1", App: "kass", Account: "cdef", Hibernate: true})
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // Unconfirmed: no card, because a card here would claim the app's hosts
+ // already read from Second when only one of them does.
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("partial-mismatch move = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "routes disagree among themselves") {
+ t.Fatalf("no routes-disagree refusal flash:\n%s", page)
+ }
+
+ // Confirmed, straight at the door: same refusal, and nothing moved.
+ status, loc, body = postGated(t, fx, c, fx.u("/apps/kass/move"),
+ url.Values{"csrf": {csrf}, "confirmed": {"1"}, "to": {"cdef"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("confirmed partial-mismatch move = %d -> %q:\n%s", status, loc, body)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "routes disagree among themselves") {
+ t.Fatalf("no routes-disagree refusal flash on the confirmed POST:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved to the minority account", has, err)
+ }
+}
+
// ---- the Settings page ------------------------------------------------------
// The rename form is offered while the name is still only a record here, and
diff --git a/internal/console/flow.go b/internal/console/flow.go
index c7c1caea0..c667ee980 100644
--- a/internal/console/flow.go
+++ b/internal/console/flow.go
@@ -79,6 +79,22 @@ type FlowStage struct {
// three version strings you compare by eye.
Carried bool
Addresses []FlowAddress
+ // RolledUp is true when this stage has more than flowAddrMax addresses:
+ // Addresses then holds only the needs-attention exceptions (capped at
+ // flowExceptionMax), and Total/Attention/Hidden/MoreURL carry the rest of
+ // the story instead of an unbounded list.
+ RolledUp bool
+ // Total is the full address count, only set when RolledUp.
+ Total int
+ // Attention is how many addresses need looking at (needsAttention),
+ // regardless of how many of them fit in Addresses.
+ Attention int
+ // Hidden is Attention minus the exceptions actually listed in
+ // Addresses — the "+N more" the template offers alongside MoreURL.
+ Hidden int
+ // MoreURL is where the rolled-up stage sends an owner for the full list:
+ // the Running tab, filtered to this app.
+ MoreURL string
}
// FlowAddress is one address hanging off a stage, with its health.
@@ -96,7 +112,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,11 +124,35 @@ func addressTone(health string) string {
return "wait"
case "Not responding", "Failed":
return "bad"
+ case "Asleep", "No readings":
+ return "idle"
default:
return "idle"
}
}
+// flowAddrMax is the most addresses a stage lists in full. Below it the
+// diagram looks exactly as it always has; above it, a page anticipating
+// hundreds of per-customer instances (2026-08-10 design) shows counts and
+// problems, never an unbounded list.
+const flowAddrMax = 8
+
+// flowExceptionMax caps the needs-attention list inside a rolled-up stage;
+// the Running tab is where the full story lives.
+const flowExceptionMax = 5
+
+// needsAttention is the rollup's filter: the words worth naming when there
+// are too many addresses to name them all. Asleep and No readings are
+// deliberately not here — counted, not listed (hibernation is the system
+// working; no-readings is ignorance, not alarm).
+func needsAttention(health string) bool {
+ switch health {
+ case "Not responding", "Failed", "Setting up":
+ return true
+ }
+ return false
+}
+
// flowLine is the release path, in the order versions travel it, with the
// label each box carries. "live" rather than "stable" is deliberate and
// matches the Versions tab's strip: stable is the channel's name, live is
@@ -122,8 +165,9 @@ var flowLine = []struct{ name, channel string }{
// assembleFlow folds the app's channels and its tenant-filtered instance rows
// into the diagram. latest/latestAt are the newest shipped version and its
-// build time ("" when nothing has been shipped).
-func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) FlowView {
+// build time ("" when nothing has been shipped). instancesPath is the app's
+// Running tab URL, used as MoreURL on any stage that rolls up.
+func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView {
f := FlowView{Shipped: latest}
if t, err := time.Parse(time.RFC3339, latestAt); err == nil {
f.ShippedAt = t
@@ -169,6 +213,23 @@ func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) Fl
if s.Err == "" && s.Version == "" && len(s.Addresses) > 0 {
s.Err = "nothing published here yet — these addresses are waiting for a version"
}
+ if addrs := s.Addresses; len(addrs) > flowAddrMax {
+ s.RolledUp = true
+ s.Total = len(addrs)
+ s.MoreURL = instancesPath
+ var exceptions []FlowAddress
+ for _, a := range addrs {
+ if !needsAttention(a.Health) {
+ continue
+ }
+ s.Attention++
+ if len(exceptions) < flowExceptionMax {
+ exceptions = append(exceptions, a)
+ }
+ }
+ s.Hidden = s.Attention - len(exceptions)
+ s.Addresses = exceptions
+ }
return s
}
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go
index 6ff027368..f5f7c7586 100644
--- a/internal/console/flow_test.go
+++ b/internal/console/flow_test.go
@@ -1,6 +1,9 @@
package console
-import "testing"
+import (
+ "fmt"
+ "testing"
+)
// stageByChannel finds one stage across every band, so the tests can assert
// about a channel without caring which band the assembler filed it under.
@@ -20,7 +23,7 @@ func stageByChannel(f FlowView, channel string) *FlowStage {
// missing reads as "there is no later step", which is the opposite of true.
func TestFlowLineIsAlwaysTheWholePath(t *testing.T) {
v := &AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}
- f := assembleFlow(v, nil, "a1", "")
+ f := assembleFlow(v, nil, "a1", "", "/a/x/apps/app/instances")
if len(f.Line) != 3 {
t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line)
@@ -50,7 +53,7 @@ func TestFlowAddressesAttachToTheirChannel(t *testing.T) {
{Host: "www.kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"},
{Host: "rc.kass.dev", Channel: "canary/rc", Version: "b2", HealthWord: "Asleep"},
}
- f := assembleFlow(v, rows, "b2", "")
+ f := assembleFlow(v, rows, "b2", "", "/a/x/apps/app/instances")
live := stageByChannel(f, "stable")
if live == nil || len(live.Addresses) != 2 {
@@ -81,7 +84,7 @@ func TestFlowDrawsEveryRunningAddress(t *testing.T) {
{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"},
{Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"},
}
- f := assembleFlow(v, rows, "a1", "")
+ f := assembleFlow(v, rows, "a1", "", "/a/x/apps/app/instances")
for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} {
found := false
@@ -109,7 +112,7 @@ func TestFlowDrawsEveryRunningAddress(t *testing.T) {
func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
chans := []ChannelView{{Name: "edge", Version: "a1"}, {Name: "beta", Version: "a1"}, {Name: "stable", Version: "a1"}}
- on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "")
+ on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "", "/a/x/apps/app/instances")
if got := stageByChannel(on, "beta").Hold; got != "~24h" {
t.Errorf("beta hold is %q, want ~24h", got)
}
@@ -120,7 +123,7 @@ func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
t.Errorf("edge holds nothing back but says %q", got)
}
- off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "")
+ off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "", "/a/x/apps/app/instances")
for _, s := range off.Line {
if s.Hold != "" {
t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold)
@@ -128,7 +131,7 @@ func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
}
// An unreadable flag is treated as on, because that is what the boxes do.
- bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "")
+ bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "", "/a/x/apps/app/instances")
if stageByChannel(bad, "stable").Hold == "" {
t.Error("an unreadable production flag must still show the delay — boxes keep baking")
}
@@ -146,8 +149,9 @@ 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", "")
+ f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "", "/a/x/apps/app/instances")
want := map[string]string{
"a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad",
@@ -155,6 +159,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] {
@@ -171,7 +178,7 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
{Name: "edge", Version: "a1"},
{Name: "beta", Version: "a1"},
{Name: "stable", Version: "old"},
- }}, nil, "a1", "")
+ }}, nil, "a1", "", "/a/x/apps/app/instances")
want := []bool{true, true, false}
for i, s := range f.Line {
if s.Carried != want[i] {
@@ -181,13 +188,13 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
// Two empty boxes in a row share the empty string, and no version has
// travelled between them — the arrow must stay dashed.
- empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "")
+ empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances")
if empty.Line[1].Carried || empty.Line[2].Carried {
t.Errorf("empty boxes must not chain a solid arrow: %+v", empty.Line)
}
// Nothing shipped at all: the very first arrow has nothing to carry.
- none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "")
+ none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "", "/a/x/apps/app/instances")
if none.Line[0].Carried {
t.Error("no shipped version — the arrow into edge cannot be solid")
}
@@ -198,7 +205,7 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
// box instead.
func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) {
rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}}
- f := assembleFlow(&AppView{}, rows, "", "")
+ f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances")
rc := stageByChannel(f, "canary/rc")
if rc == nil {
@@ -212,14 +219,90 @@ func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) {
// Nothing shipped and nothing running is not a diagram — it is an empty
// state, and the page has better words for that than three blank boxes.
func TestFlowHidesItselfWhenThereIsNothingToDraw(t *testing.T) {
- if f := assembleFlow(&AppView{}, nil, "", ""); f.Show {
+ if f := assembleFlow(&AppView{}, nil, "", "", "/a/x/apps/app/instances"); f.Show {
t.Error("an app with no channels and no addresses should not draw a diagram")
}
- if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", ""); !f.Show {
+ if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances"); !f.Show {
t.Error("a shipped version on edge is worth drawing")
}
rows := []InstanceRow{{Host: "kass.dev", Channel: "stable", HealthWord: "Running"}}
- if f := assembleFlow(&AppView{}, rows, "", ""); !f.Show {
+ if f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances"); !f.Show {
t.Error("a running address is worth drawing even with no readable pointer")
}
}
+
+// The rollup exists for stages that anticipate hundreds of addresses; a
+// stage still inside the normal range must render exactly as it always has,
+// with no rollup fields engaged at all.
+func TestFlowSmallStagesUnchanged(t *testing.T) {
+ rows := make([]InstanceRow, 0, flowAddrMax)
+ for i := 0; i < flowAddrMax; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: "Running"})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0]
+ if st.RolledUp {
+ t.Fatalf("%d addresses must not roll up: %+v", flowAddrMax, st)
+ }
+ if len(st.Addresses) != flowAddrMax {
+ t.Fatalf("all %d addresses should be listed, got %d: %+v", flowAddrMax, len(st.Addresses), st.Addresses)
+ }
+ if st.Total != 0 || st.Attention != 0 || st.Hidden != 0 || st.MoreURL != "" {
+ t.Fatalf("rollup fields must stay zero when not rolled up: %+v", st)
+ }
+}
+
+// The needs-attention list inside a rolled-up stage is capped so the diagram
+// never grows unbounded again by another route — the Running tab is where
+// the rest of the story lives, via MoreURL.
+func TestFlowRollupCapsExceptions(t *testing.T) {
+ rows := make([]InstanceRow, 0, 20)
+ for i := 0; i < 20; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ w := "Running"
+ if i < 9 {
+ w = "Not responding"
+ }
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0]
+ if !st.RolledUp || st.Attention != 9 {
+ t.Fatalf("rollup: %+v", st)
+ }
+ if len(st.Addresses) != flowExceptionMax {
+ t.Fatalf("exceptions capped at %d, got %d: %+v", flowExceptionMax, len(st.Addresses), st.Addresses)
+ }
+ if st.Hidden != 4 {
+ t.Fatalf("hidden should be 9 attention - 5 shown = 4, got %d", st.Hidden)
+ }
+}
+
+// The exact test from the task brief, kept verbatim for full alignment with
+// the interface it specifies.
+func TestFlowRollsUpBigStages(t *testing.T) {
+ rows := make([]InstanceRow, 0, 12)
+ for i := 0; i < 12; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ w := "Running"
+ if i == 3 || i == 7 {
+ w = "Not responding"
+ }
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0] // edge
+ if !st.RolledUp || st.Total != 12 || st.Attention != 2 || st.Hidden != 0 {
+ t.Fatalf("rollup: %+v", st)
+ }
+ if len(st.Addresses) != 2 || st.Addresses[0].Health != "Not responding" {
+ t.Fatalf("exceptions: %+v", st.Addresses)
+ }
+ if st.MoreURL != "/a/x/apps/app/instances" {
+ t.Fatalf("MoreURL: %q", st.MoreURL)
+ }
+}
diff --git a/internal/console/listquery.go b/internal/console/listquery.go
new file mode 100644
index 000000000..f5d072ef9
--- /dev/null
+++ b/internal/console/listquery.go
@@ -0,0 +1,151 @@
+// The Running tab's and the instances API's shared list controls: a
+// substring match, a health-word filter, and an opaque after-this-host
+// cursor. GET-param driven and server-computed on purpose — a filter the
+// server applies is one the API and CLI can apply identically (every carlos
+// command works for a user with no box access), and a bookmarked filtered
+// URL keeps meaning the same thing.
+package console
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/url"
+ "strings"
+)
+
+// instancePageSize is the Running tab's page length.
+const instancePageSize = 50
+
+// healthWords is every word healthWord can emit, in the order the header
+// lists them. A new word joins healthWord, this list, and nothing else —
+// slugs, filters and links all derive from here.
+var healthWords = []string{"Running", "Serving", "Setting up", "Asleep", "No readings", "Not responding", "Failed"}
+
+// instanceQuery is one request's list controls. Zero value = everything,
+// first page.
+type instanceQuery struct {
+ Q string // substring match on host, trimmed
+ Health string // canonical health word ("" = all)
+ Cursor string // opaque; list resumes after the host inside it
+}
+
+func parseInstanceQuery(v url.Values) instanceQuery {
+ return instanceQuery{
+ Q: strings.TrimSpace(v.Get("q")),
+ Health: healthFromSlug(v.Get("health")),
+ Cursor: v.Get("cursor"),
+ }
+}
+
+// healthSlug is a health word's URL form: "Not responding" ↔
+// "not-responding".
+func healthSlug(word string) string {
+ return strings.ReplaceAll(strings.ToLower(word), " ", "-")
+}
+
+// healthFromSlug inverts healthSlug. An unknown slug returns "" — no filter:
+// a mistyped URL shows everything rather than a mysteriously empty list.
+func healthFromSlug(slug string) string {
+ for _, w := range healthWords {
+ if healthSlug(w) == slug {
+ return w
+ }
+ }
+ return ""
+}
+
+type instanceCursor struct {
+ Host string `json:"host"`
+}
+
+func encodeInstanceCursor(host string) string {
+ b, _ := json.Marshal(instanceCursor{Host: host})
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// decodeInstanceCursor degrades on any malformed input to "no cursor" —
+// page one, never an error page (applog.DecodeCursor's rule).
+func decodeInstanceCursor(s string) (string, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(s)
+ if err != nil {
+ return "", false
+ }
+ var c instanceCursor
+ if json.Unmarshal(b, &c) != nil || c.Host == "" {
+ return "", false
+ }
+ return c.Host, true
+}
+
+// healthLink is one header chip: a word, its count over ALL the app's rows,
+// and the link that filters to it. Precomputed — templates render facts.
+type healthLink struct {
+ Word, Slug string
+ Count int
+ Active bool
+}
+
+// instancePage is one screen of the list: the rows to render plus the
+// numbers and links the header states.
+type instancePage struct {
+ Rows []InstanceRow
+ Counts map[string]int // health word -> count over ALL rows
+ Total int // rows matching Q+Health, across all pages
+ Links []healthLink // non-zero words, healthWords order
+ NextCursor string // "" on the last page
+ NextURL string // the whole next-page href; "" on the last page
+}
+
+// pageInstanceRows applies q to rows (which arrive in host order and stay in
+// it) and cuts one page. Counts cover every row regardless of filter — the
+// header's chips are the app's whole health story, and double as filters.
+func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePage {
+ p := instancePage{Counts: map[string]int{}}
+ var matched []InstanceRow
+ for _, r := range rows {
+ p.Counts[r.HealthWord]++
+ if q.Q != "" && !strings.Contains(r.Host, q.Q) {
+ continue
+ }
+ if q.Health != "" && r.HealthWord != q.Health {
+ continue
+ }
+ matched = append(matched, r)
+ }
+ p.Total = len(matched)
+ start := 0
+ if after, ok := decodeInstanceCursor(q.Cursor); ok {
+ for i, r := range matched {
+ if r.Host > after {
+ start = i
+ break
+ }
+ start = i + 1
+ }
+ }
+ end := start + limit
+ if end < len(matched) {
+ p.NextCursor = encodeInstanceCursor(matched[end-1].Host)
+ } else {
+ end = len(matched)
+ }
+ p.Rows = matched[start:end]
+ for _, w := range healthWords {
+ if p.Counts[w] == 0 {
+ continue
+ }
+ p.Links = append(p.Links, healthLink{Word: w, Slug: healthSlug(w), Count: p.Counts[w], Active: q.Health == w})
+ }
+ if p.NextCursor != "" {
+ v := url.Values{}
+ if q.Q != "" {
+ v.Set("q", q.Q)
+ }
+ if q.Health != "" {
+ v.Set("health", healthSlug(q.Health))
+ }
+ v.Set("cursor", p.NextCursor)
+ p.NextURL = "?" + v.Encode()
+ }
+ return p
+}
diff --git a/internal/console/listquery_test.go b/internal/console/listquery_test.go
new file mode 100644
index 000000000..0d283902f
--- /dev/null
+++ b/internal/console/listquery_test.go
@@ -0,0 +1,72 @@
+package console
+
+import (
+ "net/url"
+ "testing"
+)
+
+func rowsFor(hosts ...string) []InstanceRow {
+ out := make([]InstanceRow, len(hosts))
+ for i, h := range hosts {
+ out[i] = InstanceRow{Host: h, HealthWord: "Running"}
+ }
+ return out
+}
+
+func TestHealthSlugRoundTrips(t *testing.T) {
+ for _, w := range healthWords {
+ if got := healthFromSlug(healthSlug(w)); got != w {
+ t.Fatalf("%q -> %q -> %q", w, healthSlug(w), got)
+ }
+ }
+ if healthFromSlug("nonsense") != "" {
+ t.Fatal("unknown slug must mean no filter, not an error")
+ }
+}
+
+func TestPageFiltersAndCounts(t *testing.T) {
+ rows := rowsFor("a.x", "ab.x", "b.x")
+ rows[2].HealthWord = "Asleep"
+ p := pageInstanceRows(rows, instanceQuery{Q: "a"}, 50)
+ if p.Total != 2 || len(p.Rows) != 2 || p.NextCursor != "" {
+ t.Fatalf("%+v", p)
+ }
+ if p.Counts["Running"] != 2 || p.Counts["Asleep"] != 1 {
+ t.Fatalf("counts are over ALL rows, filtered or not: %+v", p.Counts)
+ }
+ p = pageInstanceRows(rows, instanceQuery{Health: "Asleep"}, 50)
+ if p.Total != 1 || p.Rows[0].Host != "b.x" {
+ t.Fatalf("%+v", p)
+ }
+}
+
+func TestPageCursors(t *testing.T) {
+ rows := rowsFor("a.x", "b.x", "c.x", "d.x", "e.x")
+ p1 := pageInstanceRows(rows, instanceQuery{}, 2)
+ if len(p1.Rows) != 2 || p1.NextCursor == "" || p1.Rows[1].Host != "b.x" {
+ t.Fatalf("page1: %+v", p1)
+ }
+ p2 := pageInstanceRows(rows, instanceQuery{Cursor: p1.NextCursor}, 2)
+ if len(p2.Rows) != 2 || p2.Rows[0].Host != "c.x" {
+ t.Fatalf("page2: %+v", p2)
+ }
+ p3 := pageInstanceRows(rows, instanceQuery{Cursor: p2.NextCursor}, 2)
+ if len(p3.Rows) != 1 || p3.Rows[0].Host != "e.x" || p3.NextCursor != "" {
+ t.Fatalf("page3: %+v", p3)
+ }
+}
+
+func TestPageGarbageCursorStartsOver(t *testing.T) {
+ rows := rowsFor("a.x", "b.x")
+ p := pageInstanceRows(rows, instanceQuery{Cursor: "!!not-base64!!"}, 50)
+ if len(p.Rows) != 2 {
+ t.Fatalf("bad cursor must degrade to page one: %+v", p)
+ }
+}
+
+func TestParseInstanceQuery(t *testing.T) {
+ q := parseInstanceQuery(url.Values{"q": {" api "}, "health": {"not-responding"}, "cursor": {"abc"}})
+ if q.Q != "api" || q.Health != "Not responding" || q.Cursor != "abc" {
+ t.Fatalf("%+v", q)
+ }
+}
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
index 2aae2b16f..0c2f5e488 100644
--- a/internal/console/metricsview.go
+++ b/internal/console/metricsview.go
@@ -697,16 +697,19 @@ func (m InstanceMetrics) Reporting() bool { return m.Box != "" }
// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the given
// hosts' own per-instance readings — carlos-app@<host>.service for a
-// unit-backed tenant, carlos-exec@<host> for an exec-backed one. It returns
-// unit-level rows only — no host totals, no other units, no alert data. The
-// allowlist is the hosts slice the caller derived from the app's OWN routes;
-// nothing else can enter the view.
+// unit-backed tenant, carlos-exec@<host> for an exec-backed one, or the
+// operator-stamped bespoke unit named in unitByHost[host] (registry
+// Route.Unit, #110) when the caller supplies one. It returns unit-level
+// rows only — no host totals, no other units, no alert data. The allowlist
+// is the hosts slice the caller derived from the app's OWN routes, plus
+// unitByHost entries drawn from those same routes; nothing else can enter
+// the view.
//
// Every allowlisted host gets exactly one row, in host order, whether or not
// the sweep saw it: a host with no unit data is an UNKNOWN row (nil
// readings, Reporting false), never zeros and never silently dropped — an
// owner whose instance is asleep must still see it listed.
-func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]InstanceMetrics, error) {
+func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost map[string]string) ([]InstanceMetrics, error) {
// The allowlist, keyed by the unit names each host can legally match.
// Matching is exact-string on this map: a unit the caller did not ask
// for cannot be reached by any path below.
@@ -722,6 +725,14 @@ func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]Instanc
// rule below picks deterministically, no special case.
want[activator.UnitName(h)] = h
want[activator.ExecName(h)] = h
+ // The third legal key: an operator-stamped bespoke unit (registry
+ // Route.Unit, #110). Opt-in per host — the caller maps host →
+ // stamped unit from the route rows it already holds; an empty map
+ // costs nothing and matches nothing new. (A nil map indexes fine
+ // in Go — no guard needed.)
+ if u := unitByHost[h]; u != "" {
+ want[u] = h
+ }
}
if len(want) == 0 {
return nil, nil
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go
index b2e260795..131b1cba7 100644
--- a/internal/console/metricsview_test.go
+++ b/internal/console/metricsview_test.go
@@ -1015,14 +1015,14 @@ func TestSweepAllFailsOnUnreadableChunk(t *testing.T) {
// having no boxes at all) never an error path the app page has to handle.
func TestOwnerAppMetricsEmptyHosts(t *testing.T) {
srv, _ := newMetricsFixture(t)
- rows, err := srv.OwnerAppMetrics(context.Background(), nil)
+ rows, err := srv.OwnerAppMetrics(context.Background(), nil, nil)
if err != nil {
t.Fatalf("empty hosts errored: %v", err)
}
if len(rows) != 0 {
t.Fatalf("empty hosts = %+v, want no rows", rows)
}
- rows, err = srv.OwnerAppMetrics(context.Background(), []string{})
+ rows, err = srv.OwnerAppMetrics(context.Background(), []string{}, nil)
if err != nil || len(rows) != 0 {
t.Fatalf("empty slice = %+v, %v; want no rows, no error", rows, err)
}
@@ -1053,7 +1053,7 @@ func TestOwnerAppMetricsFiltersToOwnUnits(t *testing.T) {
t.Fatal(err)
}
- rows, err := srv.OwnerAppMetrics(ctx, []string{"owned.example.com", "asleep.example.com"})
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"owned.example.com", "asleep.example.com"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -1140,7 +1140,7 @@ func TestOwnerAppMetricsSurvivesHealthyFlushCycle(t *testing.T) {
write("box-healthy-2", "flush2.example.com", 7*time.Minute)
write("box-dead", "dead.example.com", 8*time.Minute+30*time.Second)
- rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"})
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"flush1.example.com", "flush2.example.com", "dead.example.com"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -1292,7 +1292,7 @@ func TestOwnerAppMetricsSeesExecBackedInstances(t *testing.T) {
t.Fatal(err)
}
- rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"})
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"keymail.example.com"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -1349,7 +1349,7 @@ func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) {
t.Fatal(err)
}
- rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"})
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"flip.example.com"}, nil)
if err != nil {
t.Fatal(err)
}
@@ -1364,3 +1364,58 @@ func TestOwnerAppMetricsExecAndUnitKeysNeverDoubleReport(t *testing.T) {
t.Fatalf("CPU = %v, want the freshest box's 0.11", got.CPUFrac)
}
}
+
+// TestOwnerAppMetricsMatchesStampedUnit pins the third allowlist key: an
+// operator-stamped bespoke unit (registry Route.Unit, #110) reports under
+// its real systemd unit name, not carlos-app@<host>.service — and the
+// sweep can only match it when the caller opts a host into that name via
+// unitByHost. Without the entry the same host stays unreached, proving the
+// third key is opt-in per host, not a wildcard that reopens the boundary.
+func TestOwnerAppMetricsMatchesStampedUnit(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ ctx := context.Background()
+ at := time.Now().UTC().Add(-time.Minute)
+ if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{
+ V: 1, Box: "b1", Hour: metrics.HourOf(at),
+ Samples: []metrics.Sample{{
+ At: at,
+ Units: map[string]metrics.UnitSample{
+ "titogo@vicky.service": {CPUFrac: p(0.33), MemBytes: pi64(3333)},
+ },
+ }},
+ }); err != nil {
+ t.Fatal(err)
+ }
+
+ rows, err := srv.OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"},
+ map[string]string{"vicky.go.tito.io": "titogo@vicky.service"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows)
+ }
+ got := rows[0]
+ if got.Host != "vicky.go.tito.io" || !got.Reporting() {
+ t.Fatalf("stamped-unit host not reporting: %+v", got)
+ }
+ if got.Box != "b1" {
+ t.Fatalf("Box = %q, want b1", got.Box)
+ }
+ if got.CPUFrac == nil || *got.CPUFrac != 0.33 {
+ t.Fatalf("CPU = %v, want 0.33", got.CPUFrac)
+ }
+
+ // Without the unitByHost entry, the same host must NOT match — the
+ // third key is opt-in per host, not a wildcard.
+ rows, err = srv.OwnerAppMetrics(ctx, []string{"vicky.go.tito.io"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("rows = %+v, want exactly the one allowlisted host", rows)
+ }
+ if rows[0].Reporting() {
+ t.Fatalf("host reported without an opt-in unitByHost entry: %+v", rows[0])
+ }
+}
diff --git a/internal/console/overview.go b/internal/console/overview.go
index d7738e9be..5a635f281 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
@@ -31,6 +33,14 @@ type OverviewStats struct {
InstanceTotal int
InstanceReporting int
InstanceAsleep int
+ // InstanceNoReadings counts instance-kind routes nobody could sample: not
+ // provisioned, no carlos-app@ backing, no stamped unit. They are honest
+ // unknowns — the tile names them separately and the verdict must not
+ // treat them as down (the 2026-08-10 tito false alarm).
+ InstanceNoReadings int
+ // InstanceCounted is the Running tile's denominator: InstanceExpected
+ // minus the unknowable. Templates render facts, never compute them.
+ InstanceCounted int
// MetricsUnavailable: the sweep itself failed (nil metrics), which is
// "readings unavailable", not "zero instances reporting".
@@ -127,9 +137,12 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
st.InstanceReporting++
} else if rt.Hibernate {
st.InstanceAsleep++
+ } else if !expectsReadings(rt.Provisioned, rt.Unit, rt.Backing) {
+ st.InstanceNoReadings++
}
}
}
+ st.InstanceCounted = st.InstanceExpected - st.InstanceNoReadings
// Routes and Domains are parallel slices — Assemble appends one of each
// per registry route, in the same loop — which is what lets the domain
// rows be account-filtered here at all (DomainView carries no account of
@@ -179,7 +192,7 @@ func healthVerdict(st OverviewStats) (string, string) {
return "attention", "Needs attention"
case st.MetricsUnavailable && st.InstanceExpected > 0:
return "unknown", "Can't tell right now"
- case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected:
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceCounted:
return "attention", "Needs attention"
default:
return "ok", "All good"
@@ -236,26 +249,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 +298,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..02ab3834c 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
@@ -139,6 +141,39 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
}
}
+// TestOverviewSeparatesNoReadings pins InstanceNoReadings/InstanceCounted:
+// a silent instance route the sweep was never told how to sample (no
+// Provisioned, Unit or Backing) is honest ignorance, not an outage, and
+// must not count against the Running tile's denominator.
+func TestOverviewSeparatesNoReadings(t *testing.T) {
+ view := &AppView{Routes: []RouteView{
+ {Host: "a.x", Kind: "instance", Account: "acct1", Provisioned: true}, // reporting
+ {Host: "b.x", Kind: "instance", Account: "acct1", Provisioned: true}, // silent, expected
+ {Host: "c.x", Kind: "instance", Account: "acct1"}, // silent, bespoke-unstamped
+ {Host: "d.x", Kind: "instance", Account: "acct1", Hibernate: true}, // asleep
+ }, Domains: []DomainView{{}, {}, {}, {}}}
+ metrics := []InstanceMetrics{{Host: "a.x", Box: "box1"}}
+ st := assembleOverview(view, "acct1", metrics, nil)
+ if st.InstanceExpected != 4 || st.InstanceReporting != 1 || st.InstanceAsleep != 1 ||
+ st.InstanceNoReadings != 1 || st.InstanceCounted != 3 {
+ t.Fatalf("counts: %+v", st)
+ }
+}
+
+// TestVerdictIgnoresNoReadings pins that healthVerdict's shortfall case
+// compares against InstanceCounted, not InstanceExpected — a no-readings
+// row must not trip "attention" (the 2026-08-10 tito false alarm).
+func TestVerdictIgnoresNoReadings(t *testing.T) {
+ // 1 reporting + 1 asleep + 1 no-readings out of 3 counted... construct an
+ // OverviewStats where Reporting+Asleep == InstanceCounted and assert the
+ // verdict is NOT "attention" on account of the no-readings row.
+ st := OverviewStats{HasStable: true, InstanceExpected: 3, InstanceCounted: 2,
+ InstanceReporting: 1, InstanceAsleep: 1, InstanceNoReadings: 1}
+ if v, _ := healthVerdict(st); v != "ok" {
+ t.Fatalf("no-readings rows must not trip the verdict: %s", v)
+ }
+}
+
// PrimaryHost is the apex of this account's OWN routes — Task 2's apexHost
// reused here, so the headline's "live at" link names the same host
// summarize's account table does (same fixture shape as
@@ -298,23 +333,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 +477,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.
@@ -443,6 +513,19 @@ func TestOverviewInstancesPageRendersTable(t *testing.T) {
}
}
+func TestInstancesPageFilters(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances?q=zzz-no-such-host"))
+ if status != http.StatusOK || !strings.Contains(body, "No addresses match.") {
+ t.Fatalf("filtered-empty state missing (%d):\n%s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/kass/instances?health=running"))
+ if status != http.StatusOK {
+ t.Fatalf("health filter = %d", status)
+ }
+}
+
func TestOverviewVerdict(t *testing.T) {
cases := []struct {
name string
@@ -450,10 +533,10 @@ func TestOverviewVerdict(t *testing.T) {
verdict string
label string
}{
- {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"},
- {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
- {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"},
- {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceCounted: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
{"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"},
{"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"},
{"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
@@ -481,3 +564,38 @@ func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
t.Error("assembleOverview must fill Verdict and VerdictLabel")
}
}
+
+// TestAppPageShowsPartialMismatchNotFullAlarm guards the boundary
+// AllMismatched exists to draw: when only SOME of an app's routes disagree
+// with the viewed account, the page must name the disagreeing route(s), not
+// claim the whole app is not deploying — the false alarm task 4 was written
+// to retire (2026-08-10 tito incident, see the MismatchedRoute doc comment).
+func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved := testAccountID()
+ sqid := accountSqid(moved)
+ if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Only ONE of kass's two fixture routes moves (kass.carlosapp.dev);
+ // docs.kass.carlosapp.dev stays on ops — a partial mismatch, not a full
+ // one.
+ if err := reg.SetAccount("kass.carlosapp.dev", sqid); err != nil {
+ t.Fatal(err)
+ }
+ reg.Close()
+
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ if strings.Contains(body, "This app is not deploying") {
+ t.Errorf("a partial mismatch must not render the full-alarm copy:\n%s", body)
+ }
+ if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "(reads <code>"+sqid+"</code>)") {
+ t.Errorf("the partial-mismatch banner does not name the disagreeing route and its account:\n%s", body)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index df56e9399..fada08534 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -99,6 +99,9 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// see alertValueText. LoadMetrics only ever returns firing rules, so an
// alert row always carries a value; there is no nil case to dash out.
"alertVal": alertValueText,
+ // healthSlug: the Running tab's health-filter links and the hidden
+ // carried-filter field both need a health word's URL form (listquery.go).
+ "healthSlug": healthSlug,
// bytesOrDash renders a byte count in the smallest unit that keeps it
// readable; nil (no reading, from a unit that reported CPU but not
// memory/IO that tick) is "—", never a fabricated 0 B.
@@ -589,6 +592,13 @@ type page struct {
// App Instances: the one table — routes joined with their readings.
InstanceRows []InstanceRow
+ // App Instances: the search/health-filter/cursor controls (Task 8) —
+ // InstancePage is the computed page (rows, counts, links, pager),
+ // InstanceQuery is the parsed request so the form and links re-render
+ // what the viewer asked for.
+ InstancePage instancePage
+ InstanceQuery instanceQuery
+
// App Logs (apilogs.go's sibling page): the merged timeline plus the
// filter state the form re-renders with. Empty LogRows with LogsErr ""
// is the normal nothing-logged-yet state, never an error.
@@ -1582,14 +1592,18 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa
func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics {
hosts := make([]string, 0, len(view.Routes))
hib := make(map[string]bool, len(view.Routes))
+ unitByHost := make(map[string]string, len(view.Routes))
for _, rt := range view.Routes {
if rt.Account != acct.Sqid {
continue
}
hosts = append(hosts, rt.Host)
hib[rt.Host] = rt.Hibernate
+ if rt.Unit != "" {
+ unitByHost[rt.Host] = rt.Unit
+ }
}
- am, err := s.OwnerAppMetrics(ctx, hosts)
+ am, err := s.OwnerAppMetrics(ctx, hosts, unitByHost)
if err != nil {
log.Printf("console: app metrics %s: %v", view.App, err)
return nil
@@ -1626,7 +1640,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
if len(versions) > 0 {
latest, latestAt = versions[0].Version, versions[0].BuiltAt
}
- p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt)
+ p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt, p.Nav+"/apps/"+p.AppName+"/instances")
if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
log.Printf("console: events %s: %v", app, err)
} else {
@@ -1721,7 +1735,12 @@ 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)
+ q := parseInstanceQuery(r.URL.Query())
+ rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics)
+ pg := pageInstanceRows(rows, q, instancePageSize)
+ p.InstanceRows = pg.Rows
+ p.InstancePage = pg
+ p.InstanceQuery = q
// Placement (Task 6): where this app runs, and where else it could. Soft
// like everything else on this tab — a failed read costs the section's
// certainty (AppPlacementKnown) and a flash, never the page.
@@ -1732,7 +1751,11 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
if warn != "" && p.FlashErr == "" {
p.FlashErr = warn
}
- if len(p.InstanceRows) == 0 {
+ // The empty state keys off the app's UNFILTERED rows, not the filtered
+ // page: a filter that matches nothing shows "no matches" (below), never
+ // the attach-onboarding copy — that copy is only true when the app has
+ // no instances at all.
+ if len(rows) == 0 {
p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable")
}
s.render(w, http.StatusOK, "app_instances", p)
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..92df7251c 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -4,6 +4,19 @@
{{template "appnav" .}}
<h2>Running</h2>
+{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health}}
+<form method="get" class="listctl">
+ <input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses">
+ {{if .InstanceQuery.Health}}<input type="hidden" name="health" value="{{healthSlug .InstanceQuery.Health}}">{{end}}
+ <button>Filter</button>
+</form>
+<p class="small">
+ <a href="?{{if .InstanceQuery.Q}}q={{.InstanceQuery.Q}}{{end}}"{{if not .InstanceQuery.Health}} aria-current="true"{{end}}>All</a>
+ {{range .InstancePage.Links}}
+ · <a href="?health={{.Slug}}{{if $.InstanceQuery.Q}}&amp;q={{$.InstanceQuery.Q}}{{end}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a>
+ {{end}}
+</p>
+{{end}}
{{if .InstanceRows}}
<div class="tablewrap">
<table class="instances">
@@ -11,7 +24,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}}
@@ -27,6 +40,9 @@
<p class="small">Version is what each copy 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 copy's own peak.</p>
+{{if .InstancePage.NextURL}}<p class="small"><a href="{{.InstancePage.NextURL}}">Next {{len .InstanceRows}} shown of {{.InstancePage.Total}} →</a></p>{{end}}
+{{else if or .InstanceQuery.Q .InstanceQuery.Health}}
+<p>No addresses match.</p>
{{else}}
<p>Nothing running yet. Publish a version, then give it a web address:</p>
{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 81689f40d..3d53ef2c4 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -3,15 +3,26 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
+{{if .Detail.AllMismatched}}
<p class="banner error">
- <strong>This app is not deploying.</strong> Its hosts read releases from
- account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <strong>This app is not deploying.</strong> Every one of its addresses reads
+ releases from another account, but this page writes them to
<code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Finish the move on this app's
+ nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
<span class="small">If it is the routes that are in the wrong account, move
those back instead — whichever of the two is right, they have to agree.</span>
</p>
+{{else}}
+<p class="banner">
+ {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
+ read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
+ follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ The rest deploy normally. Fix on
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
+</p>
+{{end}}
{{end}}
{{with .Overview}}
<section class="pagehead" aria-label="App status">
@@ -36,8 +47,8 @@
routinely share a box. */}}
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Running</h2>
- <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else}}{{.InstanceReporting}} of {{.InstanceExpected}}{{end}}</p>
- <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else}}copies responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else if eq .InstanceCounted 0}}?{{else}}{{.InstanceReporting}} of {{.InstanceCounted}}{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceCounted 0}}no readings for {{.InstanceNoReadings}} copies — stamp their units to see health{{else}}copies reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{if .InstanceNoReadings}} · {{.InstanceNoReadings}} no readings{{end}}{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
<h2>Resources</h2>
@@ -47,7 +58,7 @@
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
<h2>Web addresses</h2>
<p class="tile-value">{{.DomainTotal}}</p>
- <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p>
+ <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}DNS &amp; certs OK{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
<h2>Versions</h2>
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index d26a8a4ad..ae7a6a299 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -3,13 +3,26 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
+{{if .Detail.AllMismatched}}
<p class="banner error">
- <strong>This app is not deploying.</strong> Its hosts read releases from
- account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <strong>This app is not deploying.</strong> Every one of its addresses reads
+ releases from another account, but this page writes them to
<code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Finish the move on this app's
+ nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ Finish the move on this app's
<a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
+ <span class="small">If it is the routes that are in the wrong account, move
+ those back instead — whichever of the two is right, they have to agree.</span>
</p>
+{{else}}
+<p class="banner">
+ {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
+ read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
+ follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
+ The rest deploy normally. Fix on
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
+</p>
+{{end}}
{{end}}
{{with .ReleaseSummary}}
diff --git a/internal/console/templates/flow.html b/internal/console/templates/flow.html
index 0cdb1dac1..0596ec050 100644
--- a/internal/console/templates/flow.html
+++ b/internal/console/templates/flow.html
@@ -28,6 +28,8 @@
<p class="flow-version">{{if .Version}}<code>{{.Version}}</code>{{else}}nothing{{end}}</p>
{{if .Hold}}<p class="flow-note">{{if .FastTrack}}delay skipped{{else}}waits {{.Hold}} here{{end}}</p>{{end}}
{{if .Err}}<p class="flow-note error">{{.Err}}</p>{{end}}
+ {{if .RolledUp}}
+ <p class="flow-note">{{.Total}} addresses{{if .Attention}} · <span class="error">{{.Attention}} need{{if eq .Attention 1}}s{{end}} attention</span>{{end}}</p>
{{if .Addresses}}
<ul class="flow-addrs">
{{range .Addresses}}
@@ -38,5 +40,16 @@
{{end}}
</ul>
{{end}}
+ <p class="flow-note"><a href="{{.MoreURL}}">{{if .Hidden}}+{{.Hidden}} more · {{end}}all addresses</a></p>
+ {{else}}{{if .Addresses}}
+ <ul class="flow-addrs">
+ {{range .Addresses}}
+ <li class="flow-addr flow-tone-{{.Tone}}">
+ <a href="https://{{.Host}}">{{.Host}}</a>
+ <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{end}}{{end}}
</li>
{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 9ac4b4589..ebf25f568 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -43,7 +43,7 @@
should wrap inside the box rather than force the horizontal scrollbar
every other <pre> on this page gets for a shell command. */
.changelog { white-space: pre-wrap; }
- select, input[type=text] {
+ select, input[type=text], input[type=search] {
font: inherit; font-family: var(--sans); font-size: 0.9rem; color: var(--ink); background: var(--bg);
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
}
@@ -52,6 +52,9 @@
it must not break the line it is part of. */
form.inline { display: inline; margin: 0; }
form.inline button { vertical-align: baseline; }
+ /* A list's filter row (the Running tab): its control and its button on one
+ line, wrapping onto the next when the screen is too narrow for both. */
+ form.listctl { display: flex; gap: 0.6rem; flex-wrap: wrap; margin: 0 0 0.6rem; }
/* Interface icons: inline SVG from internal/console/icons.go — Lucide,
vendored, because this page fetches nothing from anywhere. They carry no
width/height of their own, so they size to the text beside them and
diff --git a/internal/console/view.go b/internal/console/view.go
index 6b57bddcb..a5dfbe5f0 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -38,18 +38,31 @@ type AppView struct {
Production ProductionView
// RouteAccount is the account the app's ROUTES say it belongs to, when
// that disagrees with the account whose page this is; "" when they agree
- // or there are no routes. It is the half-migrated state — see
- // AccountMismatch.
+ // or there are no routes. It is the first mismatched account (kept for
+ // any caller other than the banner — see AccountMismatch) — the banner
+ // itself reads Mismatched/AllMismatched below.
RouteAccount string
+ // Mismatched is every non-platform route stamped to another account;
+ // AllMismatched is whether that is ALL of them — only then is "this app
+ // is not deploying" a claim the page can back.
+ Mismatched []MismatchedRoute
+ AllMismatched bool
}
+// MismatchedRoute is one route whose stamped account disagrees with the
+// account whose page is being viewed: host plus where it actually reads
+// releases from. The banner names these — a warning that says WHICH routes
+// is a diagnostic; one that says "this app" is an alarm, and on tito
+// (2026-08-10) it was a false one.
+type MismatchedRoute struct{ Host, Account string }
+
// AccountMismatch reports the half-migrated state that would otherwise be
// silent: `carlos accounts migrate` re-stamped this app's routes onto another
// account, and `console migrate-app` was never run, so the console promotes
// into one account's bucket keys while every host reads another's. A promote
// then flashes "Promoted" and deploys nothing, forever, with no error
// anywhere. The page has to say so.
-func (v AppView) AccountMismatch() bool { return v.RouteAccount != "" }
+func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
// DomainView is one row of the app page's Domains table — the product
// surface for what routes always were. Unknowns stay empty strings here and
@@ -137,6 +150,17 @@ type RouteView struct {
// can hold another tenant's rows, and a grant is the one field here that
// names privileged ports and box authority.
Caps string
+ // Unit is the bespoke systemd unit bound to this route
+ // (registry.Route.Unit, #110): set when an operator-run service owns the
+ // route's process. The sweep samples stamped units (Task 5), so a stamped
+ // route's silence is meaningful — see expectsReadings.
+ Unit string
+ // Backing mirrors registry.Route.Backing: "" = an exec child the
+ // activator spawns, registry.BackingUnit = a carlos-app@<host> unit.
+ Backing string
+ // Provisioned mirrors registry.Route.Provisioned: minted from a bucket
+ // instance record, so the activator runs its process by construction.
+ Provisioned bool
}
// humanizeCaps renders a registry.Route.Caps column for a person: the
@@ -344,20 +368,24 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
// only place Caps renders at all.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
- // What the ROUTES say each app's account is. A disagreement with the
- // account being viewed is the half-migrated state (see AccountMismatch);
- // platform-minted routes are excluded because they are aliases that
- // reconciliation re-stamps on its own schedule, so a lagging one is noise
- // rather than a stuck deploy.
- routeAccount := map[string]string{}
+ // What the ROUTES say each app's account is, at route granularity. A
+ // disagreement with the account being viewed is the half-migrated state
+ // (see AccountMismatch); platform-minted routes are excluded because
+ // they are aliases that reconciliation re-stamps on its own schedule, so
+ // a lagging one is noise rather than a stuck deploy. routableByApp is
+ // the denominator AllMismatched needs: every non-platform route counted,
+ // so "all of them mismatch" can be told apart from "some of them".
+ mismatchedByApp := map[string][]MismatchedRoute{}
+ routableByApp := map[string]int{}
for _, rt := range routes {
ch := rt.Channel
if ch == "" {
ch = "stable" // registry contract: empty channel means stable
}
if rt.App != "" && !rt.Platform {
- if a := release.RouteAccount(rt); routeAccount[rt.App] == "" {
- routeAccount[rt.App] = a
+ routableByApp[rt.App]++
+ if a := release.RouteAccount(rt); a != acct {
+ mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a})
}
}
rtAcct := release.RouteAccount(rt)
@@ -380,6 +408,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
Account: rtAcct,
Environment: rt.Environment,
Caps: capsFor,
+ Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned,
})
domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, edgeIPs))
}
@@ -394,9 +423,10 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
seen[app] = true
channels := assembleChannels(ctx, s, pub, acct, app, now)
+ mm := mismatchedByApp[app]
mismatch := ""
- if ra := routeAccount[app]; ra != "" && ra != acct {
- mismatch = ra
+ if len(mm) > 0 {
+ mismatch = mm[0].Account
}
pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now)
if pv.Err == "" && !pv.On {
@@ -412,14 +442,16 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
}
out = append(out, AppView{
- App: app,
- Routes: byApp[app], // List orders by host already
- Domains: domainsByApp[app],
- Channels: channels,
- Canaries: assembleCanaries(channels, byApp[app]),
- Ladder: assembleLadder(channels, byApp[app], now),
- Production: pv,
- RouteAccount: mismatch,
+ App: app,
+ Routes: byApp[app], // List orders by host already
+ Domains: domainsByApp[app],
+ Channels: channels,
+ Canaries: assembleCanaries(channels, byApp[app]),
+ Ladder: assembleLadder(channels, byApp[app], now),
+ Production: pv,
+ RouteAccount: mismatch,
+ Mismatched: mm,
+ AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],
})
}
return out, nil
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index 2d4f94e25..691e5e1d6 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -268,6 +268,154 @@ func TestAssembleDegradesBadChannel(t *testing.T) {
}
}
+// TestAssembleCarriesProcessOwnership guards the copy in Assemble's
+// RouteView literal: Unit/Backing/Provisioned are what later tasks (2, 3, 6,
+// 9) use to tell "no readings because nothing samples this route's process"
+// apart from "not responding" — a silent drop here would make every
+// unit-backed route look unowned.
+func TestAssembleCarriesProcessOwnership(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "unit-stamped.example.test", Kind: "instance",
+ Status: "ready", App: "kass", Channel: "stable",
+ Unit: "titogo@demo.service", Backing: registry.BackingUnit, Provisioned: true}); err != nil {
+ t.Fatal(err)
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"kass"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rt RouteView
+ found := false
+ for _, r := range views[0].Routes {
+ if r.Host == "unit-stamped.example.test" {
+ rt = r
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("route unit-stamped.example.test not in %+v", views[0].Routes)
+ }
+ if rt.Unit != "titogo@demo.service" || rt.Backing != registry.BackingUnit || !rt.Provisioned {
+ t.Fatalf("RouteView lost process ownership: %+v", rt)
+ }
+}
+
+// TestMismatchListsRoutes guards the route-granularity replacement for the
+// old app-wide first-writer-wins mismatch string: an app with one route
+// stamped to another account, among others that agree with the account
+// being viewed, must name that one route — not claim the whole app is
+// broken.
+func TestMismatchListsRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "one.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable", Account: "otheracct"},
+ {Host: "two.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable"},
+ {Host: "three.mismatchlist.test", Kind: "instance", Status: "ready",
+ App: "mismatchlist", Channel: "stable"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchlist"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 1 || v.Mismatched[0].Host != "one.mismatchlist.test" || v.Mismatched[0].Account != "otheracct" {
+ t.Fatalf("Mismatched = %+v, want exactly [{one.mismatchlist.test otheracct}]", v.Mismatched)
+ }
+ if v.AllMismatched {
+ t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)")
+ }
+ if !v.AccountMismatch() {
+ t.Fatal("AccountMismatch() = false, want true")
+ }
+ if v.RouteAccount != "otheracct" {
+ t.Fatalf("RouteAccount = %q, want otheracct (first mismatched account)", v.RouteAccount)
+ }
+}
+
+// TestMismatchAllRoutes guards the AllMismatched claim: only when every
+// non-platform route disagrees is "this app is not deploying" a sentence
+// the page can back.
+func TestMismatchAllRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "one.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ {Host: "two.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ {Host: "three.mismatchall.test", Kind: "instance", Status: "ready",
+ App: "mismatchall", Channel: "stable", Account: "otheracct"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchall"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 3 {
+ t.Fatalf("Mismatched = %+v, want all 3 routes", v.Mismatched)
+ }
+ if !v.AllMismatched {
+ t.Fatal("AllMismatched = false, want true (every route disagrees)")
+ }
+}
+
+// TestMismatchIgnoresPlatformRoutes guards the exclusion carried over from
+// the old routeAccount map: a platform-minted route stamped to another
+// account is reconciliation lag, not a stuck deploy, and must contribute
+// nothing to Mismatched/AllMismatched.
+func TestMismatchIgnoresPlatformRoutes(t *testing.T) {
+ regPath, store, _, pub := buildFixtures(t)
+ reg, err := registry.Open(regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "real.mismatchplatform.test", Kind: "instance", Status: "ready",
+ App: "mismatchplatform", Channel: "stable"},
+ {Host: "alias.mismatchplatform.test", Kind: "instance", Status: "ready",
+ App: "mismatchplatform", Channel: "stable", Account: "otheracct", Platform: true},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ views, err := Assemble(regPath, store, pub, release.OpsAccount, []string{"mismatchplatform"}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ v := views[0]
+ if len(v.Mismatched) != 0 {
+ t.Fatalf("Mismatched = %+v, want none (only route is platform-minted)", v.Mismatched)
+ }
+ if v.AllMismatched {
+ t.Fatal("AllMismatched = true, want false")
+ }
+ if v.AccountMismatch() {
+ t.Fatal("AccountMismatch() = true, want false")
+ }
+}
+
func TestVersionsNewestFirst(t *testing.T) {
_, store, _, _ := buildFixtures(t)
vs, err := Versions(store, release.OpsAccount, "kass")
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 0418d37d4..23fb23f62 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -133,6 +133,7 @@ import (
"os/exec"
"path"
"path/filepath"
+ "sort"
"strings"
"sync"
"time"
@@ -299,7 +300,9 @@ func RunAgent(args []string) {
go domainsLoop(reg, peekAcmeCache(args))
go instancesLoop(reg)
go retentionLoop(reg)
- go metricsLoop(cgm.Root())
+ // The sweep reads the registry only through this closure — the stamped
+ // bespoke units to sample beside the carlos- ones, re-read each tick.
+ go metricsLoop(cgm.Root(), func() []string { return stampedUnitNames(reg) })
go configSyncLoop(reg, svc, dd)
go configGenLoop(reg, svc, dd)
go socketAdvisoryLoop(reg, svc)
@@ -1519,6 +1522,39 @@ func retentionLoop(reg *registry.Registry) {
}
}
+// stampedUnitNames is the metrics sweep's one registry read: every distinct
+// bespoke unit stamped on a route (Route.Unit, the #110 column — an
+// operator's console.service, tito's titogo-canary@<tenant>.service), which
+// the box's cgroup walk cannot discover by name convention.
+//
+// Called once per 60s tick from metricsLoop's probe, so it is deliberately a
+// plain List + filter with no cache: the registry is a local SQLite file, one
+// read a minute is nothing next to the pass loops already reading it, and a
+// cache would be one more thing to invalidate when an operator stamps a unit.
+//
+// A read failure returns nil — this tick's stamped units are lost, the sweep
+// and the agent are not. The names are returned sorted so the slice handed
+// down each tick is stable (nothing below depends on the order; a stable one
+// just makes a log or a test diff mean something).
+func stampedUnitNames(reg *registry.Registry) []string {
+ routes, err := reg.List()
+ if err != nil {
+ log.Printf("agent: metrics: read stamped units: %v", err)
+ return nil
+ }
+ seen := make(map[string]bool, len(routes))
+ var out []string
+ for _, rt := range routes {
+ if rt.Unit == "" || seen[rt.Unit] {
+ continue
+ }
+ seen[rt.Unit] = true
+ out = append(out, rt.Unit)
+ }
+ sort.Strings(out)
+ return out
+}
+
// resolveStatusBucket applies the status/deployment bucket defaulting
// documented in this file's package comment: an explicit CARLOS_STATUS_BUCKET
// always wins, and when it's unset the status loop falls back to whatever
diff --git a/internal/host/agent_test.go b/internal/host/agent_test.go
index e0c4c1c8b..19783582a 100644
--- a/internal/host/agent_test.go
+++ b/internal/host/agent_test.go
@@ -1621,3 +1621,47 @@ func TestRetentionLoopIsAPlatformBoxJob(t *testing.T) {
type writerFunc func(p []byte) (int, error)
func (f writerFunc) Write(p []byte) (int, error) { return f(p) }
+// ---- stamped units for the metrics sweep (#110 column) ----------------------
+
+// TestStampedUnitNamesDedupesAndSkipsUnstamped pins the metrics sweep's one
+// registry read. Route.Unit is a per-ROUTE stamp, so two routes fronted by
+// the same bespoke service (a console and its alias) must yield that unit
+// ONCE — a duplicate would make the box read the same cgroup twice per tick
+// for the same map key. Routes with no unit contribute nothing: the carlos-
+// convention walk owns those, and a "" name would match every unnamed
+// directory the walk compares against.
+func TestStampedUnitNamesDedupesAndSkipsUnstamped(t *testing.T) {
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "console.example.com", Kind: "instance", App: "console", Unit: "console.service"},
+ {Host: "dashboard.example.com", Kind: "instance", App: "console", Unit: "console.service"},
+ {Host: "vicky.example.com", Kind: "instance", App: "titogo", Unit: "titogo-canary@vicky.service"},
+ {Host: "plain.example.com", Kind: "instance", App: "plain"}, // unstamped
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatalf("upsert %s: %v", rt.Host, err)
+ }
+ }
+
+ got := stampedUnitNames(reg)
+ want := []string{"console.service", "titogo-canary@vicky.service"}
+ if !slices.Equal(got, want) {
+ t.Fatalf("stampedUnitNames = %v, want %v (sorted, deduped, unstamped routes skipped)", got, want)
+ }
+}
+
+// TestStampedUnitNamesEmptyRegistry pins the state every box is in today:
+// nothing stamped -> nil, which listStampedUnits turns into no extra scan at
+// all. The sweep must cost nothing on a fleet that has never used the column.
+func TestStampedUnitNamesEmptyRegistry(t *testing.T) {
+ reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
+ if err != nil {
+ t.Fatalf("open registry: %v", err)
+ }
+ if got := stampedUnitNames(reg); got != nil {
+ t.Fatalf("stampedUnitNames on an empty registry = %v, want nil", got)
+ }
+}
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go
index 4427e393e..f3ffc1296 100644
--- a/internal/host/metrics_loop.go
+++ b/internal/host/metrics_loop.go
@@ -618,14 +618,27 @@ func httpPostWebhook(url string, body []byte) error {
// metrics_probes.go and probes.go — the production seam metricsLoop uses,
// never called from a test (host_test.go's convention: tests inject
// fixtures, never touch /proc or a syscall).
-func realSampleProbes(execRoot string) sampleProbes {
+//
+// stampedUnits is re-read inside the units probe rather than captured as a
+// slice, because sampleProbes is built ONCE and read every tick: a snapshot
+// taken here would pin the stamped set to whatever the registry held at agent
+// start, and a route stamped this afternoon would go unsampled until the next
+// restart. A nil func means "no stamped units" — the darwin/dev shape, and
+// what every test double passes.
+func realSampleProbes(execRoot string, stampedUnits func() []string) sampleProbes {
return sampleProbes{
procStat: readProcStat,
loadavg: readLoadAvg,
memFrac: readMemFrac,
diskFrac: readDiskFrac,
- units: func() map[string]unitRaw { return realUnitReadings(execRoot) },
- now: time.Now,
+ units: func() map[string]unitRaw {
+ var stamped []string
+ if stampedUnits != nil {
+ stamped = stampedUnits()
+ }
+ return realUnitReadings(execRoot, stamped)
+ },
+ now: time.Now,
}
}
@@ -633,11 +646,18 @@ func realSampleProbes(execRoot string) sampleProbes {
// each one's cgroup usage — the production units func sampleProbes.units
// wraps. Keyed by UNIT NAME (what the console matches against), read from the
// unit's own cgroup DIRECTORY (which for a tenant instance is a level down;
-// see listCarlosUnits) — plus, when delegation is armed, the per-host
-// exec-child cgroups under execRoot (keyed carlos-exec@<host>; see
-// listExecCgroups).
-func realUnitReadings(execRoot string) map[string]unitRaw {
+// see listCarlosUnits) — plus the operator-stamped bespoke units named in
+// stamped (registry Route.Unit; see listStampedUnits) and, when delegation is
+// armed, the per-host exec-child cgroups under execRoot (keyed
+// carlos-exec@<host>; see listExecCgroups).
+//
+// The three scans may overlap — a stamped unit that also matches the carlos-
+// convention is discovered twice — and the map write below is what dedupes
+// them: both entries name the same unit and the same cgroup dir, so the
+// second read is redundant, never contradictory.
+func realUnitReadings(execRoot string, stamped []string) map[string]unitRaw {
units := listCarlosUnits(cgroupSliceDir)
+ units = append(units, listStampedUnits(cgroupSliceDir, stamped)...)
units = append(units, listExecCgroups(execRoot)...)
if len(units) == 0 {
return nil
@@ -728,7 +748,13 @@ func metricsBoxLabel(getenv func(string) string, machineID string) string {
// optional loop in this package uses. Flush runs every 5th tick (5 minutes
// at the 60s tick cadence); alertTick runs every tick, since a threshold
// breach deserves faster detection than a metrics flush does.
-func metricsLoop(execRoot string) {
+//
+// stampedUnits is the sweep's one registry seam (agent.go's
+// stampedUnitNames): the bespoke units to sample alongside the carlos- ones,
+// re-read once per tick so a freshly stamped route is picked up within a
+// minute. It is a func, not the registry handle, so this loop keeps its "no
+// state but a bucket and /proc" shape; nil = none.
+func metricsLoop(execRoot string, stampedUnits func() []string) {
// This loop is always on and shares a process with the agent that serves
// this box's traffic, so a panic in it — an unreadable cgroup shape, a
// nil no one expected — must not take the box down with it. Monitoring
@@ -777,7 +803,7 @@ func metricsLoop(execRoot string) {
log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to %s", target)
sm := &sampler{}
- probes := realSampleProbes(execRoot)
+ probes := realSampleProbes(execRoot, stampedUnits)
tick := 0
for {
time.Sleep(60 * time.Second)
diff --git a/internal/host/metrics_probes.go b/internal/host/metrics_probes.go
index 30ec0a3bb..4a43dfcc8 100644
--- a/internal/host/metrics_probes.go
+++ b/internal/host/metrics_probes.go
@@ -4,7 +4,7 @@
// wrapping the pure parsers in internal/metrics — and the readers are
// deliberately untested by the same convention as probes.go readers;
// internal/metrics tests exercise the parsers with fixtures instead. The
-// exceptions are listCarlosUnits and listExecCgroups: they are directory
+// exceptions are listCarlosUnits, listStampedUnits and listExecCgroups: they are directory
// WALKs, not single reads, and which directories they walk is a decision that
// has already been wrong once (see listCarlosUnits' doc comment), so they take
// their roots as arguments and are pinned by metrics_probes_test.go against
@@ -115,6 +115,65 @@ func listCarlosUnits(root string) []unitCgroup {
return units
}
+// listStampedUnits finds the cgroup directories of operator-stamped bespoke
+// units — registry Route.Unit (#110), the routes the carlos- naming
+// convention above cannot see (an operator's console.service, tito's
+// titogo-canary@<tenant>.service). Read failure -> nil, same as
+// listCarlosUnits.
+//
+// Same two-level walk, for the same reason: a flat unit sits under root, a
+// template instance inside its implicit system-*.slice. The stamped NAME says
+// nothing about which of the two systemd chose, so both levels are matched
+// against the same wanted set.
+//
+// A stamped unit with no cgroup dir under root is simply not returned — no
+// reading is the honest report for a unit that isn't running, and the console
+// renders that absence rather than a guessed zero. Names arrive from the
+// registry already shaped by registry.ValidUnitName (the door validates; see
+// SetUnit), so nothing here re-validates them: an unmatchable name matches
+// nothing, which is the same outcome as a stopped unit.
+func listStampedUnits(root string, stamped []string) []unitCgroup {
+ if len(stamped) == 0 {
+ return nil
+ }
+ want := make(map[string]bool, len(stamped))
+ for _, u := range stamped {
+ want[u] = true
+ }
+
+ entries, err := os.ReadDir(root)
+ if err != nil {
+ return nil
+ }
+
+ var units []unitCgroup
+ for _, entry := range entries {
+ if !entry.IsDir() {
+ continue
+ }
+ name := entry.Name()
+ if want[name] {
+ units = append(units, unitCgroup{name: name, dir: filepath.Join(root, name)})
+ continue
+ }
+ if !strings.HasPrefix(name, "system-") || !strings.HasSuffix(name, ".slice") {
+ continue
+ }
+ sliceDir := filepath.Join(root, name)
+ nested, err := os.ReadDir(sliceDir)
+ if err != nil {
+ continue // an unreadable slice loses its units, never the whole scan
+ }
+ for _, child := range nested {
+ if !child.IsDir() || !want[child.Name()] {
+ continue
+ }
+ units = append(units, unitCgroup{name: child.Name(), dir: filepath.Join(sliceDir, child.Name())})
+ }
+ }
+ return units
+}
+
// isCarlosUnitName reports whether a cgroup directory name is one of ours:
// the "carlos-" prefix and the ".service" suffix, with at least one character
// between them.
diff --git a/internal/host/metrics_probes_test.go b/internal/host/metrics_probes_test.go
index 0e691d0ca..ee1eaa02f 100644
--- a/internal/host/metrics_probes_test.go
+++ b/internal/host/metrics_probes_test.go
@@ -54,6 +54,56 @@ func TestListCarlosUnitsFindsFlatAndNestedUnits(t *testing.T) {
}
}
+// TestListStampedUnits pins the second discovery walk: units named by the
+// registry's Unit column (#110) rather than by the carlos- convention. The
+// same two systemd levels are in play — a bespoke flat service, and a
+// template instance inside its implicit per-template slice — because the
+// stamped name says nothing about where systemd put its cgroup. A unit
+// present on disk but NOT stamped stays invisible (the convention walk owns
+// what it owns), and a stamped unit with no cgroup dir yields nothing at all:
+// absence is the honest report for a unit that isn't running.
+func TestListStampedUnits(t *testing.T) {
+ root := t.TempDir()
+ mk := func(parts ...string) string {
+ t.Helper()
+ dir := filepath.Join(append([]string{root}, parts...)...)
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ return dir
+ }
+
+ // Flat bespoke unit.
+ flatDir := mk("console.service")
+ // Template instance inside its implicit per-template slice. The literal
+ // directory name carries systemd's \x2d escaping of the dash.
+ nestedDir := mk(`system-titogo\x2dcanary.slice`, "titogo-canary@demo.service")
+ // Present on disk but NOT stamped — must not be returned.
+ mk("postgres.service")
+
+ got := listStampedUnits(root, []string{"console.service", "titogo-canary@demo.service", "absent@x.service"})
+ want := map[string]string{"console.service": flatDir, "titogo-canary@demo.service": nestedDir}
+ if len(got) != 2 {
+ t.Fatalf("got %d units, want 2 (the stamped ones that exist): %+v", len(got), got)
+ }
+ for _, u := range got {
+ dir, ok := want[u.name]
+ if !ok {
+ t.Fatalf("unexpected unit %q", u.name)
+ }
+ if u.dir != dir {
+ t.Fatalf("unit %q dir = %q, want %q (the real cgroup dir)", u.name, u.dir, dir)
+ }
+ if _, err := os.Stat(u.dir); err != nil {
+ t.Fatalf("dir %q not readable: %v", u.dir, err)
+ }
+ }
+
+ if out := listStampedUnits(root, nil); out != nil {
+ t.Fatalf("no stamped units must scan nothing, got %+v", out)
+ }
+}
+
// TestReadUnitRawReadsFromUnitDir pins that a unit's cgroup files are read
// from the directory listCarlosUnits reported for it — the nested case, since
// a reader that rebuilt the path as <root>/<unit> would find nothing there.