cli: instances list -match/-health, server-side, with HEALTH column
apiSession.instanceList gains match/health params (sent as q/health query params) and a total return — the server-side filters and count Task 9 added to the console's /instances endpoint. apiInstanceRecord gains Health. apiPath learns to join a query string it's handed with one it's adding (?exact=sqid) via & instead of a second ?, which -account-id plus any filter would otherwise have produced. runInstances list gains -match/-health flags, a HEALTH column (em-dash for an old console with no health field), and a '(showing X of Y matches)' line when a filter is active and the console reports a total bigger than the page — 0 from a pre-Task-9 console is treated as unknown, not zero. deploy.go's own instanceList call passes empty match/health (unfiltered, as before).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+104
−14
apimode.go+37 −9apimode_test.go+41 −0deploy.go+1 −1instances_cli.go+25 −4
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/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) { |