carlos / platform Public

Merge pull request #138 from carlosframework/console-running-and-flow

console: retire "Machines", and draw the release path as a path
Paul Campbell bdadb3f59202ede72209416a6a92499604a626d6 parent 8f64abdparent 6081ac4
14 files changed, +672 −27
  • internal/console/bakecontrols_test.go +3 −3
  • internal/console/confirm.go +4 −4
  • internal/console/flow.go +229 −0
  • internal/console/flow_test.go +225 −0
  • internal/console/ladder.go +1 −1
  • internal/console/overview_test.go +63 −2
  • internal/console/server.go +18 −4
  • internal/console/server_test.go +1 −1
  • internal/console/templates/app_instances.html +2 −2
  • internal/console/templates/app_overview.html +11 −4
  • internal/console/templates/app_releases.html +3 −3
  • internal/console/templates/flow.html +42 −0
  • internal/console/templates/ladder.html +3 −3
  • internal/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 &lt;site-dir&gt;</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 &lt;site-dir&gt;</code
</select>
<button type="submit" class="btn-sm">Create canary</button>
</form>
-<p class="small">Creates <code>canary/&lt;name&gt;</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/&lt;name&gt;</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