| index 6c7885762..a20de8739 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -261,6 +261,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}/instances", s.handleAppInstances) |
| 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) |
| @@ -1293,6 +1294,42 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa |
| return p, acct, true |
| } |
| |
| +// appResourceMetrics sweeps 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 widened to "all routes" or taken from the request. A failed |
| +// sweep costs the section, never the page. |
| +// |
| +// The account comparison is load-bearing and not redundant with the |
| +// accountApp gate above: Assemble groups routes by app NAME, and app |
| +// names are unique per ACCOUNT, not globally (Store.AppAccounts) — two |
| +// accounts may each own an app called "api", and both accounts' routes |
| +// arrive in this one AppView. Comparing each route's own account is what |
| +// keeps one tenant's cgroup readings off the other's page. It fails |
| +// 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. |
| +func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics { |
| + hosts := make([]string, 0, len(view.Routes)) |
| + hib := make(map[string]bool, len(view.Routes)) |
| + for _, rt := range view.Routes { |
| + if rt.Account != acct.Sqid { |
| + continue |
| + } |
| + hosts = append(hosts, rt.Host) |
| + hib[rt.Host] = rt.Hibernate |
| + } |
| + am, err := s.OwnerAppMetrics(ctx, hosts) |
| + if err != nil { |
| + log.Printf("console: app metrics %s: %v", view.App, err) |
| + return nil |
| + } |
| + for i := range am { |
| + am[i].Hibernate = hib[am[i].Host] |
| + } |
| + return am |
| +} |
| + |
| func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.appPage(w, r, "overview") |
| if !ok { |
| @@ -1306,38 +1343,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| } |
| p.Versions = versions |
| 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 |
| - // widened to "all routes" or taken from the request. A failed sweep |
| - // costs the section, never the page. |
| - // |
| - // The account comparison is load-bearing and not redundant with the |
| - // accountApp gate above: Assemble groups routes by app NAME, and app |
| - // names are unique per ACCOUNT, not globally (Store.AppAccounts) — two |
| - // accounts may each own an app called "api", and both accounts' routes |
| - // arrive in this one AppView. Comparing each route's own account is what |
| - // keeps one tenant's cgroup readings off the other's page. It fails |
| - // 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(p.Detail.Routes)) |
| - hib := make(map[string]bool, len(p.Detail.Routes)) |
| - for _, rt := range p.Detail.Routes { |
| - if rt.Account != acct.Sqid { |
| - continue |
| - } |
| - hosts = append(hosts, rt.Host) |
| - hib[rt.Host] = rt.Hibernate |
| - } |
| - if am, err := s.OwnerAppMetrics(r.Context(), hosts); err != nil { |
| - log.Printf("console: app metrics %s: %v", app, err) |
| - } else { |
| - for i := range am { |
| - am[i].Hibernate = hib[am[i].Host] |
| - } |
| - p.AppMetrics = am |
| - } |
| + p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| if events, err := s.store.Events(acct.ID, app, eventsCap); err != nil { |
| log.Printf("console: events %s: %v", app, err) |
| } else { |
| @@ -1362,6 +1368,18 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app_releases", p) |
| } |
| |
| +// handleAppInstances is what the app is actually running: its self-reported |
| +// instances, and each one's CPU/memory on the box it runs on. |
| +func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| + p, acct, ok := s.appPage(w, r, "instances") |
| + if !ok { |
| + return |
| + } |
| + p.Instances = s.instances.Report(r.Context(), p.Detail.Routes) |
| + p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| + s.render(w, http.StatusOK, "app_instances", p) |
| +} |
| + |
| // eventsCap bounds the app page's recent-activity list. |
| const eventsCap = 10 |
| |