carlos / platform Public

console: flow diagram rolls up stages above 8 addresses

Stages with more than flowAddrMax (8) addresses no longer list every
host: they show the total count, a needs-attention exception list
capped at flowExceptionMax (5), and a link to the Running tab for the
rest. needsAttention flags Not responding/Failed/Setting up only —
Asleep and No readings are counted but never listed as exceptions,
matching the canonical health vocabulary.

assembleFlow gains an instancesPath parameter, used as each rolled-up
stage's MoreURL; handleApp's call site passes the app's Running tab
URL. Stages at or below the threshold render exactly as before —
verified by TestFlowSmallStagesUnchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell b8cd473d78b852a804ccf2952a497be113952ba9 parent 9c1fc76
4 files changed, +166 −18
  • internal/console/flow.go +58 −2
  • internal/console/flow_test.go +94 −15
  • internal/console/server.go +1 −1
  • internal/console/templates/flow.html +13 −0
diff --git a/internal/console/flow.go b/internal/console/flow.go
index b0591a8da..c667ee980 100644
--- a/internal/console/flow.go
+++ b/internal/console/flow.go
@@ -79,6 +79,22 @@ type FlowStage struct {
// three version strings you compare by eye.
Carried bool
Addresses []FlowAddress
+ // RolledUp is true when this stage has more than flowAddrMax addresses:
+ // Addresses then holds only the needs-attention exceptions (capped at
+ // flowExceptionMax), and Total/Attention/Hidden/MoreURL carry the rest of
+ // the story instead of an unbounded list.
+ RolledUp bool
+ // Total is the full address count, only set when RolledUp.
+ Total int
+ // Attention is how many addresses need looking at (needsAttention),
+ // regardless of how many of them fit in Addresses.
+ Attention int
+ // Hidden is Attention minus the exceptions actually listed in
+ // Addresses — the "+N more" the template offers alongside MoreURL.
+ Hidden int
+ // MoreURL is where the rolled-up stage sends an owner for the full list:
+ // the Running tab, filtered to this app.
+ MoreURL string
}
// FlowAddress is one address hanging off a stage, with its health.
@@ -115,6 +131,28 @@ func addressTone(health string) string {
}
}
+// flowAddrMax is the most addresses a stage lists in full. Below it the
+// diagram looks exactly as it always has; above it, a page anticipating
+// hundreds of per-customer instances (2026-08-10 design) shows counts and
+// problems, never an unbounded list.
+const flowAddrMax = 8
+
+// flowExceptionMax caps the needs-attention list inside a rolled-up stage;
+// the Running tab is where the full story lives.
+const flowExceptionMax = 5
+
+// needsAttention is the rollup's filter: the words worth naming when there
+// are too many addresses to name them all. Asleep and No readings are
+// deliberately not here — counted, not listed (hibernation is the system
+// working; no-readings is ignorance, not alarm).
+func needsAttention(health string) bool {
+ switch health {
+ case "Not responding", "Failed", "Setting up":
+ return true
+ }
+ return false
+}
+
// flowLine is the release path, in the order versions travel it, with the
// label each box carries. "live" rather than "stable" is deliberate and
// matches the Versions tab's strip: stable is the channel's name, live is
@@ -127,8 +165,9 @@ var flowLine = []struct{ name, channel string }{
// assembleFlow folds the app's channels and its tenant-filtered instance rows
// into the diagram. latest/latestAt are the newest shipped version and its
-// build time ("" when nothing has been shipped).
-func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) FlowView {
+// build time ("" when nothing has been shipped). instancesPath is the app's
+// Running tab URL, used as MoreURL on any stage that rolls up.
+func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView {
f := FlowView{Shipped: latest}
if t, err := time.Parse(time.RFC3339, latestAt); err == nil {
f.ShippedAt = t
@@ -174,6 +213,23 @@ func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) Fl
if s.Err == "" && s.Version == "" && len(s.Addresses) > 0 {
s.Err = "nothing published here yet — these addresses are waiting for a version"
}
+ if addrs := s.Addresses; len(addrs) > flowAddrMax {
+ s.RolledUp = true
+ s.Total = len(addrs)
+ s.MoreURL = instancesPath
+ var exceptions []FlowAddress
+ for _, a := range addrs {
+ if !needsAttention(a.Health) {
+ continue
+ }
+ s.Attention++
+ if len(exceptions) < flowExceptionMax {
+ exceptions = append(exceptions, a)
+ }
+ }
+ s.Hidden = s.Attention - len(exceptions)
+ s.Addresses = exceptions
+ }
return s
}
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go
index 987f99832..f5f7c7586 100644
--- a/internal/console/flow_test.go
+++ b/internal/console/flow_test.go
@@ -1,6 +1,9 @@
package console
-import "testing"
+import (
+ "fmt"
+ "testing"
+)
// stageByChannel finds one stage across every band, so the tests can assert
// about a channel without caring which band the assembler filed it under.
@@ -20,7 +23,7 @@ func stageByChannel(f FlowView, channel string) *FlowStage {
// missing reads as "there is no later step", which is the opposite of true.
func TestFlowLineIsAlwaysTheWholePath(t *testing.T) {
v := &AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}
- f := assembleFlow(v, nil, "a1", "")
+ f := assembleFlow(v, nil, "a1", "", "/a/x/apps/app/instances")
if len(f.Line) != 3 {
t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line)
@@ -50,7 +53,7 @@ func TestFlowAddressesAttachToTheirChannel(t *testing.T) {
{Host: "www.kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"},
{Host: "rc.kass.dev", Channel: "canary/rc", Version: "b2", HealthWord: "Asleep"},
}
- f := assembleFlow(v, rows, "b2", "")
+ f := assembleFlow(v, rows, "b2", "", "/a/x/apps/app/instances")
live := stageByChannel(f, "stable")
if live == nil || len(live.Addresses) != 2 {
@@ -81,7 +84,7 @@ func TestFlowDrawsEveryRunningAddress(t *testing.T) {
{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"},
{Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"},
}
- f := assembleFlow(v, rows, "a1", "")
+ f := assembleFlow(v, rows, "a1", "", "/a/x/apps/app/instances")
for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} {
found := false
@@ -109,7 +112,7 @@ func TestFlowDrawsEveryRunningAddress(t *testing.T) {
func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
chans := []ChannelView{{Name: "edge", Version: "a1"}, {Name: "beta", Version: "a1"}, {Name: "stable", Version: "a1"}}
- on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "")
+ on := assembleFlow(&AppView{Channels: chans, Production: ProductionView{On: true}}, nil, "a1", "", "/a/x/apps/app/instances")
if got := stageByChannel(on, "beta").Hold; got != "~24h" {
t.Errorf("beta hold is %q, want ~24h", got)
}
@@ -120,7 +123,7 @@ func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
t.Errorf("edge holds nothing back but says %q", got)
}
- off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "")
+ off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "", "/a/x/apps/app/instances")
for _, s := range off.Line {
if s.Hold != "" {
t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold)
@@ -128,7 +131,7 @@ func TestFlowShowsTheSafetyDelayOnlyWhenItApplies(t *testing.T) {
}
// An unreadable flag is treated as on, because that is what the boxes do.
- bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "")
+ bad := assembleFlow(&AppView{Channels: chans, Production: ProductionView{Err: "unverifiable"}}, nil, "a1", "", "/a/x/apps/app/instances")
if stageByChannel(bad, "stable").Hold == "" {
t.Error("an unreadable production flag must still show the delay — boxes keep baking")
}
@@ -148,7 +151,7 @@ func TestFlowTonesAddressHealth(t *testing.T) {
{Host: "g.dev", Channel: "stable", HealthWord: "Something new"},
{Host: "h.dev", Channel: "stable", HealthWord: "No readings"},
}
- f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "")
+ f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "", "/a/x/apps/app/instances")
want := map[string]string{
"a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad",
@@ -175,7 +178,7 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
{Name: "edge", Version: "a1"},
{Name: "beta", Version: "a1"},
{Name: "stable", Version: "old"},
- }}, nil, "a1", "")
+ }}, nil, "a1", "", "/a/x/apps/app/instances")
want := []bool{true, true, false}
for i, s := range f.Line {
if s.Carried != want[i] {
@@ -185,13 +188,13 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
// Two empty boxes in a row share the empty string, and no version has
// travelled between them — the arrow must stay dashed.
- empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "")
+ empty := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances")
if empty.Line[1].Carried || empty.Line[2].Carried {
t.Errorf("empty boxes must not chain a solid arrow: %+v", empty.Line)
}
// Nothing shipped at all: the very first arrow has nothing to carry.
- none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "")
+ none := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "", "", "/a/x/apps/app/instances")
if none.Line[0].Carried {
t.Error("no shipped version — the arrow into edge cannot be solid")
}
@@ -202,7 +205,7 @@ func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) {
// box instead.
func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) {
rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}}
- f := assembleFlow(&AppView{}, rows, "", "")
+ f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances")
rc := stageByChannel(f, "canary/rc")
if rc == nil {
@@ -216,14 +219,90 @@ func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) {
// Nothing shipped and nothing running is not a diagram — it is an empty
// state, and the page has better words for that than three blank boxes.
func TestFlowHidesItselfWhenThereIsNothingToDraw(t *testing.T) {
- if f := assembleFlow(&AppView{}, nil, "", ""); f.Show {
+ if f := assembleFlow(&AppView{}, nil, "", "", "/a/x/apps/app/instances"); f.Show {
t.Error("an app with no channels and no addresses should not draw a diagram")
}
- if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", ""); !f.Show {
+ if f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "edge", Version: "a1"}}}, nil, "a1", "", "/a/x/apps/app/instances"); !f.Show {
t.Error("a shipped version on edge is worth drawing")
}
rows := []InstanceRow{{Host: "kass.dev", Channel: "stable", HealthWord: "Running"}}
- if f := assembleFlow(&AppView{}, rows, "", ""); !f.Show {
+ if f := assembleFlow(&AppView{}, rows, "", "", "/a/x/apps/app/instances"); !f.Show {
t.Error("a running address is worth drawing even with no readable pointer")
}
}
+
+// The rollup exists for stages that anticipate hundreds of addresses; a
+// stage still inside the normal range must render exactly as it always has,
+// with no rollup fields engaged at all.
+func TestFlowSmallStagesUnchanged(t *testing.T) {
+ rows := make([]InstanceRow, 0, flowAddrMax)
+ for i := 0; i < flowAddrMax; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: "Running"})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0]
+ if st.RolledUp {
+ t.Fatalf("%d addresses must not roll up: %+v", flowAddrMax, st)
+ }
+ if len(st.Addresses) != flowAddrMax {
+ t.Fatalf("all %d addresses should be listed, got %d: %+v", flowAddrMax, len(st.Addresses), st.Addresses)
+ }
+ if st.Total != 0 || st.Attention != 0 || st.Hidden != 0 || st.MoreURL != "" {
+ t.Fatalf("rollup fields must stay zero when not rolled up: %+v", st)
+ }
+}
+
+// The needs-attention list inside a rolled-up stage is capped so the diagram
+// never grows unbounded again by another route — the Running tab is where
+// the rest of the story lives, via MoreURL.
+func TestFlowRollupCapsExceptions(t *testing.T) {
+ rows := make([]InstanceRow, 0, 20)
+ for i := 0; i < 20; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ w := "Running"
+ if i < 9 {
+ w = "Not responding"
+ }
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0]
+ if !st.RolledUp || st.Attention != 9 {
+ t.Fatalf("rollup: %+v", st)
+ }
+ if len(st.Addresses) != flowExceptionMax {
+ t.Fatalf("exceptions capped at %d, got %d: %+v", flowExceptionMax, len(st.Addresses), st.Addresses)
+ }
+ if st.Hidden != 4 {
+ t.Fatalf("hidden should be 9 attention - 5 shown = 4, got %d", st.Hidden)
+ }
+}
+
+// The exact test from the task brief, kept verbatim for full alignment with
+// the interface it specifies.
+func TestFlowRollsUpBigStages(t *testing.T) {
+ rows := make([]InstanceRow, 0, 12)
+ for i := 0; i < 12; i++ {
+ h := fmt.Sprintf("i%02d.example.test", i)
+ w := "Running"
+ if i == 3 || i == 7 {
+ w = "Not responding"
+ }
+ rows = append(rows, InstanceRow{Host: h, Channel: "edge", HealthWord: w})
+ }
+ view := &AppView{Channels: []ChannelView{{Name: "edge", Version: "v1"}}}
+ f := assembleFlow(view, rows, "v1", "", "/a/x/apps/app/instances")
+ st := f.Line[0] // edge
+ if !st.RolledUp || st.Total != 12 || st.Attention != 2 || st.Hidden != 0 {
+ t.Fatalf("rollup: %+v", st)
+ }
+ if len(st.Addresses) != 2 || st.Addresses[0].Health != "Not responding" {
+ t.Fatalf("exceptions: %+v", st.Addresses)
+ }
+ if st.MoreURL != "/a/x/apps/app/instances" {
+ t.Fatalf("MoreURL: %q", st.MoreURL)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 07bb67c3c..562a5aa5b 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1630,7 +1630,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
if len(versions) > 0 {
latest, latestAt = versions[0].Version, versions[0].BuiltAt
}
- p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt)
+ p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt, p.Nav+"/apps/"+p.AppName+"/instances")
if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
log.Printf("console: events %s: %v", app, err)
} else {
diff --git a/internal/console/templates/flow.html b/internal/console/templates/flow.html
index 0cdb1dac1..0596ec050 100644
--- a/internal/console/templates/flow.html
+++ b/internal/console/templates/flow.html
@@ -28,6 +28,8 @@
<p class="flow-version">{{if .Version}}<code>{{.Version}}</code>{{else}}nothing{{end}}</p>
{{if .Hold}}<p class="flow-note">{{if .FastTrack}}delay skipped{{else}}waits {{.Hold}} here{{end}}</p>{{end}}
{{if .Err}}<p class="flow-note error">{{.Err}}</p>{{end}}
+ {{if .RolledUp}}
+ <p class="flow-note">{{.Total}} addresses{{if .Attention}} · <span class="error">{{.Attention}} need{{if eq .Attention 1}}s{{end}} attention</span>{{end}}</p>
{{if .Addresses}}
<ul class="flow-addrs">
{{range .Addresses}}
@@ -38,5 +40,16 @@
{{end}}
</ul>
{{end}}
+ <p class="flow-note"><a href="{{.MoreURL}}">{{if .Hidden}}+{{.Hidden}} more · {{end}}all addresses</a></p>
+ {{else}}{{if .Addresses}}
+ <ul class="flow-addrs">
+ {{range .Addresses}}
+ <li class="flow-addr flow-tone-{{.Tone}}">
+ <a href="https://{{.Host}}">{{.Host}}</a>
+ <span class="flow-health">{{if eq .Tone "bad"}}{{icon "triangle-alert"}}{{end}}{{.Health}}</span>
+ </li>
+ {{end}}
+ </ul>
+ {{end}}{{end}}
</li>
{{end}}