console: add instance details and channel grouping
15 files changed,
+579
−46
.gitignore+2 −0internal/console/apiinstances.go+3 −0internal/console/apiinstances_test.go+8 −0internal/console/icons.go+2 −0internal/console/instancecopy.go+28 −0internal/console/instancepage.go+160 −0internal/console/instancepage_test.go+193 −0internal/console/listquery.go+58 −17internal/console/listquery_test.go+27 −0internal/console/metricsview.go+9 −6internal/console/server.go+19 −1internal/console/server_test.go+3 −3internal/console/templates/app_instance.html+22 −0internal/console/templates/app_instances.html+35 −19internal/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 = © |
| + 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&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&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}}&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 |