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>
4 files changed,
+56
−8
internal/console/server.go+23 −7internal/console/server_test.go+19 −0internal/console/templates/app.html+1 −1internal/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}} |