carlos / platform Public

console: releases page leads with live/next, ladder and tables move behind reveals

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3c5ee7e7f77ab30dbc4eb464ec7cbeead70daa88 parent a332e7f
8 files changed, +174 −58
  • internal/console/ladder.go +5 −5
  • internal/console/releasesummary.go +45 −0
  • internal/console/releasesummary_test.go +34 −0
  • internal/console/server.go +11 −7
  • internal/console/server_test.go +1 −1
  • internal/console/templates/app_releases.html +70 −40
  • internal/console/templates/ladder.html +5 −5
  • internal/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 &lt;binary&gt;
+carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</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 &lt;binary&gt;
-carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</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 &lt;site-dir&gt;</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 {