Merge branch 'console-ux-05-deployments' into main
13 files changed,
+254
−22
internal/console/confirm.go+2 −2internal/console/deployments.go+92 −0internal/console/deployments_test.go+80 −0internal/console/server.go+20 −8internal/console/server_test.go+2 −2internal/console/templates/app_analytics.html+1 −0internal/console/templates/app_deployments.html+34 −0internal/console/templates/app_errors.html+1 −0internal/console/templates/app_logs.html+1 −0internal/console/templates/app_overview.html+5 −5internal/console/templates/app_releases.html+6 −1internal/console/templates/app_settings.html+1 −1internal/console/templates/appnav.html+9 −3
CI passed — run details
diff --git a/internal/console/confirm.go b/internal/console/confirm.go| index 5a65152aa..1f5a936db 100644 |
| --- a/internal/console/confirm.go |
| +++ b/internal/console/confirm.go |
| @@ -84,8 +84,8 @@ func (s *Server) renderConfirm(w http.ResponseWriter, r *http.Request, addr, raw |
| // backTab maps the action forms' back field onto the tab a cancel link (and |
| // nothing else) should land on. Only values the redirects themselves honor. |
| func backTab(r *http.Request) string { |
| - if r.FormValue("back") == "releases" { |
| - return "releases" |
| + if tab := releaseBackTab(r); tab != "" { |
| + return tab |
| } |
| return "" |
| } |
diff --git a/internal/console/deployments.go b/internal/console/deployments.go| new file mode 100644 |
| index 000000000..2acb62939 |
| --- /dev/null |
| +++ b/internal/console/deployments.go |
| @@ -0,0 +1,92 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "sort" |
| +) |
| + |
| +type deploymentTarget struct { |
| + Channel string |
| + Target string |
| + TargetErr string |
| + HasHistory bool |
| + Total int |
| + Unknown int |
| + Versions []deploymentObservedVersion |
| +} |
| +type deploymentObservedVersion struct { |
| + Version string |
| + Count int |
| +} |
| +type deploymentsCopyStrings struct{ Title, History, NoTarget, Instances, Target, Observed, NoChannels string } |
| + |
| +var deploymentsCopy = deploymentsCopyStrings{ |
| + Title: "Deployments", |
| + History: "Release history", |
| + NoTarget: "No target published", |
| + Instances: "View instances", |
| + Target: "Target version", |
| + Observed: "Observed versions", |
| + NoChannels: "Open Release history for the commands to publish your first version.", |
| +} |
| + |
| +func (p page) DeploymentsCopy() deploymentsCopyStrings { return deploymentsCopy } |
| + |
| +func assembleDeploymentTargets(view *AppView, account string) []deploymentTarget { |
| + groups := map[string]*deploymentTarget{} |
| + for _, ch := range view.Channels { |
| + groups[ch.Name] = &deploymentTarget{Channel: ch.Name, Target: ch.Version, TargetErr: ch.Err, HasHistory: ch.HasHistory} |
| + } |
| + versions := map[string]map[string]int{} |
| + for _, rt := range view.Routes { |
| + if rt.Account != account { |
| + continue |
| + } |
| + ch := rt.Channel |
| + if ch == "" { |
| + ch = "stable" |
| + } |
| + if groups[ch] == nil { |
| + groups[ch] = &deploymentTarget{Channel: ch} |
| + } |
| + group := groups[ch] |
| + group.Total++ |
| + if rt.Version == "" { |
| + group.Unknown++ |
| + continue |
| + } |
| + if versions[ch] == nil { |
| + versions[ch] = map[string]int{} |
| + } |
| + versions[ch][rt.Version]++ |
| + } |
| + var names []string |
| + for name := range groups { |
| + names = append(names, name) |
| + } |
| + sort.Strings(names) |
| + out := make([]deploymentTarget, 0, len(names)) |
| + for _, name := range names { |
| + group := groups[name] |
| + var vs []string |
| + for v := range versions[name] { |
| + vs = append(vs, v) |
| + } |
| + sort.Strings(vs) |
| + for _, v := range vs { |
| + group.Versions = append(group.Versions, deploymentObservedVersion{Version: v, Count: versions[name][v]}) |
| + } |
| + out = append(out, *group) |
| + } |
| + return out |
| +} |
| + |
| +func releaseBackTab(r *http.Request) string { |
| + switch r.FormValue("back") { |
| + case "releases": |
| + return "releases" |
| + case "deployments": |
| + return "deployments" |
| + } |
| + return "" |
| +} |
diff --git a/internal/console/deployments_test.go b/internal/console/deployments_test.go| new file mode 100644 |
| index 000000000..42ac77249 |
| --- /dev/null |
| +++ b/internal/console/deployments_test.go |
| @@ -0,0 +1,80 @@ |
| +package console |
| + |
| +import ( |
| + "net/http/httptest" |
| + "net/url" |
| + "reflect" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestDeploymentTargetsKeepObservationSeparate(t *testing.T) { |
| + view := &AppView{ |
| + Channels: []ChannelView{{Name: "edge", Version: "new"}, {Name: "stable", Err: "unavailable"}, {Name: "empty"}}, |
| + Routes: []RouteView{ |
| + {Account: "ours", Channel: "edge", Version: "old"}, |
| + {Account: "ours", Channel: "edge", Version: "new"}, |
| + {Account: "ours", Channel: "edge", Version: "old"}, |
| + {Account: "ours", Channel: "edge"}, |
| + {Account: "ours", Channel: "canary/custom", Version: "custom"}, |
| + {Account: "ours", Version: "running"}, |
| + {Account: "theirs", Channel: "foreign", Version: "secret"}, |
| + }, |
| + } |
| + want := []deploymentTarget{ |
| + {Channel: "canary/custom", Total: 1, Versions: []deploymentObservedVersion{{Version: "custom", Count: 1}}}, |
| + {Channel: "edge", Target: "new", Total: 4, Unknown: 1, Versions: []deploymentObservedVersion{{Version: "new", Count: 1}, {Version: "old", Count: 2}}}, |
| + {Channel: "empty"}, |
| + {Channel: "stable", TargetErr: "unavailable", Total: 1, Versions: []deploymentObservedVersion{{Version: "running", Count: 1}}}, |
| + } |
| + if got := assembleDeploymentTargets(view, "ours"); !reflect.DeepEqual(got, want) { |
| + t.Fatalf("targets = %#v, want %#v", got, want) |
| + } |
| +} |
| + |
| +func TestDeploymentRoutesAndHistory(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass/deployments")) |
| + if status != 200 { |
| + t.Fatalf("deployments status = %d", status) |
| + } |
| + for _, want := range []string{`/apps/kass/releases#version-v1`, `/apps/kass/instances?channel=stable`, `/apps/kass/deployments" aria-current="page">`} { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("deployments missing %q", want) |
| + } |
| + } |
| + _, _, history := get(t, c, fx.u("/apps/kass/releases")) |
| + if !strings.Contains(history, `id="version-v1"`) { |
| + t.Error("history has no target version anchor") |
| + } |
| + assertNonMember404(t, fx, "/apps/kass/deployments") |
| +} |
| + |
| +func TestReleaseBackTabOnlyAcceptsLocalDestinations(t *testing.T) { |
| + for _, value := range []string{"releases", "deployments", "", "https://other.example/", "../settings", "//other.example"} { |
| + r := httptest.NewRequest("POST", "/", strings.NewReader(url.Values{"back": {value}}.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + want := "" |
| + if value == "releases" || value == "deployments" { |
| + want = value |
| + } |
| + if got := releaseBackTab(r); got != want { |
| + t.Errorf("back %q = %q, want %q", value, got, want) |
| + } |
| + } |
| +} |
| + |
| +func TestDeploymentsDoNotDependOnReleaseListing(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + fx.srv.cfg.Releases = listFailStore{fx.rel} |
| + status, _, body := get(t, c, fx.u("/apps/kass/deployments")) |
| + if status != 200 || strings.Contains(body, "Could not list this app") { |
| + t.Fatalf("deployments depended on release listing: status %d, body %s", status, body) |
| + } |
| + _, _, history := get(t, c, fx.u("/apps/kass/releases")) |
| + if !strings.Contains(history, "Could not list this app") { |
| + t.Fatal("fixture did not fail the release listing") |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 355a0e46f..0f8dcbd5a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1113,6 +1113,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| 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("GET /a/{sqid}/apps/{app}/deployments", s.handleAppReleases) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances) |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance) |
| @@ -1305,6 +1306,7 @@ type page struct { |
| InstanceChannels []string |
| InstanceUnknownChannel bool |
| InstanceListURL string |
| + DeploymentTargets []deploymentTarget |
| Title string |
| Address string // signed-in address, "" when signed out |
| @@ -2779,6 +2781,16 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| return |
| } |
| + if strings.HasSuffix(r.URL.Path, "/deployments") { |
| + p.DeploymentTargets = assembleDeploymentTargets(p.Detail, acct.Sqid) |
| + if pcs, err := s.store.PendingChangesFor(acct.ID, p.AppName, time.Now()); err != nil { |
| + log.Printf("console: pending changes %s/%s: %v", acct.Sqid, p.AppName, err) |
| + } else if len(pcs) > 0 { |
| + p.Pending = s.pendingChangeViews(pcs, s.pendingPipeline(r.Context(), acct, p.AppName)) |
| + } |
| + s.render(w, http.StatusOK, "app_deployments", p) |
| + return |
| + } |
| p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels) |
| p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline) |
| versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName) |
| @@ -3579,8 +3591,8 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| - if r.FormValue("back") == "releases" { |
| - dest += "/releases" |
| + if tab := releaseBackTab(r); tab != "" { |
| + dest += "/" + tab |
| } |
| http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| @@ -3817,8 +3829,8 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| - if r.FormValue("back") == "releases" { |
| - dest += "/releases" |
| + if tab := releaseBackTab(r); tab != "" { |
| + dest += "/" + tab |
| } |
| http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| @@ -4156,8 +4168,8 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| - if r.FormValue("back") == "releases" { |
| - dest += "/releases" |
| + if tab := releaseBackTab(r); tab != "" { |
| + dest += "/" + tab |
| } |
| http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
| @@ -4296,8 +4308,8 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| back := func(kind, msg string) { |
| s.setFlash(w, kind, msg) |
| dest := "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) |
| - if r.FormValue("back") == "releases" { |
| - dest += "/releases" |
| + if tab := releaseBackTab(r); tab != "" { |
| + dest += "/" + tab |
| } |
| http.Redirect(w, r, dest, http.StatusSeeOther) |
| } |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 15b06a5e2..5cd2e4203 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -569,7 +569,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { |
| t.Fatalf("overview still renders %q", gone) |
| } |
| } |
| - for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", "Versions"} { |
| + for _, want := range []string{`class="tiles stat-tiles"`, "Running", "Web addresses", `/apps/kass/releases`} { |
| if !strings.Contains(body, want) { |
| t.Fatalf("overview missing %q", want) |
| } |
| @@ -585,7 +585,7 @@ func TestReleasesTab(t *testing.T) { |
| t.Fatalf("GET releases tab = %d", status) |
| } |
| for _, want := range []string{ |
| - `aria-current="page">Versions</a>`, |
| + `/apps/kass/deployments" aria-current="page">`, |
| "<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (", |
| } { |
| if !strings.Contains(body, want) { |
diff --git a/internal/console/templates/app_analytics.html b/internal/console/templates/app_analytics.html| index 9ebee65d3..dbb3b841b 100644 |
| --- a/internal/console/templates/app_analytics.html |
| +++ b/internal/console/templates/app_analytics.html |
| @@ -2,6 +2,7 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +{{template "lognav" .}} |
| <h2>Analytics</h2> |
| {{if .AnalyticsErr}}<p class="banner error">{{.AnalyticsErr}}</p>{{end}} |
diff --git a/internal/console/templates/app_deployments.html b/internal/console/templates/app_deployments.html| new file mode 100644 |
| index 000000000..d86613886 |
| --- /dev/null |
| +++ b/internal/console/templates/app_deployments.html |
| @@ -0,0 +1,34 @@ |
| +{{define "app_deployments"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{template "appnav" .}} |
| +<h2>{{.DeploymentsCopy.Title}}</h2> |
| +<nav class="tabs" aria-label="{{.DeploymentsCopy.Title}}"> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/deployments" aria-current="page">{{.DeploymentsCopy.Title}}</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{.DeploymentsCopy.History}}</a> |
| +</nav> |
| +{{if .DeploymentTargets}} |
| +<div class="tablewrap"> |
| +<table> |
| + <thead><tr><th scope="col">Channel</th><th scope="col">{{.DeploymentsCopy.Target}}</th><th scope="col">{{.DeploymentsCopy.Observed}}</th><th scope="col">Instances</th><th scope="col"></th></tr></thead> |
| + <tbody> |
| + {{range .DeploymentTargets}} |
| + <tr> |
| + <td><code>{{.Channel}}</code></td> |
| + <td>{{if .TargetErr}}Can't tell right now{{else if .Target}}<a href="{{$.Nav}}/apps/{{$.AppName}}/releases#version-{{.Target}}"><code>{{.Target}}</code></a>{{else}}{{$.DeploymentsCopy.NoTarget}}{{end}}</td> |
| + <td>{{range .Versions}}<div>{{.Count}} × <a href="{{$.Nav}}/apps/{{$.AppName}}/releases#version-{{.Version}}"><code>{{.Version}}</code></a></div>{{end}}{{if .Unknown}}<div>{{.Unknown}} × —</div>{{end}}{{if eq .Total 0}}—{{end}}</td> |
| + <td>{{.Total}} <a href="{{$.Nav}}/apps/{{$.AppName}}/instances?channel={{.Channel}}">{{$.DeploymentsCopy.Instances}}</a></td> |
| + <td>{{if and $.CanPromote .HasHistory (not .TargetErr) (or $.Detail.Pipeline $.IsOwner (ne .Channel "stable"))}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.AppName}}/rollback"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"><input type="hidden" name="channel" value="{{.Channel}}"><input type="hidden" name="back" value="deployments"> |
| + <button type="submit" class="btn-sm btn-danger">Roll back</button> |
| + </form> |
| + {{end}}</td> |
| + </tr> |
| + {{end}} |
| + </tbody> |
| +</table> |
| +</div> |
| +{{else}}<p class="small">{{.DeploymentsCopy.NoChannels}}</p>{{end}} |
| +{{template "pending" .}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_errors.html b/internal/console/templates/app_errors.html| index d3cc17284..36210b847 100644 |
| --- a/internal/console/templates/app_errors.html |
| +++ b/internal/console/templates/app_errors.html |
| @@ -2,6 +2,7 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +{{template "lognav" .}} |
| <h2>Errors</h2> |
| {{if .ErrsErr}}<p class="banner error">{{.ErrsErr}}</p>{{end}} |
diff --git a/internal/console/templates/app_logs.html b/internal/console/templates/app_logs.html| index f4609771d..903a27789 100644 |
| --- a/internal/console/templates/app_logs.html |
| +++ b/internal/console/templates/app_logs.html |
| @@ -2,6 +2,7 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +{{template "lognav" .}} |
| <h2>Logs</h2> |
| {{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}} |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index dd3a53097..131d17b33 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -13,7 +13,7 @@ |
| <p>We can't tell what's live right now — the record of what's live didn't read. It usually recovers on its own; check again shortly.</p> |
| {{else}} |
| <p>Nothing is live yet. Publish a first version from your terminal — the command is on the |
| - <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| + <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">{{$.DeploymentsCopy.History}}</a> tab.</p> |
| {{end}} |
| </section> |
| {{end}} |
| @@ -40,7 +40,7 @@ |
| <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}DNS & certs OK{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases"> |
| - <h2>Versions</h2> |
| + <h2>{{$.DeploymentsCopy.History}}</h2> |
| <p class="tile-value">{{.ReleaseCount}}{{if .ReleasesCapped}}+{{end}}</p> |
| <p class="tile-detail">{{if .LatestVersion}}latest <code>{{.LatestVersion}}</code>{{else}}nothing shipped yet{{end}}</p> |
| </a> |
| @@ -54,13 +54,13 @@ |
| {{if not .Detail.Pipeline}} |
| {{with .Detail.Production}} |
| {{if .Err}} |
| -<p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| +<p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">{{$.DeploymentsCopy.History}}</a> tab.</p> |
| {{else if .On}} |
| -<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.</p> |
| +<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">{{$.DeploymentsCopy.History}}</a>.</p> |
| {{else if .TurningOff}} |
| <p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.</p> |
| {{else}} |
| -<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p> |
| +<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">{{$.DeploymentsCopy.History}}</a>.{{end}}</p> |
| {{end}} |
| {{end}} |
| {{end}} |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 1f73f349a..2f093403a 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -2,6 +2,11 @@ |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +<nav class="tabs" aria-label="{{.DeploymentsCopy.Title}}"> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/deployments">{{.DeploymentsCopy.Title}}</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/releases" aria-current="page">{{.DeploymentsCopy.History}}</a> |
| +</nav> |
| +<h2>{{.DeploymentsCopy.History}}</h2> |
| {{template "accountreviewbanner" .}} |
| {{with .ReleaseSummary}} |
| @@ -41,7 +46,7 @@ |
| <table> |
| <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if and .CanPromote (not .Detail.OneChannelPipeline)}}<th>Promote</th>{{end}}</tr> |
| {{range .Versions}} |
| - <tr> |
| + <tr id="version-{{.Version}}"> |
| <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td> |
| {{if .Err}} |
| <td colspan="3" class="error">{{.Err}}</td> |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index f1cd232d1..d6a17e9cd 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -406,7 +406,7 @@ this same card.</p> |
| (custom domains included), and it is restorable for |
| {{trashCooloffDays}} days before the platform destroys its releases for |
| good.{{if .Detail.Production.On}} It is marked as production right now, which |
| -blocks deletion — turn the safety delay off on the Versions tab first.{{end}}</p> |
| +blocks deletion — turn the safety delay off on the <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{.DeploymentsCopy.History}}</a> tab first.{{end}}</p> |
| <form method="post" action="{{.Base}}/apps/{{.Detail.App}}/delete" class="settings-row"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| <label class="visually-hidden" for="delete-confirm">Type the app's name to confirm</label> |
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html| index 6ab0ca34e..9f6da22b1 100644 |
| --- a/internal/console/templates/appnav.html |
| +++ b/internal/console/templates/appnav.html |
| @@ -5,12 +5,18 @@ |
| </div> |
| <nav class="tabs" aria-label="App sections"> |
| <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a> |
| - <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/deployments"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>{{.DeploymentsCopy.Title}}</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if or (eq .ActiveTab "logs") (eq .ActiveTab "errors") (eq .ActiveTab "analytics")}} aria-current="page"{{end}}>Logs</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a> |
| +</nav> |
| +{{end}} |
| + |
| +{{define "lognav"}} |
| +<nav class="tabs" aria-label="Logs"> |
| <a href="{{.Nav}}/apps/{{.AppName}}/logs"{{if eq .ActiveTab "logs"}} aria-current="page"{{end}}>Logs</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/errors"{{if eq .ActiveTab "errors"}} aria-current="page"{{end}}>Errors</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/analytics"{{if eq .ActiveTab "analytics"}} aria-current="page"{{end}}>Analytics</a> |
| - <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a> |
| - <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a> |
| </nav> |
| {{end}} |