carlos / platform Public

console: releases tab — channels, canaries, releases, promote flows

Forms carry back=releases (allowlisted literal, never echoed) so a
promote made on the tab lands back on the tab; ladder flows still land
on the app page.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 89e92de380aa7e4531b1a033b2d580a2a36d2540 parent dcad885
3 files changed, +180 −4
  • internal/console/server.go +37 −4
  • internal/console/server_test.go +28 −0
  • internal/console/templates/app_releases.html +115 −0
diff --git a/internal/console/server.go b/internal/console/server.go
index 29823d184..6c7885762 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -260,6 +260,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction)
@@ -1345,6 +1346,22 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "app", p)
}
+// handleAppReleases is the deploy surface: every shipped release, the
+// channels they promote onto, and the canaries riding ahead.
+func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "releases")
+ if !ok {
+ return
+ }
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
+ if err != nil {
+ log.Printf("console: versions %s: %v", p.AppName, err)
+ p.FlashErr = "Could not list this app's releases."
+ }
+ p.Versions = versions
+ s.render(w, http.StatusOK, "app_releases", p)
+}
+
// eventsCap bounds the app page's recent-activity list.
const eventsCap = 10
@@ -1552,7 +1569,11 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("new_channel"))
if channel == "" {
@@ -1685,7 +1706,11 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
channel := strings.TrimSpace(r.FormValue("channel"))
if !release.ValidChannel(channel) {
@@ -1890,7 +1915,11 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
@@ -1962,7 +1991,11 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
}
back := func(kind, msg string) {
s.setFlash(w, kind, msg)
- http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+ dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+ http.Redirect(w, r, dest, http.StatusSeeOther)
}
name := strings.ToLower(strings.TrimSpace(r.FormValue("name")))
if !canaryNameRe.MatchString(name) {
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index dc3446686..835fc5c4b 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -488,6 +488,25 @@ func TestAppTabBar(t *testing.T) {
}
}
+func TestReleasesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("GET releases tab = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Releases</a>`,
+ "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
+ `name="back" value="releases"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases tab missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/releases")
+}
+
// assertNonMember404 signs bob (no membership in the fixture account) in and
// asserts the account-scoped path answers him byte-identically to an account
// that does not exist at all.
@@ -771,6 +790,15 @@ func TestRollbackFlow(t *testing.T) {
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Rolled back stable to v1.") {
t.Fatal("no rollback flash")
}
+
+ // Same happy path, but posted from the releases tab (back=releases): the
+ // redirect must land back on the tab, not the app page. stable is v1 with
+ // v2 in history after the rollback above, so a second rollback is valid.
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/rollback"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") {
+ t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases"))
+ }
}
// addRoute upserts a registry route into the fixture's registry.
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
new file mode 100644
index 000000000..3050813df
--- /dev/null
+++ b/internal/console/templates/app_releases.html
@@ -0,0 +1,115 @@
+{{define "app_releases"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+<h2>Channels</h2>
+{{if .Detail.Channels}}
+<div class="tablewrap">
+<table>
+ <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr>
+ {{range .Detail.Channels}}
+ <tr>
+ <td>{{.Name}}</td>
+ {{if .Err}}
+ <td colspan="3" class="error">{{.Err}}</td>
+ {{else}}
+ <td><code>{{.Version}}</code></td>
+ <td>{{fmtTime .PromotedAt}}</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">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="channel" value="{{.Name}}">
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="linkish danger">Roll back</button>
+ </form>
+ {{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p>
+{{else}}<p class="small">No channels yet.</p>{{end}}
+
+<h2>Canaries</h2>
+{{if .Detail.Canaries}}
+<div class="tablewrap">
+<table>
+ <tr><th>Name</th><th>Channel</th><th>Version</th><th>Created</th><th>URL</th><th></th></tr>
+ {{range .Detail.Canaries}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td><code>{{.Channel}}</code></td>
+ {{if .Err}}
+ <td colspan="3" class="error">{{.Err}}</td>
+ {{else}}
+ <td><code>{{.Version}}</code></td>
+ <td>{{fmtTime .PromotedAt}}</td>
+ <td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td>
+ {{end}}
+ <td>{{if $.CanPromote}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/canary/retire">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="name" value="{{.Name}}">
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="linkish danger">Retire</button>
+ </form>
+ {{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}}
+{{if and .CanPromote .Versions}}
+<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="hidden" name="back" value="releases">
+ <input type="text" name="name" placeholder="rc-2">
+ <select name="version">
+ {{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}}
+ </select>
+ <button type="submit">Create canary</button>
+</form>
+<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/&lt;name&gt; &lt;host&gt;</code>.</p>
+{{end}}
+
+<h2>Releases</h2>
+{{if .Versions}}
+<div class="tablewrap">
+<table>
+ <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
+ {{range .Versions}}
+ <tr>
+ <td><code>{{.Version}}</code></td>
+ {{if .Err}}
+ <td colspan="2" class="error">{{.Err}}</td>
+ {{else}}
+ <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="linkish">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 not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{template "bottom" .}}{{end}}