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>
5 files changed,
+273
−2
internal/console/listquery.go+151 −0internal/console/listquery_test.go+72 −0internal/console/overview_test.go+13 −0internal/console/server.go+21 −2internal/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}}&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}} |