console: bake status line, production toggle, fast-track control and the stable-promote prompt
Task 8 of console-bake-controls (spec §7 UI): renders .Detail.Production as a status line with owner-only turn on/off controls and the persistent prod-wiring nudge; adds the owner-only fast-track control gated on a non-zero BakedUntil per held channel row, plus its permanent "fast-tracked" badge; adds the enable_bake checkbox to the stable-promote form while the flag is off. app.html's channels table gets the same fast-tracked note for consistency.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK
3 files changed,
+152
−2
internal/console/ladder_test.go+96 −0internal/console/templates/app.html+1 −1internal/console/templates/ladder.html+55 −1
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go| index 2194c2768..c0b90edf3 100644 |
| --- a/internal/console/ladder_test.go |
| +++ b/internal/console/ladder_test.go |
| @@ -536,3 +536,99 @@ func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) { |
| t.Fatalf("expired canary still holds %s — expiry has become a gate", p.Version) |
| } |
| } |
| + |
| +// The bake status line and its controls (spec §7): off-by-default says so; |
| +// an owner gets the turn-on/turn-off controls; a held channel row offers |
| +// the owner fast-track; the stable promote form carries the enable_bake |
| +// prompt while the flag is off. |
| +func TestLadderShowsBakeStateAndControls(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") |
| + c := signInPaul(t, fx) |
| + |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "Supply-chain bake") { |
| + t.Fatalf("no bake status line:\n%s", body) |
| + } |
| + if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) { |
| + t.Fatal("owner is offered no production toggle") |
| + } |
| + // Off by default, and the stable promote form asks the question. |
| + if !strings.Contains(body, `name="enable_bake"`) { |
| + t.Fatal("the stable promote form does not carry the enable_bake prompt") |
| + } |
| + |
| + // Flip on; the held beta head now shows its window and the fast-track |
| + // control appears on it. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Fatalf("no fast-track control on a held head:\n%s", rungBlock(t, body, "beta")) |
| + } |
| + if strings.Contains(body, `name="enable_bake"`) { |
| + t.Fatal("the enable_bake prompt survived the flag turning on") |
| + } |
| + |
| + // Waive it; the badge is permanent and the control is gone. |
| + if _, err := release.FastTrack(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", "beta", "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/kass")) |
| + b := rungBlock(t, body, "beta") |
| + if !strings.Contains(b, "fast-tracked") { |
| + t.Errorf("no permanent fast-tracked badge:\n%s", b) |
| + } |
| + if strings.Contains(b, `action="/a/bab/apps/kass/fasttrack"`) { |
| + t.Error("a waived head still offers the fast-track control") |
| + } |
| +} |
| + |
| +// A member sees state, never the owner-only controls (off / fast-track) — |
| +// but IS offered turn-on, the safe direction. |
| +func TestLadderBakeControlsMemberView(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-baketpl") |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "Supply-chain bake") { |
| + t.Fatal("member sees no bake status") |
| + } |
| + if !strings.Contains(body, `name="production" value="on"`) { |
| + t.Error("member is not offered the turn-on control") |
| + } |
| + if strings.Contains(body, `name="production" value="off"`) { |
| + t.Error("member is offered the owner-only turn-off control") |
| + } |
| + if strings.Contains(body, `/fasttrack"`) { |
| + t.Error("member is offered the owner-only fast-track control") |
| + } |
| +} |
| + |
| +// The prod-wiring nudge: a stable route with bake off gets the persistent |
| +// recommendation line. |
| +func TestLadderNudgesProdWiredBakeOff(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + // The fixture's kass.carlosapp.dev is on stable and the flag is off (the |
| + // default): that IS the nudge condition. |
| + _, _, body := get(t, c, fx.u("/apps/kass")) |
| + if !strings.Contains(body, "adopts releases immediately") { |
| + t.Fatalf("no prod-wiring nudge:\n%s", body) |
| + } |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "adopts releases immediately") { |
| + t.Fatal("the nudge survived the flag turning on") |
| + } |
| +} |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index d25b31297..8684587a9 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -95,7 +95,7 @@ |
| {{else}} |
| <td><code>{{.Version}}</code></td> |
| <td>{{fmtTime .PromotedAt}}</td> |
| - <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}</td> |
| + <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td> |
| {{end}} |
| <td>{{if and $.CanPromote .HasHistory (not .Err)}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback"> |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 72448ae20..f4290fe0c 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -3,6 +3,47 @@ |
| <p class="small">A channel is maturity, not a place: the same build climbs |
| canary → edge → beta → stable, and a promotion moves a signed pointer without |
| touching what any host is wired to.</p> |
| +{{with .Detail.Production}} |
| +<div class="bake"> |
| + {{if .Err}} |
| + <p class="error">Production flag unreadable: {{.Err}} — hosts treat this app as in production and keep baking.</p> |
| + {{else if .On}} |
| + <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h and stable 72h on each host before it adopts. |
| + {{if and $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="off"> |
| + <button type="submit" class="linkish">Turn off</button> |
| + </form> |
| + <span class="small">— confirms with your passkey; each host honors it after the stable window (~72h) on its own clock</span> |
| + {{end}} |
| + </p> |
| + {{else if .TurningOff}} |
| + <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock. |
| + {{if $.CanPromote}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="on"> |
| + <button type="submit" class="linkish">Turn back on</button> |
| + </form> |
| + {{end}} |
| + </p> |
| + {{else}} |
| + <p class="small">Supply-chain bake: <strong>off</strong> (default) — hosts adopt releases immediately. |
| + {{if $.CanPromote}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="production" value="on"> |
| + <button type="submit" class="linkish">Turn on</button> |
| + </form> |
| + {{end}} |
| + </p> |
| + {{end}} |
| + {{if .Nudge}} |
| + <p class="small nudge">{{icon "triangle-alert"}} This app looks production-wired but adopts releases immediately — consider enabling the supply-chain bake.</p> |
| + {{end}} |
| +</div> |
| +{{end}} |
| <ol class="ladder"> |
| {{range .Detail.Ladder}} |
| <li class="rung" id="rung-{{.Rung}}"> |
| @@ -16,7 +57,16 @@ touching what any host is wired to.</p> |
| <p><code>{{.Channel}}</code> holds <code>{{.Version}}</code> |
| <span class="small">promoted {{fmtTime .PromotedAt}} |
| {{if .Actor}}by {{.Actor}}{{else}}— by whom is not recorded, on a pointer written before the trail existed{{end}} |
| - {{if .Hotfix}}· ladder bypassed (hotfix){{end}}</span></p> |
| + {{if .Hotfix}}· ladder bypassed (hotfix){{end}} |
| + {{if .FastTrack}}· fast-tracked past the bake window{{end}}</span></p> |
| + {{if and (not .BakedUntil.IsZero) $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/fasttrack" class="inline"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="{{.Channel}}"> |
| + <button type="submit" class="linkish">Fast-track {{.Version}}</button> |
| + </form> |
| + <span class="small">— in its bake window until ~{{fmtTime .BakedUntil}}; confirms with your passkey</span> |
| + {{end}} |
| {{end}} |
| {{if .Routes}} |
| <ul class="routes"> |
| @@ -46,6 +96,10 @@ touching what any host is wired to.</p> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="channel" value="{{.Up.To}}"> |
| <input type="hidden" name="version" value="{{.Up.Version}}"> |
| + {{if and (eq .Up.To "stable") (not $.Detail.Production.On)}} |
| + <label class="small"><input type="checkbox" name="enable_bake" value="1" checked> |
| + This app is in production — enable the supply-chain bake (recommended)</label> |
| + {{end}} |
| <button type="submit" class="linkish">Promote {{.Up.Version}} to {{.Up.To}}</button> |
| </form> |
| {{if .Up.StepUp}}<span class="small">— confirms with your passkey</span>{{end}} |