carlos / platform Public

console: app pages get their tab bar and shared appPage chrome

Tabs land page by page next; the one-page view keeps serving until the
last tab exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell dcad8850268f5374c922f3c3bc0fa417e7a0f80e parent 76edb5b
4 files changed, +56 −8
  • internal/console/server.go +23 −7
  • internal/console/server_test.go +19 −0
  • internal/console/templates/app.html +1 −1
  • internal/console/templates/appnav.html +13 −0
diff --git a/internal/console/server.go b/internal/console/server.go
index 518bb2e65..29823d184 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -324,6 +324,9 @@ type page struct {
ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+ ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
+ AppName string // app pages: the {app} segment, for the tab bar and eyebrow
+
SubmittedAddr string // interstitial: the address the user typed
KeymailURL string // interstitial: link to that address's keymail host
@@ -1263,10 +1266,12 @@ func (s *Server) accountApp(w http.ResponseWriter, r *http.Request) (addr, raw s
return addr, raw, acct, role, app, true
}
-func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+// appPage gates an app page, assembles its platform view, and fills the
+// chrome every app tab shares. Callers add their own tab's data on top.
+func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) {
addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
- return
+ return page{}, nil, false
}
// The account manifest is re-attempted on view: minting writes it, but a
// bucket hiccup there is deliberately non-fatal, and this is where it gets
@@ -1276,19 +1281,30 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
if err != nil || len(views) != 1 {
log.Printf("console: assemble %s: %v", app, err)
s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
- return
+ return page{}, nil, false
}
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = app
+ p.AppName = app
+ p.ActiveTab = tab
p.ActiveNav = "apps"
p.Detail = &views[0]
+ return p, acct, true
+}
+
+func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "overview")
+ if !ok {
+ return
+ }
+ app := p.Detail.App
versions, err := Versions(s.cfg.Releases, acct.Sqid, app)
if err != nil {
log.Printf("console: versions %s: %v", app, err)
p.FlashErr = "Could not list this app's releases."
}
p.Versions = versions
- p.Instances = s.instances.Report(r.Context(), views[0].Routes)
+ p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
// Resources: the app's own instances only. The allowlist is built HERE,
// from the routes this account's app owns and nothing else — that slice
// is the entire tenant boundary on the metrics read, so it must never be
@@ -1304,9 +1320,9 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
// closed: a route stamped with an account the console record disagrees
// with (the half-migrated state AccountMismatch describes) yields no
// metrics rather than someone else's.
- hosts := make([]string, 0, len(views[0].Routes))
- hib := make(map[string]bool, len(views[0].Routes))
- for _, rt := range views[0].Routes {
+ hosts := make([]string, 0, len(p.Detail.Routes))
+ hib := make(map[string]bool, len(p.Detail.Routes))
+ for _, rt := range p.Detail.Routes {
if rt.Account != acct.Sqid {
continue
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index ad786d45a..dc3446686 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -469,6 +469,25 @@ func TestCreateApp(t *testing.T) {
}
}
+func TestAppTabBar(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ base := strings.TrimSuffix(home, "/") + "/apps/kass"
+ _, _, body := get(t, c, fx.ts.URL+base)
+ for _, want := range []string{
+ `class="tabs"`,
+ `href="` + base + `"`, `href="` + base + `/releases"`,
+ `href="` + base + `/instances"`, `href="` + base + `/activity"`,
+ `href="` + base + `/settings"`,
+ `aria-current="page">Overview</a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app page missing %q", want)
+ }
+ }
+}
+
// 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.
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
index 44a0cf899..df603a2bc 100644
--- a/internal/console/templates/app.html
+++ b/internal/console/templates/app.html
@@ -1,7 +1,7 @@
{{define "app"}}{{template "top" .}}
-<h1>{{.Detail.App}}</h1>
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
{{if .Detail.AccountMismatch}}
<p class="banner error">
<strong>This app is not deploying.</strong> Its hosts read releases from
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
new file mode 100644
index 000000000..4f3b07c7f
--- /dev/null
+++ b/internal/console/templates/appnav.html
@@ -0,0 +1,13 @@
+{{define "appnav"}}
+<div class="pagehead">
+ <p class="eyebrow">App · {{.Account.Name}}</p>
+ <h1>{{.AppName}}</h1>
+</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}}>Releases</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</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}}