carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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.
18package console
19
20import (
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.
30type 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.
57type 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.
101type 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.
119func 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.
138const 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.
142const 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).
148func 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).
160type 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).
168var 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.
186func 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