carlos / platform Public

console: instances tab; the tenant-bounded metrics sweep becomes a helper

Boundary comment and behavior move intact — the old page and the new
tab share the one implementation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e1e9101848fe4e99023875f28fc1a6f171197a86 parent 89e92de
3 files changed, +122 −32
  • internal/console/server.go +50 −32
  • internal/console/server_test.go +13 −0
  • internal/console/templates/app_instances.html +59 −0
diff --git a/internal/console/server.go b/internal/console/server.go
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
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 835fc5c4b..3a05a1476 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -507,6 +507,19 @@ func TestReleasesTab(t *testing.T) {
assertNonMember404(t, fx, "/apps/kass/releases")
}
+func TestInstancesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != 200 {
+ t.Fatalf("GET instances tab = %d", status)
+ }
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatal("tab not active")
+ }
+ assertNonMember404(t, fx, "/apps/kass/instances")
+}
+
// 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_instances.html b/internal/console/templates/app_instances.html
new file mode 100644
index 000000000..5463f5085
--- /dev/null
+++ b/internal/console/templates/app_instances.html
@@ -0,0 +1,59 @@
+{{define "app_instances"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+
+{{if and .Instances .Instances.Reporting}}
+<h2>Instances</h2>
+<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
+{{if .Instances.Instances}}
+<div class="tablewrap">
+<table>
+ <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
+ {{range .Instances.Instances}}
+ <tr>
+ <td><code>{{.Slug}}</code></td>
+ <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
+ <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
+ <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+
+{{if .AppMetrics}}
+<h2>Resources</h2>
+<p class="small">CPU and memory for each of this app&#39;s instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance&#39;s own peak, and the number beside it is the current figure.</p>
+{{range .AppMetrics}}
+{{if .Reporting}}
+<p class="small"><code>{{.Host}}</code></p>
+<section class="tiles" aria-label="{{.Host}} resources">
+ <div class="tile">
+ <h2>CPU</h2>
+ <p class="tile-value">{{pct .CPUFrac}}</p>
+ <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>
+ </div>
+ <div class="tile">
+ <h2>Memory</h2>
+ <p class="tile-value">{{bytesOrDash .MemBytes}}</p>
+ <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg>
+ </div>
+</section>
+{{else}}
+{{if .Hibernate}}
+<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
+{{else}}
+<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
+{{end}}
+{{end}}
+{{end}}
+{{end}}
+
+{{if and (or (not .Instances) (not .Instances.Reporting)) (not .AppMetrics)}}
+<p class="small">Nothing to show yet — instances appear when the app serves
+<code>/.carlos/instances</code>, and resource readings when it runs on a
+reporting box.</p>
+{{end}}
+{{template "bottom" .}}{{end}}