Merge origin/main into fleet-api (post-#144 catch-up)
32 files changed,
+3255
−153
apimode.go+37 −9apimode_test.go+41 −0deploy.go+1 −1docs/superpowers/plans/2026-08-11-dashboard-truth-and-scale.md+1328 −0docs/superpowers/specs/2026-08-10-dashboard-truth-and-scale-design.md+145 −0instances_cli.go+25 −4internal/console/apiinstances.go+168 −5internal/console/apiinstances_test.go+225 −0internal/console/appsettings.go+46 −11internal/console/appsettings_test.go+57 −0internal/console/flow.go+64 −3internal/console/flow_test.go+98 −15internal/console/listquery.go+151 −0internal/console/listquery_test.go+72 −0internal/console/metricsview.go+16 −5internal/console/metricsview_test.go+61 −6internal/console/overview.go+47 −17internal/console/overview_test.go+136 −18internal/console/server.go+27 −4internal/console/server_test.go+16 −12internal/console/templates/app_instances.html+17 −1internal/console/templates/app_overview.html+17 −6internal/console/templates/app_releases.html+16 −3internal/console/templates/flow.html+13 −0internal/console/templates/layout.html+4 −1internal/console/view.go+53 −21internal/console/view_test.go+148 −0internal/host/agent.go+37 −1internal/host/agent_test.go+44 −0internal/host/metrics_loop.go+35 −9internal/host/metrics_probes.go+60 −1internal/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 & 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}}&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}}&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 & 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. |