console: releases page leads with live/next, ladder and tables move behind reveals
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+174
−58
internal/console/ladder.go+5 −5internal/console/releasesummary.go+45 −0internal/console/releasesummary_test.go+34 −0internal/console/server.go+11 −7internal/console/server_test.go+1 −1internal/console/templates/app_releases.html+70 −40internal/console/templates/ladder.html+5 −5internal/console/templates/layout.html+3 −0
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index 27afa1285..67eff601d 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -117,10 +117,10 @@ type LadderStep struct { |
| // gates are spec §3's gate table, in the words a person reading the page |
| // needs rather than the words the table uses. |
| var gates = map[string]string{ |
| - release.RungCanary: "No gate — a canary is proven before it is a candidate, and may be wired to anything, including production.", |
| - release.RungEdge: "Merged to main. Nothing is promoted onto edge.", |
| - release.RungBeta: "Sign-off by an account member, recorded.", |
| - release.RungStable: "Sign-off by an account owner with a passkey, recorded, after the safety delay.", |
| + release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.", |
| + release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.", |
| + release.RungBeta: "Any account member can promote here; it's recorded.", |
| + release.RungStable: "An account owner promotes here with their passkey, after the safety delay.", |
| } |
| // assembleLadder folds an app's channels and routes into the four rungs. |
| @@ -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 is published on this channel — these hosts are following a pointer that does not exist", |
| + Err: "nothing has been published here yet — these machines are waiting for a version", |
| }) |
| } |
| out := make([]LadderRung, 0, 4) |
diff --git a/internal/console/releasesummary.go b/internal/console/releasesummary.go| new file mode 100644 |
| index 000000000..2fd743a0a |
| --- /dev/null |
| +++ b/internal/console/releasesummary.go |
| @@ -0,0 +1,45 @@ |
| +// The Versions tab's first-glance strip: what is live, and what is next in |
| +// line — the two facts a visitor came for, computed here so the template |
| +// renders them without touching the ladder. "Next" is beta's version when it |
| +// differs from stable's: the ladder's one real candidate step. Everything |
| +// deeper (rungs, gates, canaries) stays in the pipeline disclosure. |
| +package console |
| + |
| +import "time" |
| + |
| +type ReleaseSummary struct { |
| + HasLive bool |
| + LiveVersion string |
| + LivePromotedAt time.Time |
| + LiveActor string |
| + LiveErr string |
| + // NextVersion is beta's candidate when it differs from live; "" when |
| + // there is nothing next. NextBakedUntil zero means it can go live now. |
| + NextVersion string |
| + NextBakedUntil time.Time |
| +} |
| + |
| +func assembleReleaseSummary(channels []ChannelView) ReleaseSummary { |
| + var s ReleaseSummary |
| + var beta ChannelView |
| + for _, ch := range channels { |
| + switch ch.Name { |
| + case "stable": |
| + if ch.Err != "" { |
| + s.LiveErr = ch.Err |
| + continue |
| + } |
| + s.HasLive = true |
| + s.LiveVersion = ch.Version |
| + s.LivePromotedAt = ch.PromotedAt |
| + s.LiveActor = ch.Actor |
| + case "beta": |
| + beta = ch |
| + } |
| + } |
| + if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion { |
| + s.NextVersion = beta.Version |
| + s.NextBakedUntil = beta.BakedUntil |
| + } |
| + return s |
| +} |
diff --git a/internal/console/releasesummary_test.go b/internal/console/releasesummary_test.go| new file mode 100644 |
| index 000000000..96b94f426 |
| --- /dev/null |
| +++ b/internal/console/releasesummary_test.go |
| @@ -0,0 +1,34 @@ |
| +package console |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestReleaseSummary(t *testing.T) { |
| + now := time.Now() |
| + chans := []ChannelView{ |
| + {Name: "stable", Version: "d388035", PromotedAt: now.Add(-48 * time.Hour), Actor: "paul@keymail.dev"}, |
| + {Name: "beta", Version: "e91ac22", PromotedAt: now.Add(-20 * time.Hour), BakedUntil: now.Add(4 * time.Hour)}, |
| + {Name: "canary/rc-1", Version: "aaa1111"}, |
| + } |
| + s := assembleReleaseSummary(chans) |
| + if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" { |
| + t.Errorf("live = %+v", s) |
| + } |
| + if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) { |
| + t.Errorf("next = %+v", s) |
| + } |
| + |
| + same := assembleReleaseSummary([]ChannelView{ |
| + {Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"}, |
| + }) |
| + if same.NextVersion != "" { |
| + t.Errorf("same version on beta must not be Next, got %q", same.NextVersion) |
| + } |
| + |
| + bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}}) |
| + if bad.HasLive || bad.LiveErr == "" || bad.NextVersion != "" { |
| + t.Errorf("unreadable stable = %+v", bad) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index cd3997492..329c49c1e 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -446,13 +446,16 @@ type page struct { |
| Flash string // one-shot success notice (cookie-carried across a redirect) |
| FlashErr string // one-shot error notice |
| - Apps []appRow // account home |
| - Detail *AppView // /a/{sqid}/apps/{app} |
| - Versions []VersionView // app page: shipped releases, newest first |
| - Tags []release.Tag // releases tab: every tag this app has cut, changelog and all |
| - Instances *InstanceReport // app page: the app's self-report, nil when unpolled |
| - Events []Event // app page: recent console actions, newest first |
| - CanPromote bool // a signing key is configured on this host |
| + Apps []appRow // account home |
| + Detail *AppView // /a/{sqid}/apps/{app} |
| + Versions []VersionView // app page: shipped releases, newest first |
| + Tags []release.Tag // releases tab: every tag this app has cut, changelog and all |
| + // ReleaseSummary is the Releases tab's lede strip: what is live and |
| + // what is next, folded from Detail.Channels once the tab has them. |
| + ReleaseSummary ReleaseSummary |
| + Instances *InstanceReport // app page: the app's self-report, nil when unpolled |
| + Events []Event // app page: recent console actions, newest first |
| + CanPromote bool // a signing key is configured on this host |
| // App page: how a canary channel gets a URL, rendered as commands the |
| // reader can actually run from where they are reading. Computed |
| @@ -1506,6 +1509,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels) |
| versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName) |
| if err != nil { |
| log.Printf("console: versions %s: %v", p.AppName, err) |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index cd1d58dd9..14635cf41 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -551,7 +551,7 @@ func TestReleasesTab(t *testing.T) { |
| } |
| for _, want := range []string{ |
| `aria-current="page">Releases</a>`, |
| - "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>", |
| + "<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (", |
| } { |
| if !strings.Contains(body, want) { |
| t.Fatalf("releases tab missing %q", want) |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index d216bc232..80152ffc6 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -12,6 +12,75 @@ |
| </p> |
| {{end}} |
| +{{with .ReleaseSummary}} |
| +<section class="pagehead" aria-label="Release status"> |
| + {{if .HasLive}} |
| + <p>Live: <code>{{.LiveVersion}}</code> <span class="small">— promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</span></p> |
| + {{else if .LiveErr}} |
| + <p>We can't tell what's live right now — it usually recovers on its own; check again shortly.</p> |
| + {{else}} |
| + <p>Nothing is live yet.</p> |
| + {{end}} |
| + {{if .NextVersion}} |
| + <p>Next up: <code>{{.NextVersion}}</code> on beta — {{if .NextBakedUntil.IsZero}}ready to go live now{{else}}goes live {{until .NextBakedUntil}}{{end}}.</p> |
| + {{if and $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="stable"> |
| + <input type="hidden" name="version" value="{{.NextVersion}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <button type="submit" class="btn">Make {{.NextVersion}} live</button> |
| + </form> |
| + {{else}} |
| + <p class="small">Only an account owner can make it live.</p> |
| + {{end}} |
| + {{end}} |
| +</section> |
| +{{end}} |
| + |
| +{{if .Versions}} |
| +<details class="reveal" open> |
| +<summary>All versions ({{len .Versions}})</summary> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr> |
| + {{range .Versions}} |
| + <tr> |
| + <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td> |
| + {{if .Err}} |
| + <td colspan="3" class="error">{{.Err}}</td> |
| + {{else}} |
| + <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}} |
| + <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details> |
| + {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td> |
| + <td>{{.Kind}}</td> |
| + <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td> |
| + {{end}} |
| + {{if $.CanPromote}}<td> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="version" value="{{.Version}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <select name="channel" aria-label="Channel to promote {{.Version}} to"> |
| + {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| + </select> |
| + <button type="submit" class="btn-sm">Promote</button> |
| + </form> |
| + </td>{{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +</details> |
| +{{else}} |
| +<p>Nothing shipped yet. Ship your first release from your repo:</p> |
| +<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 <binary> |
| +carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code></pre> |
| +<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p> |
| +{{end}} |
| + |
| +<details class="reveal"> |
| +<summary>Release pipeline — channels, test versions, and the promotion ladder</summary> |
| {{template "ladder" .}} |
| <h2>Channels</h2> |
| @@ -92,46 +161,6 @@ |
| {{end}} |
| {{end}} |
| -<h2>Releases</h2> |
| -{{if .Versions}} |
| -<div class="tablewrap"> |
| -<table> |
| - <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr> |
| - {{range .Versions}} |
| - <tr> |
| - <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td> |
| - {{if .Err}} |
| - <td colspan="3" class="error">{{.Err}}</td> |
| - {{else}} |
| - <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}} |
| - <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details> |
| - {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td> |
| - <td>{{.Kind}}</td> |
| - <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td> |
| - {{end}} |
| - {{if $.CanPromote}}<td> |
| - <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote"> |
| - <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| - <input type="hidden" name="version" value="{{.Version}}"> |
| - <input type="hidden" name="back" value="releases"> |
| - <select name="channel"> |
| - {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| - </select> |
| - <input type="text" name="new_channel" placeholder="or a new channel" size="14"> |
| - <button type="submit" class="btn-sm">Promote</button> |
| - </form> |
| - </td>{{end}} |
| - </tr> |
| - {{end}} |
| -</table> |
| -</div> |
| -{{else}} |
| -<p>Nothing shipped yet. Ship your first release from your repo:</p> |
| -<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 <binary> |
| -carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code></pre> |
| -<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p> |
| -{{end}} |
| - |
| {{if .Tags}} |
| <h2>Tags</h2> |
| {{range .Tags}} |
| @@ -141,5 +170,6 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| </details> |
| {{end}} |
| {{end}} |
| +</details> |
| {{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index 5cd165b96..4a57c86be 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -1,7 +1,7 @@ |
| {{define "ladder"}} |
| -<h2>Ladder</h2> |
| -<p class="small">The same build climbs canary → edge → beta → stable; a |
| -promotion moves a signed pointer, and hosts follow their channel.</p> |
| +<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> |
| {{with .Detail.Production}} |
| <div class="bake"> |
| {{if .Err}} |
| @@ -88,7 +88,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p> |
| {{end}} |
| </ul> |
| {{else}} |
| - <p class="small">No host follows this channel.</p> |
| + <p class="small">No machine follows this channel.</p> |
| {{end}} |
| </div> |
| {{end}} |
| @@ -109,7 +109,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p> |
| {{else if .Up.Note}} |
| <span class="small">{{.Up.Note}}</span> |
| {{else}} |
| - <span class="small">Promoting to {{.Up.To}} is the account owner's to do.</span> |
| + <span class="small">Only an account owner can promote to {{.Up.To}}.</span> |
| {{end}} |
| </div> |
| {{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 44e7f76eb..e64cf6af6 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -276,6 +276,9 @@ |
| } |
| .pagehead h1 { margin: 0 0 0.35rem; } |
| .pagehead .lede { margin: 0; font-size: 1.05rem; } |
| + /* Progressive-reveal sections: a page leads with the summary strip, and |
| + the deeper tables/ladder sit behind an opt-in disclosure. */ |
| + .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; } |
| /* App tab bar (Heroku-style), and the account nav's active marker. |
| aria-current carries the state; the accent underline draws it. */ |
| .tabs { |