Merge pull request #138 from carlosframework/console-running-and-flow
console: retire "Machines", and draw the release path as a path
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 |