// The app page's flow diagram: one picture answering "what is going on with // this app" — a version you shipped, the channels it moves through, the // addresses each channel feeds, and how each of those is doing. // // It exists because the ladder table answered the same question in prose that // only made sense to someone who already knew the answer (the 2026-08-09 // clarity critique: recognition-over-recall 1/4, minimalism 1/4). A picture // of three boxes with an arrow between them says "promote moves it one step" // without a sentence explaining what promote means. // // It is assembled from the app's channel pointers and the SAME tenant-filtered // instance rows the Running tab renders — deliberately NOT from AppView.Routes // or the ladder, both of which carry routes stamped to other accounts (apps // are grouped by name across accounts; see assembleOverview's tenant note). // Reusing the rows means the diagram cannot draw an address the Running tab // does not, cannot disagree with it about health, and cannot leak a // neighbouring tenant's host into this account's picture. package console import ( "sort" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // FlowView is the whole diagram: the release line every app has, plus the // bands for channels that sit outside it. type FlowView struct { // Show is false when the app has published nothing and serves nothing — // three empty boxes teach less than the page's own empty state. Show bool // Shipped is the newest version in the account's bucket for this app, // which is where the line starts: it is the one thing on the diagram the // owner did directly. Shipped string // ShippedAt is the build time, parsed from the manifest's RFC3339 stamp; // zero when there is none or it will not parse, which renders as no date // rather than as a raw timestamp nobody can read at a glance. ShippedAt time.Time // Line is the release path — edge, beta, live — ALWAYS all three, even // when the later ones hold nothing. A path drawn only as far as the app // has travelled reads as a path with no further to go. Line []FlowStage // Tests are the canary/* channels: named, disposable, no delay. Tests []FlowStage // Extra is any other channel an address actually follows. Nothing should // normally land here — it is the band that keeps the diagram honest, so // a route on an off-ladder channel is drawn rather than silently omitted // from a picture that presents itself as the whole story. Extra []FlowStage } // FlowStage is one box on the diagram: a channel, what it holds, and the // addresses following it. type FlowStage struct { // Name is the label on the box — the word an owner uses ("live"), not // always the channel's name ("stable"); Channel is the real one. Name string Channel string Version string // PromotedAt is when this channel last moved; zero when it holds nothing. PromotedAt time.Time // Err is one line of bad news about this channel: a pointer that would // not read, or a channel addresses follow that nothing was published to. Err string // Hold is the safety delay this channel imposes on arrival — "~24h" — // and is empty both when the channel imposes none and when the app has // the delay switched off. It is the general rule; BakedUntil is the // live countdown for the version actually sitting here now. Hold string BakedUntil time.Time FastTrack bool // Carried is whether the version in the box before this one actually // made it here. It is what the diagram draws as a solid arrow rather // than a dashed one, and it is the whole reason the picture beats the // table: "how far has my build got" becomes a thing you see rather than // 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. type FlowAddress struct { Host string Health string // healthWord's plain word, as the Running tab shows it Version string // what this address reports it is running, "" unknown // Tone is how the health word should read: ok, idle, wait or bad. // Asleep is idle rather than bad on purpose — hibernating is the system // working, and colouring it as a problem trains owners to ignore the // colour that means an actual problem. Tone string } // addressTone maps healthWord's vocabulary onto the four tones. An // unrecognised word falls to idle: a health word nobody taught this function // is missing information, and missing information must never render as // either reassurance or alarm. "No readings" is named here rather than left // to the default for the same reason "Asleep" is: it is deliberately idle, // not a gap this function forgot to cover — a route the sweep was never told // how to sample is honest ignorance, not a problem to alarm on. func addressTone(health string) string { switch health { case "Running", "Serving": return "ok" case "Setting up": return "wait" case "Not responding", "Failed": return "bad" case "Asleep", "No readings": return "idle" default: return "idle" } } // 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 } // flowLineEntry is one box's name/channel pair on the release line — the // legacy frozen three (flowLine below), or a pipelined app's own declared // channels, each named for itself (assembleFlow builds that list from // view.Pipeline.Channels). type flowLineEntry struct{ name, channel string } // flowLine is the LEGACY 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 what it means. Used only when view.Pipeline is nil — // legacy-path-only, exactly as the ladder card and the CLI's promote gate // already are (spec §7). var flowLine = []flowLineEntry{ {"edge", "edge"}, {"beta", "beta"}, {"live", release.RungStable}, } // 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). instancesPath is the app's // Running tab URL, used as MoreURL on any stage that rolls up. // // Pipeline-aware (Task 9 review, the CRITICAL finding): a one-channel // pipeline hides this diagram entirely, same as the ladder card and the // promote form (spec §5 — "the app page shows a Releases list and a live // marker", not a picture of a ladder that does not exist); a multi-channel // pipeline draws its OWN declared channels, in order, rather than the // frozen edge/beta/live boxes a pipelined app may not even have. Legacy // (view.Pipeline == nil) is byte-identical to before this awareness existed. func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt, instancesPath string) FlowView { if view.OneChannelPipeline() { return FlowView{} } f := FlowView{Shipped: latest} if t, err := time.Parse(time.RFC3339, latestAt); err == nil { f.ShippedAt = t } // Rows arrive in route order and stay in it: the Running tab lists these // same hosts in the same order, and two orderings of one list is exactly // the kind of small friction that makes a picture feel untrustworthy. byChannel := map[string][]FlowAddress{} for _, r := range rows { byChannel[r.Channel] = append(byChannel[r.Channel], FlowAddress{ Host: r.Host, Health: r.HealthWord, Version: r.Version, Tone: addressTone(r.HealthWord), }) } chans := make(map[string]ChannelView, len(view.Channels)) for _, c := range view.Channels { chans[c.Name] = c } // line is the release path this app actually has, and holdFor is how // each box on it resolves its general "waits ~24h here" line — the two // things that differ between a legacy app and a pipelined one. Neither // duplicates the ladder card's own logic: line is read straight off // view.Pipeline.Channels (the same order assemblePipelineLadder reads), // and holdFor calls the SAME holdWindow resolver the ladder card and the // promote/fast-track gates already share — this is the one new call // site, not a second implementation. var line []flowLineEntry var holdFor func(channel string) time.Duration if view.Pipeline != nil { line = make([]flowLineEntry, len(view.Pipeline.Channels)) for i, c := range view.Pipeline.Channels { line[i] = flowLineEntry{c.Name, c.Name} } holdFor = func(channel string) time.Duration { return holdWindow(view.Pipeline, channel) } } else { line = flowLine // An unreadable production flag counts as ON, because that is what // the boxes do with it (they keep baking) — the diagram must not // tell an owner their delay is off while every box is still // holding. Legacy-path-only: a pipelined app's bake policy comes // solely from its own pipeline and never consults this flag at all // (spec §3) — holdFor above already reflects that. held := view.Production.On || view.Production.Err != "" holdFor = func(channel string) time.Duration { if !held { return 0 } return release.HoldFor(release.DefaultHolds, channel) } } stage := func(name, channel string) FlowStage { c := chans[channel] s := FlowStage{ Name: name, Channel: channel, Version: c.Version, PromotedAt: c.PromotedAt, Err: c.Err, BakedUntil: c.BakedUntil, FastTrack: c.FastTrack, Addresses: byChannel[channel], } if d := holdFor(channel); d > 0 { s.Hold = holdText(d) } // A channel with addresses but nothing ever published to it is the // silent failure the ladder was built to surface: hosts serving // nothing, waiting for a version that never came. 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 } // The line, then the arrows between its boxes. A step counts as carried // only when the box before it actually holds something: two empty boxes // share a version string ("") without any version having travelled // between them, and drawing that as a solid arrow would be the diagram's // one chance to lie outright. for _, ls := range line { f.Line = append(f.Line, stage(ls.name, ls.channel)) } prev := latest for i := range f.Line { f.Line[i].Carried = prev != "" && f.Line[i].Version == prev prev = f.Line[i].Version } // Canaries from their pointers first, then any canary channel an address // follows that has no pointer at all. drawn := map[string]bool{} for _, ls := range line { drawn[ls.channel] = true } for _, c := range view.Channels { if !strings.HasPrefix(c.Name, "canary/") || drawn[c.Name] { continue } drawn[c.Name] = true f.Tests = append(f.Tests, stage(strings.TrimPrefix(c.Name, "canary/"), c.Name)) } for ch := range byChannel { if drawn[ch] { continue } drawn[ch] = true if strings.HasPrefix(ch, "canary/") { f.Tests = append(f.Tests, stage(strings.TrimPrefix(ch, "canary/"), ch)) continue } f.Extra = append(f.Extra, stage(ch, ch)) } // Map iteration ordered nothing above, and a diagram that reshuffles // between two refreshes of the same page is one nobody trusts. sortStages := func(s []FlowStage) { sort.Slice(s, func(a, b int) bool { return s[a].Channel < s[b].Channel }) } sortStages(f.Tests) sortStages(f.Extra) for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { for _, s := range band { if s.Version != "" || len(s.Addresses) > 0 { f.Show = true } } } return f }