console: owner app metrics — own units only, strict boundary
The app page gains a Resources section: per instance-host CPU and memory with 48h sparklines, read off the Task-10 sweep's single shared cache. OwnerAppMetrics filters that sweep to EXACTLY carlos-app@<host>.service for the hosts the caller allowlisted — handleApp builds that allowlist from the app's own routes, after the unchanged accountApp gate, and that slice is the whole tenant boundary. No host totals, no other tenants' units, no alert data, and no box label can reach the page; the tests seed a second tenant's unit and host sentinels on the same box and assert their absence from the body. Every allowlisted host gets a row whether or not the sweep saw it: an absent instance renders "asleep — no recent readings", never zeros and never silently dropped. A failed sweep is logged and costs the section, never the page. UnitRow gains its own sparklines (a unit rendered alone has no host row to borrow a shape from); memory, which has no 0..1 ceiling the console may know, is drawn against the unit's own 48h peak.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+368
−5
internal/console/metricsview.go+148 −5internal/console/metricsview_test.go+91 −0internal/console/server.go+19 −0internal/console/server_test.go+86 −0internal/console/templates/app.html+24 −0
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go| index 92a4bafea..cd00557bc 100644 |
| --- a/internal/console/metricsview.go |
| +++ b/internal/console/metricsview.go |
| @@ -20,15 +20,19 @@ import ( |
| "sync" |
| "time" |
| + "github.com/carlosframework/platform/internal/activator" |
| "github.com/carlosframework/platform/internal/metrics" |
| ) |
| -// UnitRow is one systemd unit's latest reading, ready to render. |
| +// UnitRow is one systemd unit's latest reading, ready to render, plus its |
| +// own 48h sparklines (the owner app-metrics view renders a unit on its own, |
| +// with no host row to borrow a shape from). |
| type UnitRow struct { |
| Unit string // full unit name |
| CPUFrac *float64 |
| MemBytes *int64 |
| ReadBytes, WriteBytes *int64 |
| + SparkCPU, SparkMem string // SVG polyline points, "" when no data |
| } |
| // BoxMetricsView is one box's assembled dashboard row: latest host readings |
| @@ -196,16 +200,22 @@ func buildBoxView(box string, samples []metrics.Sample) BoxMetricsView { |
| view.SparkMem = sparkPoints(mem, 240, 40) |
| view.SparkDisk = sparkPoints(disk, 240, 40) |
| - view.Units = buildUnitRows(latest.Units) |
| + view.Units = buildUnitRows(samples) |
| return view |
| } |
| // buildUnitRows turns the latest sample's Units map into rows sorted by |
| -// CPUFrac descending, nil-CPU rows last, stable by unit name within ties. A |
| +// CPUFrac descending, nil-CPU rows last, stable by unit name within ties, |
| +// each carrying its own sparklines drawn across the whole swept window. A |
| // unit absent from the latest sample's map simply isn't in this slice — |
| // never rendered, never aggregated as a zero (Task 4's "missing map key is |
| -// no reading" ruling, same map shape here). |
| -func buildUnitRows(units map[string]metrics.UnitSample) []UnitRow { |
| +// no reading" ruling, same map shape here) — even if it appears in older |
| +// samples: the row set is "what is running now", not "what ever ran". |
| +func buildUnitRows(samples []metrics.Sample) []UnitRow { |
| + if len(samples) == 0 { |
| + return nil |
| + } |
| + units := samples[len(samples)-1].Units |
| rows := make([]UnitRow, 0, len(units)) |
| for name, u := range units { |
| rows = append(rows, UnitRow{ |
| @@ -214,6 +224,8 @@ func buildUnitRows(units map[string]metrics.UnitSample) []UnitRow { |
| MemBytes: u.MemBytes, |
| ReadBytes: u.ReadBytes, |
| WriteBytes: u.WriteBytes, |
| + SparkCPU: sparkPoints(unitCPUSeries(samples, name), 240, 40), |
| + SparkMem: sparkPoints(unitMemSeries(samples, name), 240, 40), |
| }) |
| } |
| // Name order first so the CPU-desc sort below, being stable, ties out |
| @@ -232,6 +244,137 @@ func buildUnitRows(units map[string]metrics.UnitSample) []UnitRow { |
| return rows |
| } |
| +// unitCPUSeries is one unit's CPU fraction at each swept sample index. A |
| +// sample where the unit is absent, or present without a CPU reading, stays |
| +// nil — sparkPoints draws a gap, never a zero. |
| +func unitCPUSeries(samples []metrics.Sample, unit string) []*float64 { |
| + out := make([]*float64, len(samples)) |
| + for i, sm := range samples { |
| + if u, ok := sm.Units[unit]; ok { |
| + out[i] = u.CPUFrac |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// unitMemSeries is one unit's memory at each swept sample index, scaled to |
| +// its OWN 48h peak. Unlike CPU, a unit's memory has no 0..1 ceiling the |
| +// console can know — the cgroup limit isn't published, and the box's total |
| +// RAM is deliberately not in an owner's view — so the sparkline shows the |
| +// shape of this instance's memory against its own high-water mark, and the |
| +// number beside it stays the absolute byte count. Absent/nil readings stay |
| +// nil; a unit that never reported memory gets no line at all. |
| +func unitMemSeries(samples []metrics.Sample, unit string) []*float64 { |
| + var peak int64 |
| + for _, sm := range samples { |
| + if u, ok := sm.Units[unit]; ok && u.MemBytes != nil && *u.MemBytes > peak { |
| + peak = *u.MemBytes |
| + } |
| + } |
| + if peak <= 0 { |
| + return nil |
| + } |
| + out := make([]*float64, len(samples)) |
| + for i, sm := range samples { |
| + u, ok := sm.Units[unit] |
| + if !ok || u.MemBytes == nil { |
| + continue |
| + } |
| + f := float64(*u.MemBytes) / float64(peak) |
| + out[i] = &f |
| + } |
| + return out |
| +} |
| + |
| +// InstanceMetrics is one of an APP OWNER's instances, named the way owners |
| +// name their instances: by route host. It carries that instance's unit |
| +// readings and nothing else — no host totals, no sibling units, no alerts. |
| +// Box is here for internal use (picking the freshest reporting box when a |
| +// host has moved); the app page must not print it, because which box a |
| +// tenant sits on is not an owner's business. |
| +type InstanceMetrics struct { |
| + Host string // the route host — how owners name their instances |
| + Box string |
| + CPUFrac *float64 |
| + MemBytes *int64 |
| + |
| + SparkCPU, SparkMem string |
| +} |
| + |
| +// Reporting is the template's one question: did any box report this |
| +// instance's unit in the last sweep? False is the hibernating/absent case — |
| +// rendered as "asleep — no recent readings", never as zeros. (Reporting with |
| +// nil readings is a different, honest state: the unit is up but that metric |
| +// was unreadable, which renders as the usual em-dash.) |
| +func (m InstanceMetrics) Reporting() bool { return m.Box != "" } |
| + |
| +// OwnerAppMetrics filters the cached 48h sweep down to EXACTLY the units |
| +// carlos-app@<host>.service for the given hosts. It returns unit-level rows |
| +// only — no host totals, no other units, no alert data. The allowlist is the |
| +// hosts slice the caller derived from the app's OWN routes; nothing else can |
| +// enter the view. |
| +// |
| +// Every allowlisted host gets exactly one row, in host order, whether or not |
| +// the sweep saw it: a host with no unit data is an UNKNOWN row (nil |
| +// readings, Reporting false), never zeros and never silently dropped — an |
| +// owner whose instance is asleep must still see it listed. |
| +func (s *Server) OwnerAppMetrics(ctx context.Context, hosts []string) ([]InstanceMetrics, error) { |
| + // The allowlist, keyed by the one unit name each host can legally match. |
| + // Matching is exact-string on this map: a unit the caller did not ask |
| + // for cannot be reached by any path below. |
| + want := make(map[string]string, len(hosts)) |
| + for _, h := range hosts { |
| + if h == "" { |
| + continue |
| + } |
| + want[activator.UnitName(h)] = h |
| + } |
| + if len(want) == 0 { |
| + return nil, nil |
| + } |
| + |
| + boxes, _, err := s.LoadMetrics(ctx) |
| + if err != nil { |
| + return nil, err |
| + } |
| + |
| + found := make(map[string]InstanceMetrics, len(want)) |
| + at := make(map[string]time.Time, len(want)) |
| + for _, box := range boxes { |
| + for _, u := range box.Units { |
| + host, ok := want[u.Unit] |
| + if !ok { |
| + continue // not this app's — the whole boundary is this line |
| + } |
| + // A host that reports on two boxes at once (a move in flight) |
| + // shows the freshest box's reading, not whichever sorted first. |
| + if prev, taken := at[host]; taken && !box.At.After(prev) { |
| + continue |
| + } |
| + at[host] = box.At |
| + found[host] = InstanceMetrics{ |
| + Host: host, |
| + Box: box.Box, |
| + CPUFrac: u.CPUFrac, |
| + MemBytes: u.MemBytes, |
| + SparkCPU: u.SparkCPU, |
| + SparkMem: u.SparkMem, |
| + } |
| + } |
| + } |
| + |
| + rows := make([]InstanceMetrics, 0, len(want)) |
| + for _, host := range want { |
| + if row, ok := found[host]; ok { |
| + rows = append(rows, row) |
| + } else { |
| + rows = append(rows, InstanceMetrics{Host: host}) |
| + } |
| + } |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + return rows, nil |
| +} |
| + |
| // sweepAlerts reads every alerts/boxes/*.json and returns one AlertRow per |
| // currently-firing rule. Unreadable-but-present keys fail the sweep (so it |
| // is never cached half-done); an unparsable object is skipped, matching |
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go| index 36c67dea3..e23e05714 100644 |
| --- a/internal/console/metricsview_test.go |
| +++ b/internal/console/metricsview_test.go |
| @@ -186,6 +186,97 @@ func TestLoadMetricsEmptyBucket(t *testing.T) { |
| } |
| } |
| +// TestOwnerAppMetricsEmptyHosts: an app with no routes asks for nothing and |
| +// gets nothing — empty, no error, and (proven by the poisoned-free fixture |
| +// having no boxes at all) never an error path the app page has to handle. |
| +func TestOwnerAppMetricsEmptyHosts(t *testing.T) { |
| + srv, _ := newMetricsFixture(t) |
| + rows, err := srv.OwnerAppMetrics(context.Background(), nil) |
| + if err != nil { |
| + t.Fatalf("empty hosts errored: %v", err) |
| + } |
| + if len(rows) != 0 { |
| + t.Fatalf("empty hosts = %+v, want no rows", rows) |
| + } |
| + rows, err = srv.OwnerAppMetrics(context.Background(), []string{}) |
| + if err != nil || len(rows) != 0 { |
| + t.Fatalf("empty slice = %+v, %v; want no rows, no error", rows, err) |
| + } |
| +} |
| + |
| +// TestOwnerAppMetricsFiltersToOwnUnits is the boundary at the function level: |
| +// one box carries this owner's unit, another tenant's unit, and host-level |
| +// readings; only the owner's unit row comes back, and a host in the allowlist |
| +// with no unit data in the sweep comes back as an unknown (nil) row, never |
| +// zeros and never dropped. |
| +func TestOwnerAppMetricsFiltersToOwnUnits(t *testing.T) { |
| + srv, rel := newMetricsFixture(t) |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-5 * time.Minute) |
| + if err := metrics.WriteRaw(ctx, rel, metrics.RawChunk{ |
| + V: 1, Box: "box-secret-a", Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + Host: metrics.HostSample{CPUFrac: p(0.777), MemFrac: p(0.888), DiskFrac: p(0.999)}, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, |
| + "carlos-app@other-tenant.example.com.service": {CPUFrac: p(0.55), MemBytes: pi64(4096)}, |
| + "carlos-litestream.service": {CPUFrac: p(0.66), MemBytes: pi64(8192)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + rows, err := srv.OwnerAppMetrics(ctx, []string{"owned.example.com", "asleep.example.com"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(rows) != 2 { |
| + t.Fatalf("rows = %+v, want exactly 2 (the two allowlisted hosts)", rows) |
| + } |
| + byHost := map[string]InstanceMetrics{} |
| + for _, r := range rows { |
| + byHost[r.Host] = r |
| + } |
| + owned, ok := byHost["owned.example.com"] |
| + if !ok { |
| + t.Fatalf("owned.example.com missing from %+v", rows) |
| + } |
| + if owned.CPUFrac == nil || *owned.CPUFrac != 0.11 { |
| + t.Fatalf("owned CPU = %v, want 0.11", owned.CPUFrac) |
| + } |
| + if owned.MemBytes == nil || *owned.MemBytes != 1024 { |
| + t.Fatalf("owned mem = %v, want 1024", owned.MemBytes) |
| + } |
| + if owned.SparkCPU == "" || owned.SparkMem == "" { |
| + t.Fatalf("owned sparklines empty: %+v", owned) |
| + } |
| + if !owned.Reporting() { |
| + t.Fatalf("owned should be reporting: %+v", owned) |
| + } |
| + asleep, ok := byHost["asleep.example.com"] |
| + if !ok { |
| + t.Fatalf("asleep.example.com dropped instead of rendering as unknown: %+v", rows) |
| + } |
| + if asleep.CPUFrac != nil || asleep.MemBytes != nil { |
| + t.Fatalf("a host with no unit data must be nil (unknown), not zero: %+v", asleep) |
| + } |
| + if asleep.Reporting() { |
| + t.Fatalf("asleep must not report: %+v", asleep) |
| + } |
| + // Nothing else can enter the view: no other tenant's unit, no non-app |
| + // unit, no host row. |
| + for _, r := range rows { |
| + if r.Host != "owned.example.com" && r.Host != "asleep.example.com" { |
| + t.Fatalf("row outside the allowlist: %+v", r) |
| + } |
| + if r.CPUFrac != nil && (*r.CPUFrac == 0.55 || *r.CPUFrac == 0.66 || *r.CPUFrac == 0.777) { |
| + t.Fatalf("row carries another unit's or the host's reading: %+v", r) |
| + } |
| + } |
| +} |
| + |
| // TestSparkPointsInvertsYAndSkipsNil pins the coordinate math the brief |
| // calls out explicitly: a nil sample contributes no pair at all (not a |
| // zero), and a higher fraction plots at a SMALLER y (SVG's y axis points |
diff --git a/internal/console/server.go b/internal/console/server.go| index 54ca6ba9e..913f68071 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -294,6 +294,11 @@ type page struct { |
| // alerts are the normal, healthy, empty state, not an error. |
| Metrics []BoxMetricsView |
| MetricAlerts []AlertRow |
| + |
| + // App page, "Resources": this app's OWN instances' unit readings (Task |
| + // 12), one row per route host. Nil renders no section at all — metrics |
| + // are decoration on this page and must never be the reason it fails. |
| + AppMetrics []InstanceMetrics |
| } |
| // IsOwner is the template's one permission question. |
| @@ -1114,6 +1119,20 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| } |
| p.Versions = versions |
| p.Instances = s.instances.Report(r.Context(), views[0].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. |
| + hosts := make([]string, 0, len(views[0].Routes)) |
| + for _, rt := range views[0].Routes { |
| + hosts = append(hosts, rt.Host) |
| + } |
| + if am, err := s.OwnerAppMetrics(r.Context(), hosts); err != nil { |
| + log.Printf("console: app metrics %s: %v", app, err) |
| + } else { |
| + p.AppMetrics = am |
| + } |
| if events, err := s.store.Events(acct.ID, app, eventsCap); err != nil { |
| log.Printf("console: events %s: %v", app, err) |
| } else { |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 9e67ef982..a977dcbd4 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1201,3 +1201,89 @@ func TestFleetMetricsEmptyState(t *testing.T) { |
| t.Fatalf("empty /fleet/metrics: %d %s", status, body) |
| } |
| } |
| + |
| +// TestAppPageOwnerMetricsBoundary is Task 12's boundary, end to end: one box |
| +// carries this owner's unit, ANOTHER tenant's unit, and host-level readings. |
| +// The owner's app page shows their own instance's numbers and nothing else — |
| +// no other tenant's unit, no host totals, no box name. A route with no unit |
| +// data in the sweep (hibernating) renders as asleep, never as zeros. |
| +func TestAppPageOwnerMetricsBoundary(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", |
| + Status: "ready", App: "kass", Backing: registry.BackingUnit}) |
| + fx.addRoute(t, registry.Route{Host: "asleep.example.com", Kind: "instance", |
| + Status: "ready", App: "kass", Backing: registry.BackingUnit, Hibernate: true}) |
| + |
| + ctx := context.Background() |
| + at := time.Now().UTC().Add(-5 * time.Minute) |
| + if err := metrics.WriteRaw(ctx, fx.rel, metrics.RawChunk{ |
| + V: 1, Box: "box-secret-a", Hour: metrics.HourOf(at), |
| + Samples: []metrics.Sample{{ |
| + At: at, |
| + // Host sentinels chosen so their rendered forms ("78%", "89%", |
| + // "44%") cannot collide with any unit number on this page — and |
| + // deliberately NOT 0.999, whose "100%" collides with the |
| + // layout's own `width: 100%` CSS. |
| + Host: metrics.HostSample{CPUFrac: p(0.777), MemFrac: p(0.888), DiskFrac: p(0.444)}, |
| + Units: map[string]metrics.UnitSample{ |
| + "carlos-app@owned.example.com.service": {CPUFrac: p(0.11), MemBytes: pi64(1024)}, |
| + "carlos-app@other-tenant.example.com.service": {CPUFrac: p(0.55), MemBytes: pi64(4096)}, |
| + }, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass")) |
| + if status != http.StatusOK { |
| + t.Fatalf("/apps/kass = %d, want 200: %s", status, body) |
| + } |
| + for _, want := range []string{ |
| + "Resources", "owned.example.com", "11%", "1.0 KB", |
| + "asleep.example.com", "asleep — no recent readings", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("app page missing %q in:\n%s", want, body) |
| + } |
| + } |
| + // The boundary: nothing about the other tenant, the host, or the box. |
| + for _, forbidden := range []string{ |
| + "other-tenant", "box-secret-a", // other tenant's unit, the box's name |
| + "55%", "4.0 KB", // the other tenant's readings |
| + "77.7", "78%", "89%", "44%", // host CPU/mem/disk totals |
| + } { |
| + if strings.Contains(body, forbidden) { |
| + t.Fatalf("app page leaked %q in:\n%s", forbidden, body) |
| + } |
| + } |
| +} |
| + |
| +// TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that |
| +// fails every read must still leave the owner a fully rendered app page. |
| +func TestAppPageSurvivesMetricsFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance", |
| + Status: "ready", App: "kass", Backing: registry.BackingUnit}) |
| + fx.srv.cfg.Releases = failingListStore{fx.rel} |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass")) |
| + if status != http.StatusOK { |
| + t.Fatalf("/apps/kass with a broken metrics read = %d, want 200: %s", status, body) |
| + } |
| + if !strings.Contains(body, "Domains") { |
| + t.Fatalf("app page did not render its normal sections: %s", body) |
| + } |
| +} |
| + |
| +// failingListStore fails exactly the List the metrics sweep starts with, |
| +// leaving every other read (channels, manifests) working — the shape of a |
| +// bucket permission gap on metrics/ alone. |
| +type failingListStore struct{ release.Store } |
| + |
| +func (s failingListStore) List(ctx context.Context, prefix string) ([]string, error) { |
| + if strings.HasPrefix(prefix, "metrics/") { |
| + return nil, errors.New("boom") |
| + } |
| + return s.Store.List(ctx, prefix) |
| +} |
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html| index fbb3fc1a1..d25b31297 100644 |
| --- a/internal/console/templates/app.html |
| +++ b/internal/console/templates/app.html |
| @@ -58,6 +58,30 @@ |
| {{end}} |
| {{end}} |
| +{{if .AppMetrics}} |
| +<h2>Resources</h2> |
| +<p class="small">CPU and memory for each of this app's instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance'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}} |
| +<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p> |
| +{{end}} |
| +{{end}} |
| +{{end}} |
| + |
| <h2>Channels</h2> |
| {{if .Detail.Channels}} |
| <div class="tablewrap"> |