carlos / platform Public

console: Running tab gains search, health filter and cursor pagination

- internal/console/listquery.go: shared instanceQuery/instancePage
  machinery (parseInstanceQuery, pageInstanceRows, health slugs, opaque
  cursor) — Task 9's API reuses it verbatim.
- handleAppInstances now parses q/health/cursor, pages the app's rows,
  and carries InstancePage/InstanceQuery on the page struct. The
  attach-onboarding empty state keys off the app's UNFILTERED row
  count, not the filtered page — a filter matching nothing renders
  'No addresses match.' instead of onboarding copy.
- app_instances.html: filter form + health-count links above the
  table, a cursor pager below it, and the filtered-empty state.
  healthSlug registered in the template FuncMap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell d32797b86b97f299321a8b13945b10f511f53152 parent b8cd473
5 files changed, +273 −2
  • internal/console/listquery.go +151 −0
  • internal/console/listquery_test.go +72 −0
  • internal/console/overview_test.go +13 −0
  • internal/console/server.go +21 −2
  • internal/console/templates/app_instances.html +16 −0
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/overview_test.go b/internal/console/overview_test.go
index d60cadbe7..02ab3834c 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -513,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
diff --git a/internal/console/server.go b/internal/console/server.go
index 562a5aa5b..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.
@@ -1725,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.
@@ -1736,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/templates/app_instances.html b/internal/console/templates/app_instances.html
index 24222c9b4..92df7251c 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -4,6 +4,19 @@
{{template "appnav" .}}
<h2>Running</h2>
+{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health}}
+<form method="get" class="listctl">
+ <input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses">
+ {{if .InstanceQuery.Health}}<input type="hidden" name="health" value="{{healthSlug .InstanceQuery.Health}}">{{end}}
+ <button>Filter</button>
+</form>
+<p class="small">
+ <a href="?{{if .InstanceQuery.Q}}q={{.InstanceQuery.Q}}{{end}}"{{if not .InstanceQuery.Health}} aria-current="true"{{end}}>All</a>
+ {{range .InstancePage.Links}}
+ · <a href="?health={{.Slug}}{{if $.InstanceQuery.Q}}&amp;q={{$.InstanceQuery.Q}}{{end}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a>
+ {{end}}
+</p>
+{{end}}
{{if .InstanceRows}}
<div class="tablewrap">
<table class="instances">
@@ -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}}