console: retire "Machines", and draw the release path as a path
Two changes to the app Overview, both from the same finding.
The "Machines" tile and the "Web addresses" tile counted the identical
thing — both loop the app's routes with the same account filter and no
further condition — so every app page showed one number twice under two
names. "Machines" was also the wrong word for the row: each row is an
address the app answers on, and several routinely share one box, so the
count implied hardware nobody is paying for.
So the tile stops duplicating and starts answering. It is now "Running",
matching the tab it links to, and its value is health ("2 of 3", "Served",
"—") rather than a headcount. Where prose genuinely needs a noun for a
running copy it says "copy"; in about half the sentences the noun simply
goes ("goes live everywhere after the safety delay"). The uses that mean
the reader's OWN computer — the CLI approval page, Settings, authcli — are
deliberately untouched: those really are machines.
The diagram (flow.go, flow.html) is the Overview's answer to "what is
going on": you shipped → edge → beta → live, drawn left to right, with
each address hanging off the channel that feeds it and carrying the same
health word the Running tab gives it. The one claim it makes beyond its
own words is the arrow — solid and accented where a version really did
travel that step, dashed and grey where it did not — so "how far has my
build got" is something you see rather than three version strings you
compare by eye.
It is HTML and CSS, not plotted SVG: nodes reflow to a column on a phone,
the text is selectable and screen-readable, and the addresses are real
links. It is assembled from the app's channel pointers and the SAME
tenant-filtered instance rows the Running tab renders — never from
AppView.Routes, which carries other accounts' routes — so the picture
cannot draw an address the table does not, disagree with it about health,
or leak a neighbouring tenant's host. Any address on a channel outside the
release path still gets drawn, in its own band: a diagram that presents
itself as the whole story must not silently omit a running host.
Gate: go build, go vet, go test ./... -count=1, gofmt, GOOS=darwin build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 files changed,
+672
−27
internal/console/bakecontrols_test.go+3 −3internal/console/confirm.go+4 −4internal/console/flow.go+229 −0internal/console/flow_test.go+225 −0internal/console/ladder.go+1 −1internal/console/overview_test.go+63 −2internal/console/server.go+18 −4internal/console/server_test.go+1 −1internal/console/templates/app_instances.html+2 −2internal/console/templates/app_overview.html+11 −4internal/console/templates/app_releases.html+3 −3internal/console/templates/flow.html+42 −0internal/console/templates/ladder.html+3 −3internal/console/templates/layout.html+67 −0
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go| index cab5d35d7..e86a9b363 100644 |
| --- a/internal/console/bakecontrols_test.go |
| +++ b/internal/console/bakecontrols_test.go |
| @@ -278,7 +278,7 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| // The flash tells the truth about both halves of what just happened: the |
| // flag went on in this action, so the safety-delay note is now accurate. |
| if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Safety delay enabled.") || |
| - !strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("enable_bake flash missing its two halves:\n%s", body) |
| } |
| } |
| @@ -333,7 +333,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| t.Fatalf("beta = %+v, want v3", p) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("a flagless app was promised a safety delay:\n%s", body) |
| } |
| @@ -348,7 +348,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther { |
| t.Fatalf("beta promote of v4 = %d:\n%s", status, body) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live on each machine after the safety delay") { |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live everywhere after the safety delay") { |
| t.Fatalf("a production app's held promotion did not mention the window:\n%s", body) |
| } |
| } |
diff --git a/internal/console/confirm.go b/internal/console/confirm.go| index a165103f4..95ad7d7e3 100644 |
| --- a/internal/console/confirm.go |
| +++ b/internal/console/confirm.go |
| @@ -134,7 +134,7 @@ func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, ra |
| flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| baking := ferr != nil || (flag != nil && flag.Production) |
| if hold := release.HoldFor(release.DefaultHolds, channel); hold > 0 && baking { |
| - cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".") |
| + cv.Lines = append(cv.Lines, "This app has the safety delay on: each copy waits "+holdText(hold)+" on "+channel+" before running "+version+".") |
| } |
| if stable && ferr == nil && !baking { |
| cv.CheckboxName = "enable_bake" |
| @@ -157,7 +157,7 @@ func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, r |
| channel + " serves " + cur + " now.", |
| "Rolling back re-points it to " + target + ", the version before this one.", |
| }, |
| - Note: "Rollbacks skip the safety delay — machines pick it up within a couple of minutes.", |
| + Note: "Rollbacks skip the safety delay — it rolls out within a couple of minutes.", |
| Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback", |
| // target pins what the card promised: the confirmed POST refuses if |
| // the channel's history has moved on since this was rendered. |
| @@ -192,13 +192,13 @@ func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, |
| cv.Title = "Skip the safety delay for " + cur.Version + " on " + channel + "?" |
| cv.Lines = append(cv.Lines, |
| channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", |
| - "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes.") |
| + "Skipping waives the rest of it: "+cur.Version+" rolls out within a couple of minutes.") |
| cv.Button = "Skip the delay for " + cur.Version |
| // Pin the version the card names — the waiver must not land on a |
| // build promoted after this was rendered. |
| cv.Fields = append(cv.Fields, [2]string{"version", cur.Version}) |
| } else { |
| - cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes.") |
| + cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: its current version rolls out within a couple of minutes.") |
| } |
| s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| } |
diff --git a/internal/console/flow.go b/internal/console/flow.go| new file mode 100644 |
| index 000000000..c7c1caea0 |
| --- /dev/null |
| +++ b/internal/console/flow.go |
| @@ -0,0 +1,229 @@ |
| +// 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 |
| +} |
| + |
| +// 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. |
| +func addressTone(health string) string { |
| + switch health { |
| + case "Running", "Serving": |
| + return "ok" |
| + case "Setting up": |
| + return "wait" |
| + case "Not responding", "Failed": |
| + return "bad" |
| + default: |
| + return "idle" |
| + } |
| +} |
| + |
| +// 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 |
| +// what it means. |
| +var flowLine = []struct{ name, channel string }{ |
| + {"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). |
| +func assembleFlow(view *AppView, rows []InstanceRow, latest, latestAt string) 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 |
| + } |
| + // 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. |
| + held := view.Production.On || view.Production.Err != "" |
| + |
| + 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 held { |
| + if d := release.HoldFor(release.DefaultHolds, 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" |
| + } |
| + 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 flowLine { |
| + 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 flowLine { |
| + 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 |
| +} |
diff --git a/internal/console/flow_test.go b/internal/console/flow_test.go| new file mode 100644 |
| index 000000000..6ff027368 |
| --- /dev/null |
| +++ b/internal/console/flow_test.go |
| @@ -0,0 +1,225 @@ |
| +package console |
| + |
| +import "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. |
| +func stageByChannel(f FlowView, channel string) *FlowStage { |
| + for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| + for i := range band { |
| + if band[i].Channel == channel { |
| + return &band[i] |
| + } |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// The line is the app's release path, and it is drawn whole even when the |
| +// app has only reached the first rung: a pipeline with its later steps |
| +// 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", "") |
| + |
| + if len(f.Line) != 3 { |
| + t.Fatalf("line has %d stages, want edge/beta/live: %+v", len(f.Line), f.Line) |
| + } |
| + for i, want := range []string{"edge", "beta", "stable"} { |
| + if f.Line[i].Channel != want { |
| + t.Errorf("line[%d] is %q, want %q", i, f.Line[i].Channel, want) |
| + } |
| + } |
| + if f.Line[2].Name != "live" { |
| + t.Errorf("the stable stage is labelled %q, want the word owners use: live", f.Line[2].Name) |
| + } |
| + if f.Line[1].Version != "" { |
| + t.Errorf("beta holds nothing but reports version %q", f.Line[1].Version) |
| + } |
| +} |
| + |
| +// The whole point of the picture is that an address hangs off the channel |
| +// feeding it. An address filed under the wrong stage would draw a lie. |
| +func TestFlowAddressesAttachToTheirChannel(t *testing.T) { |
| + v := &AppView{Channels: []ChannelView{ |
| + {Name: "stable", Version: "a1"}, |
| + {Name: "canary/rc", Version: "b2"}, |
| + }} |
| + rows := []InstanceRow{ |
| + {Host: "kass.dev", Channel: "stable", Version: "a1", HealthWord: "Running"}, |
| + {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", "") |
| + |
| + live := stageByChannel(f, "stable") |
| + if live == nil || len(live.Addresses) != 2 { |
| + t.Fatalf("live stage should carry both stable hosts: %+v", live) |
| + } |
| + if live.Addresses[0].Host != "kass.dev" || live.Addresses[1].Host != "www.kass.dev" { |
| + t.Errorf("live addresses out of order: %+v", live.Addresses) |
| + } |
| + rc := stageByChannel(f, "canary/rc") |
| + if rc == nil || len(rc.Addresses) != 1 || rc.Addresses[0].Host != "rc.kass.dev" { |
| + t.Fatalf("the canary host should hang off canary/rc: %+v", rc) |
| + } |
| + if rc.Name != "rc" { |
| + t.Errorf("canary stage is labelled %q, want the short name rc", rc.Name) |
| + } |
| + if len(f.Tests) != 1 { |
| + t.Errorf("canary/rc belongs in the test band, got %d test stages", len(f.Tests)) |
| + } |
| +} |
| + |
| +// A running address that the diagram does not draw is worse than no diagram: |
| +// the picture claims to be the whole story. Whatever channel a route follows, |
| +// its host must appear somewhere. |
| +func TestFlowDrawsEveryRunningAddress(t *testing.T) { |
| + v := &AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}} |
| + rows := []InstanceRow{ |
| + {Host: "kass.dev", Channel: "stable", HealthWord: "Running"}, |
| + {Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Running"}, |
| + {Host: "odd.kass.dev", Channel: "experiment", HealthWord: "Running"}, |
| + } |
| + f := assembleFlow(v, rows, "a1", "") |
| + |
| + for _, want := range []string{"kass.dev", "rc.kass.dev", "odd.kass.dev"} { |
| + found := false |
| + for _, band := range [][]FlowStage{f.Line, f.Tests, f.Extra} { |
| + for _, s := range band { |
| + for _, a := range s.Addresses { |
| + if a.Host == want { |
| + found = true |
| + } |
| + } |
| + } |
| + } |
| + if !found { |
| + t.Errorf("%s is running but appears nowhere in the diagram", want) |
| + } |
| + } |
| + if len(f.Extra) != 1 || f.Extra[0].Channel != "experiment" { |
| + t.Errorf("a route off the ladder needs its own band, got %+v", f.Extra) |
| + } |
| +} |
| + |
| +// The safety delay is the single most confusing thing on the old ladder, so |
| +// the diagram states it — but only when it is actually switched on, and only |
| +// on the channels that hold. |
| +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", "") |
| + if got := stageByChannel(on, "beta").Hold; got != "~24h" { |
| + t.Errorf("beta hold is %q, want ~24h", got) |
| + } |
| + if got := stageByChannel(on, "stable").Hold; got != "~72h" { |
| + t.Errorf("live hold is %q, want ~72h", got) |
| + } |
| + if got := stageByChannel(on, "edge").Hold; got != "" { |
| + t.Errorf("edge holds nothing back but says %q", got) |
| + } |
| + |
| + off := assembleFlow(&AppView{Channels: chans}, nil, "a1", "") |
| + for _, s := range off.Line { |
| + if s.Hold != "" { |
| + t.Errorf("safety delay is off, but %s advertises a %q wait", s.Channel, s.Hold) |
| + } |
| + } |
| + |
| + // 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", "") |
| + if stageByChannel(bad, "stable").Hold == "" { |
| + t.Error("an unreadable production flag must still show the delay — boxes keep baking") |
| + } |
| +} |
| + |
| +// Health is the third of the three questions the console exists to answer, |
| +// and the diagram is where it gets answered per address. Asleep must read as |
| +// idle, not as a problem: hibernating is the platform working. |
| +func TestFlowTonesAddressHealth(t *testing.T) { |
| + rows := []InstanceRow{ |
| + {Host: "a.dev", Channel: "stable", HealthWord: "Running"}, |
| + {Host: "b.dev", Channel: "stable", HealthWord: "Asleep"}, |
| + {Host: "c.dev", Channel: "stable", HealthWord: "Not responding"}, |
| + {Host: "d.dev", Channel: "stable", HealthWord: "Failed"}, |
| + {Host: "e.dev", Channel: "stable", HealthWord: "Setting up"}, |
| + {Host: "f.dev", Channel: "stable", HealthWord: "Serving"}, |
| + {Host: "g.dev", Channel: "stable", HealthWord: "Something new"}, |
| + } |
| + f := assembleFlow(&AppView{Channels: []ChannelView{{Name: "stable", Version: "a1"}}}, rows, "a1", "") |
| + |
| + want := map[string]string{ |
| + "a.dev": "ok", "b.dev": "idle", "c.dev": "bad", "d.dev": "bad", |
| + "e.dev": "wait", "f.dev": "ok", |
| + // A word this function has never been taught is missing information: |
| + // neither reassurance nor alarm. |
| + "g.dev": "idle", |
| + } |
| + for _, a := range stageByChannel(f, "stable").Addresses { |
| + if a.Tone != want[a.Host] { |
| + t.Errorf("%s (%s): tone=%q, want %q", a.Host, a.Health, a.Tone, want[a.Host]) |
| + } |
| + } |
| +} |
| + |
| +// The solid-vs-dashed arrow is the diagram's one claim beyond the words on |
| +// it: that a version really did travel this step. It must be exactly true. |
| +func TestFlowCarriesTheArrowOnlyWhereTheVersionTravelled(t *testing.T) { |
| + // a1 shipped, promoted all the way to beta, not yet live. |
| + f := assembleFlow(&AppView{Channels: []ChannelView{ |
| + {Name: "edge", Version: "a1"}, |
| + {Name: "beta", Version: "a1"}, |
| + {Name: "stable", Version: "old"}, |
| + }}, nil, "a1", "") |
| + want := []bool{true, true, false} |
| + for i, s := range f.Line { |
| + if s.Carried != want[i] { |
| + t.Errorf("%s: carried=%v, want %v", s.Channel, s.Carried, want[i]) |
| + } |
| + } |
| + |
| + // 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", "") |
| + 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, "", "") |
| + if none.Line[0].Carried { |
| + t.Error("no shipped version — the arrow into edge cannot be solid") |
| + } |
| +} |
| + |
| +// A host following a channel nothing was ever published to is the failure the |
| +// ladder exists to make visible; the diagram must not quietly draw an empty |
| +// box instead. |
| +func TestFlowSaysWhenAChannelHasNothingPublished(t *testing.T) { |
| + rows := []InstanceRow{{Host: "rc.kass.dev", Channel: "canary/rc", HealthWord: "Not responding"}} |
| + f := assembleFlow(&AppView{}, rows, "", "") |
| + |
| + rc := stageByChannel(f, "canary/rc") |
| + if rc == nil { |
| + t.Fatal("a host follows canary/rc — the stage must be drawn") |
| + } |
| + if rc.Err == "" { |
| + t.Error("canary/rc has no version and no pointer: the stage must say so") |
| + } |
| +} |
| + |
| +// 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 { |
| + 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 { |
| + 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 { |
| + t.Error("a running address is worth drawing even with no readable pointer") |
| + } |
| +} |
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index 67eff601d..535ed1f57 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -170,7 +170,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [ |
| } |
| entries[rung] = append(entries[rung], LadderChannel{ |
| Channel: ch, Routes: rts, |
| - Err: "nothing has been published here yet — these machines are waiting for a version", |
| + Err: "nothing has been published here yet — these addresses are waiting for a version", |
| }) |
| } |
| out := make([]LadderRung, 0, 4) |
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go| index 4ada7994e..cfc5175a3 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -347,8 +347,14 @@ func TestOverviewPageRendersTiles(t *testing.T) { |
| if !strings.Contains(body, "Needs attention") { |
| t.Fatalf("the headline verdict is missing:\n%s", body) |
| } |
| - if !strings.Contains(body, "Machines") { |
| - t.Fatalf("no Machines tile on the Overview:\n%s", body) |
| + if !strings.Contains(body, "<h2>Running</h2>") { |
| + t.Fatalf("no Running tile on the Overview:\n%s", body) |
| + } |
| + // "Machines" implied one computer per row when several rows routinely |
| + // share a box, and its count was byte-for-byte the Web addresses count |
| + // beside it. Both the word and the duplicate are meant to stay gone. |
| + if strings.Contains(body, "Machines") || strings.Contains(body, "machines") { |
| + t.Fatalf("the Overview still talks about machines:\n%s", body) |
| } |
| if strings.Contains(body, "stable pointer unreadable") { |
| t.Fatalf("stale tile copy still rendered:\n%s", body) |
| @@ -361,6 +367,61 @@ func TestOverviewPageRendersTiles(t *testing.T) { |
| } |
| } |
| +// flowSection returns just the diagram's markup. The console inlines its |
| +// whole stylesheet into every page, so `.flow-stage.is-carried` and friends |
| +// are present in the CSS on a page that draws no diagram at all — an |
| +// unscoped Contains would assert on the rules instead of the drawing. |
| +func flowSection(t *testing.T, page string) string { |
| + t.Helper() |
| + i := strings.Index(page, `<section class="flow"`) |
| + if i < 0 { |
| + t.Fatalf("no flow diagram on the page:\n%s", page) |
| + } |
| + j := strings.Index(page[i:], "</section>") |
| + if j < 0 { |
| + t.Fatalf("the flow section is never closed:\n%s", page[i:]) |
| + } |
| + return page[i : i+j] |
| +} |
| + |
| +// The flow diagram is the Overview's answer to "what is going on" — the |
| +// release path drawn as a path, with each address hanging off the channel |
| +// that feeds it. These assertions cover what a reader has to be able to see; |
| +// flow_test.go covers what the drawing is allowed to claim. |
| +func TestOverviewDrawsTheFlow(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, page := get(t, c, fx.u("/apps/kass")) |
| + |
| + // Scoped to the section, deliberately: the page carries its stylesheet |
| + // inline, so every class name this test looks for also appears in the |
| + // CSS above it, and a whole-body assertion would pass on the rules |
| + // rather than on the drawing. |
| + body := flowSection(t, page) |
| + // The whole path, including the steps this app has not reached: a path |
| + // drawn only as far as the app has travelled reads as a path with no |
| + // further to go. |
| + for _, label := range []string{"you shipped", "edge", "beta", "live"} { |
| + if !strings.Contains(body, `<p class="flow-label">`+label+"</p>") { |
| + t.Fatalf("the flow is missing its %q box:\n%s", label, body) |
| + } |
| + } |
| + // The addresses hang off the diagram, each with the same health word the |
| + // Running tab would give it — and as real links, since the point of an |
| + // address is that you can go to it. |
| + if !strings.Contains(body, `<a href="https://kass.carlosapp.dev">kass.carlosapp.dev</a>`) { |
| + t.Fatalf("the flow does not show the app's address as a link:\n%s", body) |
| + } |
| + if !strings.Contains(body, `class="flow-addr flow-tone-bad"`) { |
| + t.Fatalf("the fixture's silent host should be toned as needing attention:\n%s", body) |
| + } |
| + // The fixture ships v2 but only has v1 live, so nothing has travelled the |
| + // whole path: no step may claim it has. |
| + if strings.Contains(body, "is-carried") { |
| + t.Fatalf("v2 is not live, so no step carried it:\n%s", body) |
| + } |
| +} |
| + |
| // The Instances tab's one table, with the columns an owner reads across. |
| func TestOverviewInstancesPageRendersTable(t *testing.T) { |
| fx := newConsoleFixture(t) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4a9f9226a..d249e3c39 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -516,6 +516,10 @@ type page struct { |
| // from reads the page does anyway. |
| Overview OverviewStats |
| + // App Overview: the same reads drawn as the pipeline they describe |
| + // (flow.go). Zero-valued (Show false) renders nothing at all. |
| + Flow FlowView |
| + |
| // App Instances: the one table — routes joined with their readings. |
| InstanceRows []InstanceRow |
| @@ -1495,6 +1499,16 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p.Versions = versions |
| metrics := s.appResourceMetrics(r.Context(), p.Detail, acct) |
| p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions) |
| + // The diagram is drawn from the SAME tenant-filtered rows the Running tab |
| + // renders, so the two can never disagree about which addresses exist or |
| + // how they are doing — see flow.go's package comment for why it is not |
| + // built from Detail.Routes. |
| + p.InstanceRows = assembleInstanceRows(p.Detail, acct, metrics) |
| + var latest, latestAt string |
| + if len(versions) > 0 { |
| + latest, latestAt = versions[0].Version, versions[0].BuiltAt |
| + } |
| + p.Flow = assembleFlow(p.Detail, p.InstanceRows, latest, latestAt) |
| if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil { |
| log.Printf("console: events %s: %v", app, err) |
| } else { |
| @@ -1953,7 +1967,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| // checkbox was ticked, the production flag — in a single bump. |
| s.bumpSerial(ctx) |
| if baking && release.HoldFor(release.DefaultHolds, channel) > 0 { |
| - msg += " It goes live on each machine after the safety delay — rollbacks skip it." |
| + msg += " It goes live everywhere after the safety delay — rollbacks skip it." |
| } |
| back("ok", msg) |
| } |
| @@ -2107,7 +2121,7 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.") |
| } else { |
| s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)") |
| - back("ok", "Safety delay turning off — takes effect on each machine after ~72h.") |
| + back("ok", "Safety delay turning off — takes effect after ~72h.") |
| } |
| } |
| @@ -2195,7 +2209,7 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| // A waiver re-signs the pointer to say "adopt this now" — the one write |
| // whose entire point is that boxes act on it immediately. |
| s.bumpSerial(ctx) |
| - back("ok", "Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes.") |
| + back("ok", "Skipped the delay for "+ver+" on "+channel+" — it rolls out within a couple of minutes.") |
| } |
| // ---- canaries --------------------------------------------------------------- |
| @@ -2311,7 +2325,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| if enable != "" { |
| create = enable + " && " + create |
| } |
| - back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a machine follows it, no safety delay. Attach one: "+create) |
| + back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment an address follows it, no safety delay. Attach one: "+create) |
| } |
| // instanceDomainFor answers "where do this app's instance hosts live?" — the |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 71ad872ed..b8ff01e2c 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -534,7 +534,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { |
| t.Fatalf("overview still renders %q", gone) |
| } |
| } |
| - for _, want := range []string{`class="tiles stat-tiles"`, "Machines", "Web addresses", "Versions"} { |
| + for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", "Versions"} { |
| if !strings.Contains(body, want) { |
| t.Fatalf("overview missing %q", want) |
| } |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index 62f390e41..2607731c1 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -24,9 +24,9 @@ |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Version is what each machine reports it is running — a dash means |
| +<p class="small">Version is what each copy reports it is running — a dash means |
| it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn |
| -against that machine's own peak.</p> |
| +against that copy's own peak.</p> |
| {{else}} |
| <p>Nothing running yet. Publish a version, then give it a web address:</p> |
| {{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index de5a45ea1..81689f40d 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -26,16 +26,23 @@ |
| <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| {{end}} |
| </section> |
| +{{end}} |
| +{{template "flow" .}} |
| +{{with .Overview}} |
| <section class="tiles stat-tiles" aria-label="Vital statistics"> |
| + {{/* "Running" answers health, not headcount: the count this tile used to |
| + show was byte-for-byte the Web addresses count beside it, under a name |
| + ("Machines") that also implied one computer per row when several rows |
| + routinely share a box. */}} |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| - <h2>Machines</h2> |
| - <p class="tile-value">{{.InstanceTotal}}</p> |
| - <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + <h2>Running</h2> |
| + <p class="tile-value">{{if eq .InstanceTotal 0}}—{{else if eq .InstanceExpected 0}}Served{{else if .MetricsUnavailable}}?{{else}}{{.InstanceReporting}} of {{.InstanceExpected}}{{end}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}nothing running yet{{else if eq .InstanceExpected 0}}static — the platform serves it, nothing to run{{else if .MetricsUnavailable}}no data right now{{else}}copies responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Resources</h2> |
| <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p> |
| - <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p> |
| + <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the copies responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings"> |
| <h2>Web addresses</h2> |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 589f324fa..6c8074365 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -110,7 +110,7 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p> |
| +<p class="small">Go-live times are estimates — each copy keeps its own clock. Rollbacks skip the delay.</p> |
| {{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}} |
| <h2>Canaries</h2> |
| @@ -152,10 +152,10 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| </select> |
| <button type="submit" class="btn-sm">Create canary</button> |
| </form> |
| -<p class="small">Creates <code>canary/<name></code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:</p> |
| +<p class="small">Creates <code>canary/<name></code> — a test version serving the chosen build immediately, no delay. It gets its own URL from an address that follows it:</p> |
| {{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
| {{.AttachCmd}}</code></pre> |
| -<p class="small">This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p> |
| +<p class="small">This app has no addresses yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p> |
| {{else}}<pre><code>{{.AttachCmd}}</code></pre> |
| <p class="small">The address comes live within a couple of minutes.</p> |
| {{end}} |
diff --git a/internal/console/templates/flow.html b/internal/console/templates/flow.html| new file mode 100644 |
| index 000000000..0cdb1dac1 |
| --- /dev/null |
| +++ b/internal/console/templates/flow.html |
| @@ -0,0 +1,42 @@ |
| +{{define "flow"}}{{with .Flow}}{{if .Show}} |
| +<section class="flow" aria-label="How this app's versions reach the web"> |
| + <ol class="flow-line"> |
| + <li class="flow-stage flow-origin{{if .Shipped}} is-held{{end}}"> |
| + <p class="flow-label">you shipped</p> |
| + <p class="flow-version">{{if .Shipped}}<code>{{.Shipped}}</code>{{else}}nothing yet{{end}}</p> |
| + {{if not .ShippedAt.IsZero}}<p class="flow-note">{{ago .ShippedAt}}</p>{{end}} |
| + </li> |
| + {{range .Line}}{{template "flow_stage" .}}{{end}} |
| + </ol> |
| + {{if .Tests}} |
| + <p class="flow-band">Test versions — no delay, and they can point anywhere.</p> |
| + <ul class="flow-line flow-aside">{{range .Tests}}{{template "flow_stage" .}}{{end}}</ul> |
| + {{end}} |
| + {{if .Extra}} |
| + <p class="flow-band">Following a channel outside the release path.</p> |
| + <ul class="flow-line flow-aside">{{range .Extra}}{{template "flow_stage" .}}{{end}}</ul> |
| + {{end}} |
| + <p class="small flow-caption">A version moves one step at a time, left to right. Promoting is what moves it.</p> |
| +</section> |
| +{{end}}{{end}}{{end}} |
| + |
| +{{/* One box: a channel, what it holds, and the addresses it feeds. Used as an |
| + <li> in both the ordered release path and the unordered bands below it. */}} |
| +{{define "flow_stage"}} |
| +<li class="flow-stage{{if .Version}} is-held{{end}}{{if .Carried}} is-carried{{end}}"> |
| + <p class="flow-label">{{.Name}}</p> |
| + <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 .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}} |
| +</li> |
| +{{end}} |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 4a57c86be..5adf19d0f 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -1,7 +1,7 @@ |
| {{define "ladder"}} |
| <h2>The ladder</h2> |
| <p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live). |
| -Promoting moves it one step up; the machines following that step pick it up.</p> |
| +Promoting moves it one step up; whatever follows that step picks it up.</p> |
| {{with .Detail.Production}} |
| <div class="bake"> |
| {{if .Err}} |
| @@ -16,7 +16,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p> |
| {{end}} |
| </p> |
| {{else if .On}} |
| - <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up. |
| + <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before they go live. |
| {{if and $.CanPromote $.IsOwner}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| @@ -88,7 +88,7 @@ Promoting moves it one step up; the machines following that step pick it up.</p> |
| {{end}} |
| </ul> |
| {{else}} |
| - <p class="small">No machine follows this channel.</p> |
| + <p class="small">Nothing follows this channel yet.</p> |
| {{end}} |
| </div> |
| {{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 820d2c96a..99cba569c 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -189,6 +189,73 @@ |
| .chip-up { background: var(--accent-soft); color: #2f7a4f; } |
| .chip-down { background: var(--accent-soft); color: var(--bad); } |
| .chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; } |
| + /* The app flow diagram (flow.go): the release path drawn as the pipeline it |
| + is, with the addresses each channel feeds hanging under it. |
| + |
| + The one claim the drawing makes beyond its own words is the ARROW: solid |
| + and accented where a version really did travel that step, dashed and grey |
| + where it did not. That is what lets "how far has my build got" be seen |
| + rather than worked out by comparing three version strings, so the two |
| + states have to stay visually unmistakable — and distinguishable by more |
| + than colour, hence solid vs dashed. */ |
| + .flow { margin: 0 0 1.75rem; } |
| + .flow-line { |
| + --flow-y: 0.52rem; |
| + display: flex; flex-wrap: wrap; gap: 1.1rem 2.6rem; |
| + list-style: none; margin: 0; padding: 0; |
| + } |
| + .flow-stage { position: relative; flex: 1 1 10rem; min-width: 9rem; } |
| + .flow-stage + .flow-stage::before, .flow-stage + .flow-stage::after { |
| + content: ""; position: absolute; pointer-events: none; |
| + } |
| + .flow-stage + .flow-stage::before { |
| + left: -2.05rem; top: var(--flow-y); width: 1.3rem; border-top: 1px dashed var(--rule); |
| + } |
| + .flow-stage + .flow-stage::after { |
| + left: -0.82rem; top: var(--flow-y); transform: translateY(-50%); |
| + border: 0.28rem solid transparent; border-right: 0; border-left-color: var(--rule); |
| + } |
| + .flow-stage.is-carried::before { border-top-style: solid; border-top-color: var(--accent); } |
| + .flow-stage.is-carried::after { border-left-color: var(--accent); } |
| + .flow-label { |
| + margin: 0; font-family: var(--mono); font-size: 0.74rem; text-transform: uppercase; |
| + letter-spacing: 0.05em; color: var(--muted); font-weight: 600; |
| + } |
| + .flow-version { margin: 0.3rem 0 0; font-size: 0.95rem; } |
| + /* An empty box states its emptiness in words, so it needs no fill of its |
| + own — the missing version chip is the signal. */ |
| + .flow-stage:not(.is-held) .flow-version { color: var(--muted); } |
| + .flow-note { margin: 0.25rem 0 0; font-size: 0.78rem; color: var(--muted); } |
| + .flow-addrs { |
| + list-style: none; margin: 0.6rem 0 0; padding: 0 0 0 0.7rem; |
| + border-left: 1px solid var(--rule); display: grid; gap: 0.35rem; |
| + } |
| + .flow-addr { font-size: 0.82rem; line-height: 1.35; } |
| + .flow-addr a { display: block; overflow-wrap: anywhere; } |
| + .flow-health { display: inline-flex; align-items: center; gap: 0.2rem; color: var(--muted); } |
| + .flow-health .icon { width: 0.9em; height: 0.9em; flex: none; } |
| + .flow-tone-ok .flow-health { color: var(--good); } |
| + .flow-tone-wait .flow-health { color: var(--warn, #a6790f); } |
| + .flow-tone-bad .flow-health { color: var(--bad); font-weight: 600; } |
| + .flow-band { margin: 1.4rem 0 0.7rem; font-size: 0.9rem; color: var(--muted); } |
| + .flow-caption { margin: 1.1rem 0 0; } |
| + /* Narrow screens: the same path, turned down the page. The connector turns |
| + with it — an arrow still pointing the way the version travels. */ |
| + @media (max-width: 46rem) { |
| + .flow-line { display: block; } |
| + .flow-stage + .flow-stage { margin-top: 1.6rem; } |
| + .flow-stage + .flow-stage::before { |
| + left: 0.55rem; top: -1.2rem; width: 0; height: 0.85rem; |
| + border-top: 0; border-left: 1px dashed var(--rule); |
| + } |
| + .flow-stage + .flow-stage::after { |
| + left: 0.55rem; top: -0.34rem; transform: translateX(-50%); |
| + border: 0.28rem solid transparent; border-bottom: 0; border-top-color: var(--rule); |
| + border-left-color: transparent; |
| + } |
| + .flow-stage.is-carried::before { border-left-color: var(--accent); } |
| + .flow-stage.is-carried::after { border-top-color: var(--accent); border-left-color: transparent; } |
| + } |
| /* The release ladder: four rungs drawn top-down in the order the spec |
| draws them (canary first, stable at the foot), with the promotion |
| between two of them rendered as the step it is. list-style is off |