carlos / platform Public

Merge origin/main into console-ux-01-status

Paul Campbell pushed by paul@keymail.dev 72bcb5dcd596378376b837ffccad6292bbf6430c parent 6157e34parent d73bd1b
13 files changed, +253 −21
  • internal/console/confirm.go +2 −2
  • internal/console/deployments.go +92 −0
  • internal/console/deployments_test.go +80 −0
  • internal/console/server.go +20 −8
  • internal/console/server_test.go +2 −2
  • internal/console/templates/app_analytics.html +1 −0
  • internal/console/templates/app_deployments.html +34 −0
  • internal/console/templates/app_errors.html +1 −0
  • internal/console/templates/app_logs.html +1 −0
  • internal/console/templates/app_overview.html +4 −4
  • internal/console/templates/app_releases.html +6 −1
  • internal/console/templates/app_settings.html +1 −1
  • internal/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 b8622d27e..0bcf3ebbd 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
@@ -2781,6 +2783,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.Detail.Routes, acct.Sqid)
p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline)
versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
@@ -3581,8 +3593,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)
}
@@ -3819,8 +3831,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)
}
@@ -4158,8 +4170,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)
}
@@ -4298,8 +4310,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 a5aa219be..9a0af8938 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 a97a68432..1ed17eaab 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -43,7 +43,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 &amp; 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>
@@ -57,13 +57,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 f703e26ab..78febd318 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}}
@@ -46,7 +51,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}}