carlos / platform Public

console: add instance details and channel grouping

Paul Campbell pushed by paul@keymail.dev e543e47929848b0324b61a6642b364b571aa7add parent dcf0cad
15 files changed, +579 −46
  • .gitignore +2 −0
  • internal/console/apiinstances.go +3 −0
  • internal/console/apiinstances_test.go +8 −0
  • internal/console/icons.go +2 −0
  • internal/console/instancecopy.go +28 −0
  • internal/console/instancepage.go +160 −0
  • internal/console/instancepage_test.go +193 −0
  • internal/console/listquery.go +58 −17
  • internal/console/listquery_test.go +27 −0
  • internal/console/metricsview.go +9 −6
  • internal/console/server.go +19 −1
  • internal/console/server_test.go +3 −3
  • internal/console/templates/app_instance.html +22 −0
  • internal/console/templates/app_instances.html +35 −19
  • internal/console/templates/layout.html +10 −0

CI passed — run details

diff --git a/.gitignore b/.gitignore
index 42d71f76e..183f87420 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,3 +9,5 @@ platform
# directory in the checkout is that step's cache — see the step for why.
.amadan-cache/
infra/modules/**/.terraform.lock.hcl
+
+writing/
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index de186a440..67f60854b 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -410,6 +410,9 @@ func (s *Server) handleAPIInstanceList(w http.ResponseWriter, r *http.Request) {
if q.Q != "" && !strings.Contains(rec.Host, q.Q) {
continue
}
+ if q.Channel != "" && rec.Channel != q.Channel {
+ continue
+ }
if q.Health != "" && h != q.Health {
continue
}
diff --git a/internal/console/apiinstances_test.go b/internal/console/apiinstances_test.go
index 0f9a38a78..9b3981c63 100644
--- a/internal/console/apiinstances_test.go
+++ b/internal/console/apiinstances_test.go
@@ -852,6 +852,14 @@ func TestAPIInstanceListHealthAndFilters(t *testing.T) {
if got := hostsOf(unknown); !slices.Equal(got, order) {
t.Errorf("health=nonsense = %v, want everything — an unknown word is no filter", got)
}
+ stable, _ := get(t, "?channel=stable")
+ if got := hostsOf(stable); !slices.Equal(got, order) || stable.Total != len(order) {
+ t.Errorf("channel=stable = %v total %d, want all stable records", got, stable.Total)
+ }
+ missing, _ := get(t, "?channel=retired")
+ if got := hostsOf(missing); len(got) != 0 || missing.Total != 0 {
+ t.Errorf("channel=retired widened to %v total %d", got, missing.Total)
+ }
// 5) ADDITIVE: the OLD client's shape still parses, and every field a
// record already had is byte-identical. The keys the wire gained are
diff --git a/internal/console/icons.go b/internal/console/icons.go
index ff349519f..2306ff106 100644
--- a/internal/console/icons.go
+++ b/internal/console/icons.go
@@ -50,6 +50,7 @@ import "html/template"
// a review in this repo has already flagged template.HTML on bucket-derived
// text as a Critical finding, and that judgement stands.
const (
+ iconExternalLink template.HTML = iconOpen + `<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M21 14v5a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5"/></svg>`
// iconOpen is the shared opening tag: no width or height, so the icon
// sizes from the CSS .icon rule (1em) and tracks the text beside it.
iconOpen = `<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" ` +
@@ -112,6 +113,7 @@ const (
// icons maps each Lucide slug to its vendored markup. Slugs are the Lucide
// names so the mapping back to lucide.dev stays obvious.
var icons = map[string]template.HTML{
+ "external-link": iconExternalLink,
"chevron-down": iconChevronDown,
"check": iconCheck,
"plus": iconPlus,
diff --git a/internal/console/instancecopy.go b/internal/console/instancecopy.go
new file mode 100644
index 000000000..364f1e13b
--- /dev/null
+++ b/internal/console/instancecopy.go
@@ -0,0 +1,28 @@
+package console
+
+type instanceCopyStrings struct {
+ AllChannels, GroupChannel, Open, Observed, Target, LastReading, NoReadings, PendingVersion string
+}
+
+var instanceCopy = instanceCopyStrings{
+ AllChannels: "All channels",
+ GroupChannel: "Group by channel",
+ Open: "Open instance",
+ Observed: "Observed version",
+ Target: "Target version",
+ LastReading: "Last reading",
+ NoReadings: "The platform operator needs to enable health reporting for this instance.",
+ PendingVersion: "The target version has not been reported yet.",
+}
+
+func (p page) InstanceCopy() instanceCopyStrings { return instanceCopy }
+func (p page) InstanceQuerySuffix() string { return instanceQuerySuffix(p.InstanceQuery) }
+func (p page) InstanceAllURL() string {
+ q := p.InstanceQuery
+ q.Health, q.Cursor = "", ""
+ suffix := instanceQuerySuffix(q)
+ if suffix == "" {
+ return "?"
+ }
+ return suffix
+}
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
new file mode 100644
index 000000000..e454687eb
--- /dev/null
+++ b/internal/console/instancepage.go
@@ -0,0 +1,160 @@
+package console
+
+import (
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+type instanceDetail struct {
+ Row InstanceRow
+ TargetVersion string
+ TargetErr string
+ Aliases []string
+}
+
+type instanceGroup struct {
+ Channel string
+ TargetVersion string
+ TargetErr string
+ Total int
+ Rows []InstanceRow
+}
+
+func ownedInstanceRoutes(routes []RouteView, account string) []RouteView {
+ var out []RouteView
+ for _, rt := range routes {
+ if rt.Account == account {
+ out = append(out, rt)
+ }
+ }
+ return out
+}
+
+func instanceChannels(rows []InstanceRow) []string {
+ seen := map[string]bool{}
+ for _, row := range rows {
+ seen[row.Channel] = true
+ }
+ out := make([]string, 0, len(seen))
+ for channel := range seen {
+ out = append(out, channel)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func groupInstanceRows(rows []InstanceRow, channels []ChannelView, all []InstanceRow) []instanceGroup {
+ targets := map[string]ChannelView{}
+ for _, ch := range channels {
+ targets[ch.Name] = ch
+ }
+ counts := map[string]int{}
+ for _, row := range all {
+ counts[row.Channel]++
+ }
+ grouped := map[string][]InstanceRow{}
+ for _, row := range rows {
+ grouped[row.Channel] = append(grouped[row.Channel], row)
+ }
+ names := instanceChannels(rows)
+ out := make([]instanceGroup, 0, len(names))
+ for _, name := range names {
+ ch := targets[name]
+ out = append(out, instanceGroup{Channel: name, TargetVersion: ch.Version, TargetErr: ch.Err, Total: counts[name], Rows: grouped[name]})
+ }
+ return out
+}
+
+func instanceQuerySuffix(q instanceQuery) string {
+ v := url.Values{}
+ if q.Q != "" {
+ v.Set("q", q.Q)
+ }
+ if q.Health != "" {
+ v.Set("health", healthSlug(q.Health))
+ }
+ if q.Channel != "" {
+ v.Set("channel", q.Channel)
+ }
+ if q.Group {
+ v.Set("group", "channel")
+ }
+ if q.Cursor != "" {
+ v.Set("cursor", q.Cursor)
+ }
+ if len(v) == 0 {
+ return ""
+ }
+ return "?" + v.Encode()
+}
+
+func encodePageCursor(row InstanceRow, grouped bool) string {
+ if !grouped {
+ return encodeInstanceCursor(row.Host)
+ }
+ b, _ := json.Marshal(instanceCursor{Host: row.Host, Channel: row.Channel, Grouped: true})
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+func decodePageCursor(raw string, grouped bool) (instanceCursor, bool) {
+ b, err := base64.RawURLEncoding.DecodeString(raw)
+ var cur instanceCursor
+ if err != nil || json.Unmarshal(b, &cur) != nil || cur.Host == "" || cur.Grouped != grouped {
+ return cur, false
+ }
+ return cur, true
+}
+
+func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "instances")
+ if !ok {
+ return
+ }
+ host := r.PathValue("host")
+ if !provision.ValidHostShape(host) {
+ http.NotFound(w, r)
+ return
+ }
+ var selected *RouteView
+ for _, rt := range p.Detail.Routes {
+ if rt.Host == host && rt.Account == acct.Sqid {
+ copy := rt
+ selected = &copy
+ break
+ }
+ }
+ if selected == nil {
+ http.NotFound(w, r)
+ return
+ }
+ view := *p.Detail
+ view.Routes = []RouteView{*selected}
+ metrics := s.appResourceMetrics(r.Context(), &view, acct)
+ rows := assembleInstanceRows(&view, acct, metrics)
+ detail := &instanceDetail{Row: rows[0]}
+ for _, ch := range p.Detail.Channels {
+ if ch.Name == selected.Channel {
+ detail.TargetVersion, detail.TargetErr = ch.Version, ch.Err
+ break
+ }
+ }
+ if reg, err := openRegistry(s.cfg.Registry); err == nil {
+ if aliases, err := reg.ListAliases(); err == nil {
+ for _, alias := range aliases {
+ if alias.Host == host && alias.Account == acct.Sqid && alias.App == p.AppName {
+ detail.Aliases = append(detail.Aliases, alias.Alias)
+ }
+ }
+ sort.Strings(detail.Aliases)
+ }
+ }
+ p.InstanceDetail = detail
+ p.InstanceQuery = parseInstanceQuery(r.URL.Query())
+ p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(p.InstanceQuery)
+ s.render(w, http.StatusOK, "app_instance", p)
+}
diff --git a/internal/console/instancepage_test.go b/internal/console/instancepage_test.go
new file mode 100644
index 000000000..62b50e354
--- /dev/null
+++ b/internal/console/instancepage_test.go
@@ -0,0 +1,193 @@
+package console
+
+import (
+ "github.com/carlosframework/platform/internal/registry"
+ "net/http"
+ "net/url"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestInstanceGroupingPaginatesWithoutLoss(t *testing.T) {
+ rows := []InstanceRow{
+ {Host: "a.example.com", Channel: "stable", HealthWord: "Running"},
+ {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"},
+ {Host: "c.example.com", Channel: "stable", HealthWord: "Running"},
+ {Host: "d.example.com", Channel: "edge", HealthWord: "Running"},
+ }
+ q := instanceQuery{Group: true}
+ var got []string
+ for i := 0; i < 4; i++ {
+ p := pageInstanceRows(rows, q, 1)
+ for _, r := range p.Rows {
+ got = append(got, r.Host)
+ }
+ if p.NextCursor == "" {
+ break
+ }
+ q.Cursor = p.NextCursor
+ u, err := url.Parse(p.NextURL)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if u.Query().Get("group") != "channel" {
+ t.Fatal(p.NextURL)
+ }
+ }
+ want := []string{"b.example.com", "d.example.com", "a.example.com", "c.example.com"}
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("got %v want %v", got, want)
+ }
+ p := pageInstanceRows(rows, instanceQuery{Cursor: q.Cursor}, 50)
+ if len(p.Rows) != 4 {
+ t.Fatal("changing grouping must reset a cursor from the other order")
+ }
+}
+
+func TestInstanceChannelFilterAndLinks(t *testing.T) {
+ rows := []InstanceRow{{Host: "a.example.com", Channel: "canary/a+b", HealthWord: "Running"}, {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"}}
+ q := instanceQuery{Channel: "canary/a+b", Group: true}
+ p := pageInstanceRows(rows, q, 50)
+ if p.Total != 1 || p.Rows[0].Host != "a.example.com" {
+ t.Fatalf("%+v", p)
+ }
+ for _, l := range p.Links {
+ u, e := url.Parse(l.URL)
+ if e != nil {
+ t.Fatal(e)
+ }
+ if u.Query().Get("channel") != q.Channel || u.Query().Get("group") != "channel" {
+ t.Fatal(l.URL)
+ }
+ }
+ q.Channel = "missing"
+ if p := pageInstanceRows(rows, q, 50); p.Total != 0 {
+ t.Fatalf("unmatched channel widened: %+v", p)
+ }
+}
+
+func TestInstanceGroupsKeepTargetSeparate(t *testing.T) {
+ rows := []InstanceRow{{Host: "a.example.com", Channel: "edge", Version: "old"}, {Host: "b.example.com", Channel: "edge", Version: "new"}}
+ groups := groupInstanceRows(rows[:1], []ChannelView{{Name: "edge", Version: "new"}}, rows)
+ if len(groups) != 1 || groups[0].Total != 2 || groups[0].TargetVersion != "new" || groups[0].Rows[0].Version != "old" {
+ t.Fatalf("%+v", groups)
+ }
+}
+
+func TestInstanceGroupCountsRespectActiveFilters(t *testing.T) {
+ rows := []InstanceRow{
+ {Host: "a.example.com", Channel: "edge", HealthWord: "Running"},
+ {Host: "b.example.com", Channel: "edge", HealthWord: "Asleep"},
+ {Host: "c.example.com", Channel: "stable", HealthWord: "Running"},
+ }
+ p := pageInstanceRows(rows, instanceQuery{Group: true, Channel: "edge", Health: "Running"}, 1)
+ groups := groupInstanceRows(p.Rows, nil, p.Matching)
+ if len(groups) != 1 || groups[0].Channel != "edge" || groups[0].Total != 1 || len(groups[0].Rows) != 1 {
+ t.Fatalf("groups = %+v; page = %+v", groups, p)
+ }
+ if p.Counts["Running"] != 2 || p.Counts["Asleep"] != 1 {
+ t.Fatalf("global health counts changed with filters: %+v", p.Counts)
+ }
+}
+
+func TestOwnedInstanceRoutesDoesNotPassOtherAccountsToSelfReport(t *testing.T) {
+ routes := []RouteView{{Host: "mine.example.com", Account: "bab"}, {Host: "theirs.example.com", Account: "bac"}}
+ got := ownedInstanceRoutes(routes, "bab")
+ if len(got) != 1 || got[0].Host != "mine.example.com" {
+ t.Fatalf("%+v", got)
+ }
+}
+
+func TestInstanceDetailOwnsRouteAndAliases(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ reg, err := openRegistry(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.Upsert(registry.Route{Host: "foreign.example.com", App: "kass", Account: "bac", Kind: "instance", Version: "foreign-version"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "alias.example.com", Host: "kass.carlosapp.dev", Account: fx.sqid, App: "kass"}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.UpsertAlias(registry.RouteAlias{Alias: "foreign-alias.example.com", Host: "kass.carlosapp.dev", Account: "bac", App: "kass"}); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev?channel=stable&group=channel"))
+ if status != http.StatusOK {
+ t.Fatalf("%d: %s", status, body)
+ }
+ for _, want := range []string{"kass.carlosapp.dev", "alias.example.com", `/instances?channel=stable&amp;group=channel`, `href="https://kass.carlosapp.dev"`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q", want)
+ }
+ }
+ for _, bad := range []string{"foreign-version", "foreign-alias.example.com", "stamp its unit"} {
+ if strings.Contains(body, bad) {
+ t.Errorf("leaked %q", bad)
+ }
+ }
+ for _, host := range []string{"foreign.example.com", "otherapp.carlosapp.dev", "missing.example.com"} {
+ status, _, body := get(t, c, fx.u("/apps/kass/instances/"+host))
+ if status != http.StatusNotFound || body != "404 page not found\n" {
+ t.Fatalf("%s = %d %q", host, status, body)
+ }
+ }
+}
+
+func TestInstanceListLinksToDetailAndKeepsHealthShort(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances?group=channel&channel=stable"))
+ if status != http.StatusOK {
+ t.Fatalf("%d %s", status, body)
+ }
+ if !strings.Contains(body, `/instances/kass.carlosapp.dev?channel=stable&amp;group=channel`) {
+ t.Fatal("missing filtered detail link")
+ }
+ if !strings.Contains(body, `href="https://kass.carlosapp.dev"`) {
+ t.Fatal("missing separate external link")
+ }
+ if strings.Contains(body, "stamp its unit") {
+ t.Fatal("health cell still carries infrastructure instructions")
+ }
+ if !strings.Contains(body, `class="instance-group"`) {
+ t.Fatal("missing grouped table")
+ }
+}
+
+func TestInstanceListKeepsUnknownSelectedChannelVisible(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances?channel=retired"))
+ if status != http.StatusOK {
+ t.Fatalf("%d %s", status, body)
+ }
+ if !strings.Contains(body, `<option value="retired" selected>retired</option>`) {
+ t.Fatalf("selected unknown channel is invisible: %s", body)
+ }
+ if strings.Contains(body, `<option value="" selected>`) {
+ t.Fatal("unknown channel made the selector appear unfiltered")
+ }
+}
+
+func TestInstanceDetailRendersFlashBeforeNavigation(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ u, err := url.Parse(fx.u("/apps/kass/instances/kass.carlosapp.dev"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ c.Jar.SetCookies(u, []*http.Cookie{{Name: flashCookie, Value: url.QueryEscape("err|flash marker"), Path: "/"}})
+ status, _, body := get(t, c, u.String())
+ if status != http.StatusOK {
+ t.Fatalf("%d %s", status, body)
+ }
+ flash := strings.Index(body, `<p class="banner error">flash marker</p>`)
+ nav := strings.Index(body, `<nav class="tabs"`)
+ if flash < 0 || nav < 0 || flash > nav {
+ t.Fatalf("flash=%d nav=%d", flash, nav)
+ }
+}
diff --git a/internal/console/listquery.go b/internal/console/listquery.go
index f5d072ef9..a9c8ead15 100644
--- a/internal/console/listquery.go
+++ b/internal/console/listquery.go
@@ -10,6 +10,7 @@ import (
"encoding/base64"
"encoding/json"
"net/url"
+ "sort"
"strings"
)
@@ -24,6 +25,9 @@ var healthWords = []string{"Running", "Serving", "Setting up", "Asleep", "No rea
// instanceQuery is one request's list controls. Zero value = everything,
// first page.
type instanceQuery struct {
+ Channel string
+ Group bool
+
Q string // substring match on host, trimmed
Health string // canonical health word ("" = all)
Cursor string // opaque; list resumes after the host inside it
@@ -31,9 +35,11 @@ type instanceQuery struct {
func parseInstanceQuery(v url.Values) instanceQuery {
return instanceQuery{
- Q: strings.TrimSpace(v.Get("q")),
- Health: healthFromSlug(v.Get("health")),
- Cursor: v.Get("cursor"),
+ Channel: strings.TrimSpace(v.Get("channel")),
+ Group: v.Get("group") == "channel",
+ Q: strings.TrimSpace(v.Get("q")),
+ Health: healthFromSlug(v.Get("health")),
+ Cursor: v.Get("cursor"),
}
}
@@ -43,6 +49,28 @@ func healthSlug(word string) string {
return strings.ReplaceAll(strings.ToLower(word), " ", "-")
}
+func healthTone(word string) string {
+ switch word {
+ case "Running", "Serving":
+ return "ok"
+ case "Failed", "Not responding":
+ return "attention"
+ case "Asleep", "Setting up":
+ return "notdeployed"
+ default:
+ return "unknown"
+ }
+}
+
+func hasInstanceChannel(channels []string, selected string) bool {
+ for _, channel := range channels {
+ if channel == selected {
+ return true
+ }
+ }
+ return false
+}
+
// 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 {
@@ -55,6 +83,9 @@ func healthFromSlug(slug string) string {
}
type instanceCursor struct {
+ Channel string `json:"channel,omitempty"`
+ Grouped bool `json:"grouped,omitempty"`
+
Host string `json:"host"`
}
@@ -83,14 +114,16 @@ type healthLink struct {
Word, Slug string
Count int
Active bool
+ URL string
}
// instancePage is one screen of the list: the rows to render plus the
// numbers and links the header states.
type instancePage struct {
Rows []InstanceRow
+ Matching []InstanceRow // rows matching Q+Health+Channel, across all pages
Counts map[string]int // health word -> count over ALL rows
- Total int // rows matching Q+Health, across all pages
+ Total int // rows matching Q+Health+Channel, 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
@@ -107,16 +140,28 @@ func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePa
if q.Q != "" && !strings.Contains(r.Host, q.Q) {
continue
}
+ if q.Channel != "" && r.Channel != q.Channel {
+ continue
+ }
if q.Health != "" && r.HealthWord != q.Health {
continue
}
matched = append(matched, r)
}
+ if q.Group {
+ sort.SliceStable(matched, func(i, j int) bool {
+ if matched[i].Channel != matched[j].Channel {
+ return matched[i].Channel < matched[j].Channel
+ }
+ return matched[i].Host < matched[j].Host
+ })
+ }
p.Total = len(matched)
+ p.Matching = matched
start := 0
- if after, ok := decodeInstanceCursor(q.Cursor); ok {
+ if after, ok := decodePageCursor(q.Cursor, q.Group); ok {
for i, r := range matched {
- if r.Host > after {
+ if (!q.Group && r.Host > after.Host) || (q.Group && (r.Channel > after.Channel || (r.Channel == after.Channel && r.Host > after.Host))) {
start = i
break
}
@@ -125,7 +170,7 @@ func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePa
}
end := start + limit
if end < len(matched) {
- p.NextCursor = encodeInstanceCursor(matched[end-1].Host)
+ p.NextCursor = encodePageCursor(matched[end-1], q.Group)
} else {
end = len(matched)
}
@@ -134,18 +179,14 @@ func pageInstanceRows(rows []InstanceRow, q instanceQuery, limit int) instancePa
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})
+ linkQuery := q
+ linkQuery.Health, linkQuery.Cursor = w, ""
+ p.Links = append(p.Links, healthLink{Word: w, Slug: healthSlug(w), Count: p.Counts[w], Active: q.Health == w, URL: instanceQuerySuffix(linkQuery)})
}
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()
+ next := q
+ next.Cursor = p.NextCursor
+ p.NextURL = instanceQuerySuffix(next)
}
return p
}
diff --git a/internal/console/listquery_test.go b/internal/console/listquery_test.go
index 0d283902f..32b245e2f 100644
--- a/internal/console/listquery_test.go
+++ b/internal/console/listquery_test.go
@@ -24,6 +24,20 @@ func TestHealthSlugRoundTrips(t *testing.T) {
}
}
+func TestHealthToneUsesExistingChipStyles(t *testing.T) {
+ tests := map[string]string{
+ "Running": "ok", "Serving": "ok",
+ "Failed": "attention", "Not responding": "attention",
+ "Asleep": "notdeployed", "Setting up": "notdeployed",
+ "No readings": "unknown",
+ }
+ for word, want := range tests {
+ if got := healthTone(word); got != want {
+ t.Errorf("healthTone(%q) = %q, want %q", word, got, want)
+ }
+ }
+}
+
func TestPageFiltersAndCounts(t *testing.T) {
rows := rowsFor("a.x", "ab.x", "b.x")
rows[2].HealthWord = "Asleep"
@@ -64,6 +78,19 @@ func TestPageGarbageCursorStartsOver(t *testing.T) {
}
}
+func TestPageAcceptsLegacyCursorAndPastLastIsEmpty(t *testing.T) {
+ rows := rowsFor("a.x", "b.x")
+ legacy := encodeInstanceCursor("a.x")
+ p := pageInstanceRows(rows, instanceQuery{Cursor: legacy}, 50)
+ if len(p.Rows) != 1 || p.Rows[0].Host != "b.x" {
+ t.Fatalf("legacy cursor: %+v", p)
+ }
+ p = pageInstanceRows(rows, instanceQuery{Cursor: encodeInstanceCursor("z.x")}, 50)
+ if len(p.Rows) != 0 || p.NextCursor != "" {
+ t.Fatalf("past-last cursor: %+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" {
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
index 0c2f5e488..7f1c70307 100644
--- a/internal/console/metricsview.go
+++ b/internal/console/metricsview.go
@@ -668,6 +668,8 @@ func unitMemSeries(samples []metrics.Sample, unit string) []*float64 {
// host has moved); the app page must not print it, because which box a
// tenant sits on is not an owner's business.
type InstanceMetrics struct {
+ ObservedAt time.Time
+
Host string // the route host — how owners name their instances
Box string
CPUFrac *float64
@@ -766,12 +768,13 @@ func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string, unitByHost
}
at[host] = box.At
found[host] = InstanceMetrics{
- Host: host,
- Box: box.Box,
- CPUFrac: u.CPUFrac,
- MemBytes: u.MemBytes,
- SparkCPU: u.SparkCPU,
- SparkMem: u.SparkMem,
+ Host: host,
+ ObservedAt: box.At,
+ Box: box.Box,
+ CPUFrac: u.CPUFrac,
+ MemBytes: u.MemBytes,
+ SparkCPU: u.SparkCPU,
+ SparkMem: u.SparkMem,
}
}
}
diff --git a/internal/console/server.go b/internal/console/server.go
index 449b0ab52..95ae94b90 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -119,6 +119,13 @@ var templates = template.Must(template.New("").Funcs(template.FuncMap{
// 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,
+ "healthTone": healthTone,
+ "instanceRowPage": func(p page, row InstanceRow) any {
+ return struct {
+ Page page
+ Row InstanceRow
+ }{p, row}
+ },
// logLines: the Logs tab's window folded into the terminal pane's lines
// (logsview.go). The padding is computed across the whole window, so it
// has to be one call over all the records, not one per row.
@@ -1106,6 +1113,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
// The Errors tab and its two setting doors (errorsview.go): the tab is
// member-readable like every other app tab; the level is the owner's,
@@ -1288,6 +1296,12 @@ func normalizeHost(host string) string {
// page is the one bag every template reads from; unused fields are ignored.
type page struct {
+ InstanceDetail *instanceDetail
+ InstanceGroups []instanceGroup
+ InstanceChannels []string
+ InstanceUnknownChannel bool
+ InstanceListURL string
+
Title string
Address string // signed-in address, "" when signed out
CSRF string // the session token, double-submitted on the signout POST
@@ -2782,7 +2796,7 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ p.Instances = s.instances.Report(r.Context(), ownedInstanceRoutes(p.Detail.Routes, acct.Sqid))
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
q := parseInstanceQuery(r.URL.Query())
rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics)
@@ -2790,6 +2804,10 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p.InstanceRows = pg.Rows
p.InstancePage = pg
p.InstanceQuery = q
+ p.InstanceChannels = instanceChannels(rows)
+ p.InstanceUnknownChannel = q.Channel != "" && !hasInstanceChannel(p.InstanceChannels, q.Channel)
+ p.InstanceGroups = groupInstanceRows(pg.Rows, p.Detail.Channels, pg.Matching)
+ p.InstanceListURL = p.Nav + "/apps/" + p.AppName + "/instances" + instanceQuerySuffix(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.
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index df744afe2..d67a229a1 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -1298,7 +1298,7 @@ func TestAppPageInstances(t *testing.T) {
Status: "ready", App: "kass"})
_, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
- "<h2>Running</h2>", "Reported by the app itself", "tracks its own internal workers",
+ "<h2>Instances</h2>", "Reported by the app itself", "tracks its own internal workers",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
@@ -2173,9 +2173,9 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
// the same "just the per-route metrics chrome" scope, at its new address.
func resourcesSection(t *testing.T, body string) string {
t.Helper()
- i := strings.Index(body, "<h2>Running</h2>")
+ i := strings.Index(body, "<h2>Instances</h2>")
if i < 0 {
- t.Fatalf("no Running section in:\n%s", body)
+ t.Fatalf("no Instances section in:\n%s", body)
}
j := strings.Index(body[i:], "</main>")
if j < 0 {
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
new file mode 100644
index 000000000..11d699189
--- /dev/null
+++ b/internal/console/templates/app_instance.html
@@ -0,0 +1,22 @@
+{{define "app_instance"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+<p><a href="{{.InstanceListURL}}">Instances</a></p>
+{{with .InstanceDetail}}
+<h2 class="instance-address">{{.Row.Host}} <a class="instance-open" href="https://{{.Row.Host}}" aria-label="{{$.InstanceCopy.Open}} {{.Row.Host}}">{{icon "external-link"}}</a></h2>
+<dl class="instance-facts">
+ <dt>Health</dt><dd>{{.Row.HealthWord}}</dd>
+ <dt>{{$.InstanceCopy.LastReading}}</dt><dd>{{if .Row.Metrics.ObservedAt.IsZero}}—{{else}}<time title="{{fmtTime .Row.Metrics.ObservedAt}}">{{ago .Row.Metrics.ObservedAt}}</time>{{end}}</dd>
+ <dt>Channel</dt><dd><code>{{.Row.Channel}}</code></dd>
+ <dt>{{$.InstanceCopy.Observed}}</dt><dd>{{if .Row.Version}}<code>{{.Row.Version}}</code>{{else}}—{{end}}</dd>
+ <dt>{{$.InstanceCopy.Target}}</dt><dd>{{if .TargetErr}}Can't tell right now{{else if .TargetVersion}}<code>{{.TargetVersion}}</code>{{else}}—{{end}}</dd>
+ <dt>CPU</dt><dd>{{pct .Row.Metrics.CPUFrac}}{{if .Row.Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkCPU}}"/></svg>{{end}}</dd>
+ <dt>Memory</dt><dd>{{bytesOrDash .Row.Metrics.MemBytes}}{{if .Row.Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkMem}}"/></svg>{{end}}</dd>
+ {{if .Aliases}}<dt>Web addresses</dt><dd>{{range .Aliases}}<div><a href="https://{{.}}">{{.}}</a></div>{{end}}</dd>{{end}}
+</dl>
+{{if eq .Row.HealthWord "No readings"}}<p class="small">{{$.InstanceCopy.NoReadings}}</p>{{end}}
+{{if eq .Row.HealthWord "Asleep"}}<p class="small">wakes on the next request</p>{{end}}
+{{if and .Row.Version .TargetVersion (not .TargetErr) (ne .Row.Version .TargetVersion)}}<p class="small">{{$.InstanceCopy.PendingVersion}}</p>{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index 92df7251c..f212f47d7 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -3,37 +3,40 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-<h2>Running</h2>
-{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health}}
+<h2>Instances</h2>
+{{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health .InstanceQuery.Channel}}
<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}}
+ <label class="visually-hidden" for="instance-channel">Channel</label>
+ <select id="instance-channel" name="channel">
+ <option value=""{{if not .InstanceQuery.Channel}} selected{{end}}>{{.InstanceCopy.AllChannels}}</option>
+ {{if .InstanceUnknownChannel}}<option value="{{.InstanceQuery.Channel}}" selected>{{.InstanceQuery.Channel}}</option>{{end}}
+ {{range .InstanceChannels}}<option value="{{.}}"{{if eq . $.InstanceQuery.Channel}} selected{{end}}>{{.}}</option>{{end}}
+ </select>
+ <label><input type="checkbox" name="group" value="channel"{{if .InstanceQuery.Group}} checked{{end}}> {{.InstanceCopy.GroupChannel}}</label>
<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>
+ <a href="{{.InstanceAllURL}}"{{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>
+ · <a href="{{.URL}}"{{if .Active}} aria-current="true"{{end}}>{{.Word}} {{.Count}}</a>
{{end}}
</p>
{{end}}
{{if .InstanceRows}}
<div class="tablewrap">
<table class="instances">
- <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr>
- {{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}}{{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}}
- <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td>
- <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td>
- {{else}}
- <td colspan="2"><span class="small">no data</span></td>
- {{end}}
- </tr>
+ <thead><tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr></thead>
+ {{if .InstanceQuery.Group}}
+ {{range .InstanceGroups}}
+ <tbody>
+ <tr class="instance-group"><th colspan="6" scope="rowgroup"><code>{{.Channel}}</code> <span class="small">{{$.InstanceCopy.Target}}: {{if .TargetErr}}—{{else if .TargetVersion}}<code>{{.TargetVersion}}</code>{{else}}—{{end}} · Instances: {{len .Rows}} / {{.Total}}</span></th></tr>
+ {{range .Rows}}{{template "instance_row" (instanceRowPage $ .)}}{{end}}
+ </tbody>
+ {{end}}
+ {{else}}
+ <tbody>{{range .InstanceRows}}{{template "instance_row" (instanceRowPage $ .)}}{{end}}</tbody>
{{end}}
</table>
</div>
@@ -41,7 +44,7 @@
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}}
+{{else if or .InstanceQuery.Q .InstanceQuery.Health .InstanceQuery.Channel}}
<p>No addresses match.</p>
{{else}}
<p>Nothing running yet. Publish a version, then give it a web address:</p>
@@ -90,3 +93,16 @@ hibernated instance state stays in the old placement's bucket.</p>
</details>
{{end}}
{{template "bottom" .}}{{end}}
+
+{{define "instance_row"}}
+<tr>
+ <td class="instance-address"><a href="{{.Page.Nav}}/apps/{{.Page.AppName}}/instances/{{.Row.Host}}{{.Page.InstanceQuerySuffix}}">{{.Row.Host}}</a> <a class="instance-open" href="https://{{.Row.Host}}" aria-label="{{.Page.InstanceCopy.Open}} {{.Row.Host}}">{{icon "external-link"}}</a></td>
+ <td><span class="chip chip-health-{{healthTone .Row.HealthWord}}">{{.Row.HealthWord}}</span></td>
+ <td>{{if .Row.Version}}<code>{{.Row.Version}}</code>{{else}}—{{end}}</td>
+ <td><code>{{.Row.Channel}}</code></td>
+ {{if .Row.Metrics.Reporting}}
+ <td><span class="cell-value">{{pct .Row.Metrics.CPUFrac}}</span>{{if .Row.Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkCPU}}"/></svg>{{end}}</td>
+ <td><span class="cell-value">{{bytesOrDash .Row.Metrics.MemBytes}}</span>{{if .Row.Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkMem}}"/></svg>{{end}}</td>
+ {{else}}<td colspan="2"><span class="small">no data</span></td>{{end}}
+</tr>
+{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 6f872f1b8..35eba307f 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -144,6 +144,16 @@
padding: 0.6rem 0.9rem; margin: 0 0 1.5rem; max-width: 40rem;
}
.tablewrap { overflow-x: auto; }
+ .instances .instance-address { white-space: nowrap; }
+ .instances td:nth-child(2) { white-space: nowrap; }
+ .instance-open { display: inline-flex; padding: .25rem; }
+ .instance-group th { text-align: left; padding-top: 1rem; background: var(--accent-soft); }
+ .instance-group .small { font-weight: normal; }
+ .small a[aria-current="true"] { color: var(--ink); font-weight: 700; text-decoration-thickness: 3px; }
+ .instance-facts { display: grid; grid-template-columns: minmax(8rem, auto) 1fr; gap: .5rem 1.5rem; margin: 1rem 0; }
+ .instance-facts dt { color: var(--muted); }
+ .instance-facts dd { margin: 0; overflow-wrap: anywhere; }
+ .instance-facts .cellspark { max-width: 24rem; }
/* The Logs tab (logsview.go): the pane reads as the terminal it mirrors —
the same line `carlos logs` prints, monospaced, one line per record, no
wrapping. A table wrapped every message across three rows and put the