| 1 | // The app page's flow diagram: one picture answering "what is going on with |
| 2 | // this app" — a version you shipped, the channels it moves through, the |
| 3 | // addresses each channel feeds, and how each of those is doing. |
| 4 | // |
| 5 | // It exists because the ladder table answered the same question in prose that |
| 6 | // only made sense to someone who already knew the answer (the 2026-08-09 |
| 7 | // clarity critique: recognition-over-recall 1/4, minimalism 1/4). A picture |
| 8 | // of three boxes with an arrow between them says "promote moves it one step" |
| 9 | // without a sentence explaining what promote means. |
| 10 | // |
| 11 | // It is assembled from the app's channel pointers and the SAME tenant-filtered |
| 12 | // instance rows the Running tab renders — deliberately NOT from AppView.Routes |
| 13 | // or the ladder, both of which carry routes stamped to other accounts (apps |
| 14 | // are grouped by name across accounts; see assembleOverview's tenant note). |
| 15 | // Reusing the rows means the diagram cannot draw an address the Running tab |
| 16 | // does not, cannot disagree with it about health, and cannot leak a |
| 17 | // neighbouring tenant's host into this account's picture. |
| 18 | package console |
| 19 | |
| 20 | import ( |
| 21 | "sort" |
| 22 | "strings" |
| 23 | "time" |
| 24 | |
| 25 | "github.com/carlosframework/platform/internal/release" |
| 26 | ) |
| 27 | |
| 28 | // FlowView is the whole diagram: the release line every app has, plus the |
| 29 | // bands for channels that sit outside it. |
| 30 | type FlowView struct { |
| 31 | // Show is false when the app has published nothing and serves nothing — |
| 32 | // three empty boxes teach less than the page's own empty state. |
| 33 | Show bool |
| 34 | // Shipped is the newest version in the account's bucket for this app, |
| 35 | // which is where the line starts: it is the one thing on the diagram the |
| 36 | // owner did directly. |
| 37 | Shipped string |
| 38 | // ShippedAt is the build time, parsed from the manifest's RFC3339 stamp; |
| 39 | // zero when there is none or it will not parse, which renders as no date |
| 40 | // rather than as a raw timestamp nobody can read at a glance. |
| 41 | ShippedAt time.Time |
| 42 | // Line is the release path — edge, beta, live — ALWAYS all three, even |
| 43 | // when the later ones hold nothing. A path drawn only as far as the app |
| 44 | // has travelled reads as a path with no further to go. |
| 45 | Line []FlowStage |
| 46 | // Tests are the canary/* channels: named, disposable, no delay. |
| 47 | Tests []FlowStage |
| 48 | // Extra is any other channel an address actually follows. Nothing should |
| 49 | // normally land here — it is the band that keeps the diagram honest, so |
| 50 | // a route on an off-ladder channel is drawn rather than silently omitted |
| 51 | // from a picture that presents itself as the whole story. |
| 52 | Extra []FlowStage |
| 53 | } |
| 54 | |
| 55 | // FlowStage is one box on the diagram: a channel, what it holds, and the |
| 56 | // addresses following it. |
| 57 | type FlowStage struct { |
| 58 | // Name is the label on the box — the word an owner uses ("live"), not |
| 59 | // always the channel's name ("stable"); Channel is the real one. |
| 60 | Name string |
| 61 | Channel string |
| 62 | Version string |
| 63 | // PromotedAt is when this channel last moved; zero when it holds nothing. |
| 64 | PromotedAt time.Time |
| 65 | // Err is one line of bad news about this channel: a pointer that would |
| 66 | // not read, or a channel addresses follow that nothing was published to. |
| 67 | Err string |
| 68 | // Hold is the safety delay this channel imposes on arrival — "~24h" — |
| 69 | // and is empty both when the channel imposes none and when the app has |
| 70 | // the delay switched off. It is the general rule; BakedUntil is the |
| 71 | // live countdown for the version actually sitting here now. |
| 72 | Hold string |
| 73 | BakedUntil time.Time |
| 74 | FastTrack bool |
| 75 | // Carried is whether the version in the box before this one actually |
| 76 | // made it here. It is what the diagram draws as a solid arrow rather |
| 77 | // than a dashed one, and it is the whole reason the picture beats the |
| 78 | // table: "how far has my build got" becomes a thing you see rather than |
| 79 | // three version strings you compare by eye. |
| 80 | Carried bool |
| 81 | Addresses []FlowAddress |
| 82 | // RolledUp is true when this stage has more than flowAddrMax addresses: |
| 83 | // Addresses then holds only the needs-attention exceptions (capped at |
| 84 | // flowExceptionMax), and Total/Attention/Hidden/MoreURL carry the rest of |
| 85 | // the story instead of an unbounded list. |
| 86 | RolledUp bool |
| 87 | // Total is the full address count, only set when RolledUp. |
| 88 | Total int |
| 89 | // Attention is how many addresses need looking at (needsAttention), |
| 90 | // regardless of how many of them fit in Addresses. |
| 91 | Attention int |
| 92 | // Hidden is Attention minus the exceptions actually listed in |
| 93 | // Addresses — the "+N more" the template offers alongside MoreURL. |
| 94 | Hidden int |
| 95 | // MoreURL is where the rolled-up stage sends an owner for the full list: |
| 96 | // the Running tab, filtered to this app. |
| 97 | MoreURL string |
| 98 | } |
| 99 | |
| 100 | // FlowAddress is one address hanging off a stage, with its health. |
| 101 | type FlowAddress struct { |
| 102 | Host string |
| 103 | Health string // healthWord's plain word, as the Running tab shows it |
| 104 | Version string // what this address reports it is running, "" unknown |
| 105 | // Tone is how the health word should read: ok, idle, wait or bad. |
| 106 | // Asleep is idle rather than bad on purpose — hibernating is the system |
| 107 | // working, and colouring it as a problem trains owners to ignore the |
| 108 | // colour that means an actual problem. |
| 109 | Tone string |
| 110 | } |
| 111 | |
| 112 | // addressTone maps healthWord's vocabulary onto the four tones. An |
| 113 | // unrecognised word falls to idle: a health word nobody taught this function |
| 114 | // is missing information, and missing information must never render as |
| 115 | // either reassurance or alarm. "No readings" is named here rather than left |
| 116 | // to the default for the same reason "Asleep" is: it is deliberately idle, |
| 117 | // not a gap this function forgot to cover — a route the sweep was never told |
| 118 | // how to sample is honest ignorance, not a problem to alarm on. |
| 119 | func addressTone(health string) string { |
| 120 | switch health { |
| 121 | case "Running", "Serving": |
| 122 | return "ok" |
| 123 | case "Setting up": |
| 124 | return "wait" |
| 125 | case "Not responding", "Failed": |
| 126 | return "bad" |
| 127 | case "Asleep", "No readings": |
| 128 | return "idle" |
| 129 | default: |
| 130 | return "idle" |
| 131 | } |
| 132 | } |
| 133 | |
| 134 | // flowAddrMax is the most addresses a stage lists in full. Below it the |
| 135 | // diagram looks exactly as it always has; above it, a page anticipating |
| 136 | // hundreds of per-customer instances (2026-08-10 design) shows counts and |
| 137 | // problems, never an unbounded list. |
| 138 | const flowAddrMax = 8 |
| 139 | |
| 140 | // flowExceptionMax caps the needs-attention list inside a rolled-up stage; |
| 141 | // the Running tab is where the full story lives. |
| 142 | const flowExceptionMax = 5 |
| 143 | |
| 144 | // needsAttention is the rollup's filter: the words worth naming when there |
| 145 | // are too many addresses to name them all. Asleep and No readings are |
| 146 | // deliberately not here — counted, not listed (hibernation is the system |
| 147 | // working; no-readings is ignorance, not alarm). |
| 148 | func needsAttention(health string) bool { |
| 149 | switch health { |
| 150 | case "Not responding", "Failed", "Setting up": |
| 151 | return true |
| 152 | } |
| 153 | return false |
| 154 | } |
| 155 | |
| 156 | // flowLineEntry is one box's name/channel pair on the release line — the |
| 157 | // legacy frozen three (flowLine below), or a pipelined app's own declared |
| 158 | // channels, each named for itself (assembleFlow builds that list from |
| 159 | // view.Pipeline.Channels). |
| 160 | type flowLineEntry struct{ name, channel string } |
| 161 | |
| 162 | // flowLine is the LEGACY release path, in the order versions travel it, |
| 163 | // with the label each box carries. "live" rather than "stable" is |
| 164 | // deliberate and matches the Versions tab's strip: stable is the channel's |
| 165 | // name, live is what it means. Used only when view.Pipeline is nil — |
| 166 | // legacy-path-only, exactly as the ladder card and the CLI's promote gate |
| 167 | // already are (spec §7). |
| 168 | var flowLine = []flowLineEntry{ |
| 169 | {"edge", "edge"}, |
| 170 | {"beta", "beta"}, |
| 171 | {"live", release.RungStable}, |
| 172 | } |
| 173 | |
| 174 | // assembleFlow folds the app's channels and its tenant-filtered instance rows |
| 175 | // into the diagram. latest/latestAt are the newest shipped version and its |
| 176 | // build time ("" when nothing has been shipped). instancesPath is the app's |
| 177 | // Running tab URL, used as MoreURL on any stage that rolls up. |
| 178 | // |
| 179 | // Pipeline-aware (Task 9 review, the CRITICAL finding): a one-channel |
| 180 | // pipeline hides this diagram entirely, same as the ladder card and the |
| 181 | // promote form (spec §5 — "the app page shows a Releases list and a live |
| 182 | // marker", not a picture of a ladder that does not exist); a multi-channel |
| 183 | // pipeline draws its OWN declared channels, in order, rather than the |
| 184 | // frozen edge/beta/live boxes a pipelined app may not even have. Legacy |
| 185 | // (view.Pipeline == nil) is byte-identical to before this awareness existed. |
| 186 | func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView { |
| 187 | if view.OneChannelPipeline() { |
| 188 | return FlowView{} |
| 189 | } |
| 190 | f := FlowView{Shipped: latest} |
| 191 | if t, err := time.Parse(time.RFC3339, latestAt); err == nil { |
| 192 | f.ShippedAt = t |
| 193 | } |
| 194 | |
| 195 | // Rows arrive in route order and stay in it: the Running tab lists these |
| 196 | // same hosts in the same order, and two orderings of one list is exactly |
| 197 | // the kind of small friction that makes a picture feel untrustworthy. |
| 198 | byChannel := map[string][]FlowAddress{} |
| 199 | for _, r := range rows { |
| 200 | byChannel[r.Channel] = append(byChannel[r.Channel], FlowAddress{ |
| 201 | Host: r.Host, |
| 202 | Health: r.HealthWord, |
| 203 | Version: r.Version, |
| 204 | Tone: addressTone(r.HealthWord), |
| 205 | }) |
| 206 | } |
| 207 | chans := make(map[string]ChannelView, len(view.Channels)) |
| 208 | for _, c := range view.Channels { |
| 209 | chans[c.Name] = c |
| 210 | } |
| 211 | |
| 212 | // line is the release path this app actually has, and holdFor is how |
| 213 | // each box on it resolves its general "waits ~24h here" line — the two |
| 214 | // things that differ between a legacy app and a pipelined one. Neither |
| 215 | // duplicates the ladder card's own logic: line is read straight off |
| 216 | // view.Pipeline.Channels (the same order assemblePipelineLadder reads), |
| 217 | // and holdFor calls the SAME holdWindow resolver the ladder card and the |
| 218 | // promote/fast-track gates already share — this is the one new call |
| 219 | // site, not a second implementation. |
| 220 | var line []flowLineEntry |
| 221 | var holdFor func(channel string) time.Duration |
| 222 | if view.Pipeline != nil { |
| 223 | line = make([]flowLineEntry, len(view.Pipeline.Channels)) |
| 224 | for i, c := range view.Pipeline.Channels { |
| 225 | line[i] = flowLineEntry{c.Name, c.Name} |
| 226 | } |
| 227 | holdFor = func(channel string) time.Duration { return holdWindow(view.Pipeline, channel) } |
| 228 | } else { |
| 229 | line = flowLine |
| 230 | // An unreadable production flag counts as ON, because that is what |
| 231 | // the boxes do with it (they keep baking) — the diagram must not |
| 232 | // tell an owner their delay is off while every box is still |
| 233 | // holding. Legacy-path-only: a pipelined app's bake policy comes |
| 234 | // solely from its own pipeline and never consults this flag at all |
| 235 | // (spec §3) — holdFor above already reflects that. |
| 236 | held := view.Production.On || view.Production.Err != "" |
| 237 | holdFor = func(channel string) time.Duration { |
| 238 | if !held { |
| 239 | return 0 |
| 240 | } |
| 241 | return release.HoldFor(release.DefaultHolds, channel) |
| 242 | } |
| 243 | } |
| 244 | |
| 245 | stage := func(name, channel string) FlowStage { |
| 246 | c := chans[channel] |
| 247 | s := FlowStage{ |
| 248 | Name: name, Channel: channel, |
| 249 | Version: c.Version, PromotedAt: c.PromotedAt, Err: c.Err, |
| 250 | BakedUntil: c.BakedUntil, FastTrack: c.FastTrack, |
| 251 | Addresses: byChannel[channel], |
| 252 | } |
| 253 | if d := holdFor(channel); d > 0 { |
| 254 | s.Hold = holdText(d) |
| 255 | } |
| 256 | // A channel with addresses but nothing ever published to it is the |
| 257 | // silent failure the ladder was built to surface: hosts serving |
| 258 | // nothing, waiting for a version that never came. |
| 259 | if s.Err == "" && s.Version == "" && len(s.Addresses) > 0 { |
| 260 | s.Err = "nothing published here yet — these addresses are waiting for a version" |
| 261 | } |
| 262 | if addrs := s.Addresses; len(addrs) > flowAddrMax { |
| 263 | s.RolledUp = true |
| 264 | s.Total = len(addrs) |
| 265 | s.MoreURL = instancesPath |
| 266 | var exceptions []FlowAddress |
| 267 | for _, a := range addrs { |
| 268 | if !needsAttention(a.Health) { |
| 269 | continue |
| 270 | } |
| 271 | s.Attention++ |
| 272 | if len(exceptions) < flowExceptionMax { |
| 273 | exceptions = append(exceptions, a) |
| 274 | } |
| 275 | } |
| 276 | s.Hidden = s.Attention - len(exceptions) |
| 277 | s.Addresses = exceptions |
| 278 | } |
| 279 | return s |
| 280 | } |
| 281 | |
| 282 | // The line, then the arrows between its boxes. A step counts as carried |
| 283 | // only when the box before it actually holds something: two empty boxes |
| 284 | // share a version string ("") without any version having travelled |
| 285 | // between them, and drawing that as a solid arrow would be the diagram's |
| 286 | // one chance to lie outright. |
| 287 | for _, ls := range line { |
| 288 | f.Line = append(f.Line, stage(ls.name, ls.channel)) |
| 289 | } |
| 290 | prev := latest |
| 291 | for i := range f.Line { |
| 292 | f.Line[i].Carried = prev != "" && f.Line[i].Version == prev |
| 293 | prev = f.Line[i].Version |
| 294 | } |
| 295 | |
| 296 | // Canaries from their pointers first, then any canary channel an address |
| 297 | // follows that has no pointer at all. |
| 298 | drawn := map[string]bool{} |
| 299 | for _, ls := range line { |
| 300 | drawn[ls.channel] = true |
| 301 | } |
| 302 | for _, c := range view.Channels { |
| 303 | if !strings.HasPrefix(c.Name, "canary/") || drawn[c.Name] { |
| 304 | continue |
| 305 | } |
| 306 | drawn[c.Name] = true |
| 307 | f.Tests = append(f.Tests, stage(strings.TrimPrefix(c.Name, "canary/"), c.Name)) |
| 308 | } |
| 309 | for ch := range byChannel { |
| 310 | if drawn[ch] { |
| 311 | continue |
| 312 | } |
| 313 | drawn[ch] = true |
| 314 | if strings.HasPrefix(ch, "canary/") { |
| 315 | f.Tests = append(f.Tests, stage(strings.TrimPrefix(ch, "canary/"), ch)) |
| 316 | continue |
| 317 | } |
| 318 | f.Extra = append(f.Extra, stage(ch, ch)) |
| 319 | } |
| 320 | // Map iteration ordered nothing above, and a diagram that reshuffles |
| 321 | // between two refreshes of the same page is one nobody trusts. |
| 322 | sortStages := func(s []FlowStage) { |
| 323 | sort.Slice(s, func(a, b int) bool { return s[a].Channel < s[b].Channel }) |
| 324 | } |
| 325 | sortStages(f.Tests) |
| 326 | sortStages(f.Extra) |
| 327 | |
| 328 | for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| 329 | for _, s := range band { |
| 330 | if s.Version != "" || len(s.Addresses) > 0 { |
| 331 | f.Show = true |
| 332 | } |
| 333 | } |
| 334 | } |
| 335 | return f |
| 336 | } |
| 337 | |