carlos / platform Public

console: the instance page's Layout fact (plan 4, task 5, step 4)

instanceDetail.Layout carries the WORD a box's layout report gives
this host, never the reason (R10) — filled in handleAppInstance from
one systemBoxes read (platform boxes plus fleet boxes), falling back
to the approved "not reported yet" text when no box names the host or
no bucket is configured. RouteView and Assemble untouched, per R10.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev fddb8f846696ec2947985a2ad3f2f1b33fe080d2 parent 10db5c4
3 files changed, +91 −2
  • internal/console/instancepage.go +37 −1
  • internal/console/instancepage_test.go +53 −1
  • internal/console/templates/app_instance.html +1 −0
diff --git a/internal/console/instancepage.go b/internal/console/instancepage.go
index 23400b7f8..7d3236754 100644
--- a/internal/console/instancepage.go
+++ b/internal/console/instancepage.go
@@ -8,6 +8,7 @@ import (
"sort"
"github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/status"
)
type instanceDetail struct {
@@ -15,6 +16,32 @@ type instanceDetail struct {
TargetVersion string
TargetErr string
Aliases []string
+ // Layout is the WORD the box's layout pass classified this host as
+ // (flat, nested, fenced, conflict, foreign, external) — never the
+ // reason, which can carry a path (R10). The approved fallback text
+ // when no box's report names this host.
+ Layout string
+}
+
+// layoutUnknownCopy is console.instance.layout.unknown (approved copy):
+// what a member sees when no box's snapshot has reported this host's
+// layout at all — a box running code from before layout reporting
+// existed, or one whose activator has not armed a pass yet.
+const layoutUnknownCopy = "not reported yet"
+
+// layoutWordFor is R10's join: the first box whose layout report names
+// host wins, and only the word is carried through — the reason is an
+// operator-only detail (it can carry a path), never shown to a member.
+func layoutWordFor(boxes []status.Box, host string) string {
+ for _, b := range boxes {
+ if b.Metrics == nil || b.Metrics.Layout == nil {
+ continue
+ }
+ if row, ok := b.Metrics.Layout.Rows[host]; ok {
+ return row.Layout
+ }
+ }
+ return layoutUnknownCopy
}
type instanceGroup struct {
@@ -140,13 +167,22 @@ func (s *Server) handleAppInstance(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
- detail := &instanceDetail{Row: rows[0]}
+ detail := &instanceDetail{Row: rows[0], Layout: layoutUnknownCopy}
for _, ch := range p.Detail.Channels {
if ch.Name == selected.Channel {
detail.TargetVersion, detail.TargetErr = ch.Version, ch.Err
break
}
}
+ // One LoadBoxes read (plus fleet boxes, through the same systemBoxes
+ // helper the console's other box readers use) — nil Releases or a read
+ // error both leave the approved unknown text already set above, the
+ // same soft-degrade the alias lookup just below gives a broken registry.
+ if s.cfg.Releases != nil {
+ if boxes, _, err := s.systemBoxes(r.Context(), false); err == nil {
+ detail.Layout = layoutWordFor(boxes, host)
+ }
+ }
if reg, err := openRegistry(s.cfg.Registry); err == nil {
if aliases, err := reg.ListAliases(); err == nil {
for _, alias := range aliases {
diff --git a/internal/console/instancepage_test.go b/internal/console/instancepage_test.go
index a3dee4db4..0bc284284 100644
--- a/internal/console/instancepage_test.go
+++ b/internal/console/instancepage_test.go
@@ -1,12 +1,15 @@
package console
import (
- "github.com/carlosframework/platform/internal/registry"
"net/http"
"net/url"
"reflect"
"strings"
"testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
)
func TestInstanceGroupingPaginatesWithoutLoss(t *testing.T) {
@@ -219,3 +222,52 @@ func TestInstanceDetailRendersFlashBeforeNavigation(t *testing.T) {
t.Fatalf("flash=%d nav=%d", flash, nav)
}
}
+
+// TestInstanceDetailShowsLayoutWord pins R10: the instance page's Layout
+// fact shows the WORD a box's report gives this host, never the reason —
+// proven here with a foreign row, whose reason names paths a member has no
+// business seeing.
+func TestInstanceDetailShowsLayoutWord(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ seedLayoutBox(t, fx.rel, "box-a", time.Now(), &status.LayoutReport{
+ Mode: "nested",
+ Rows: map[string]status.RowLayout{
+ "kass.carlosapp.dev": {Layout: "foreign", Reason: "db \"/secret/path\" is not the flat or nested default"},
+ },
+ })
+ httpStatus, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev"))
+ if httpStatus != http.StatusOK {
+ t.Fatalf("%d: %s", httpStatus, body)
+ }
+ if !strings.Contains(body, "<dt>Layout</dt><dd>foreign</dd>") {
+ t.Errorf("missing the layout fact showing the word: %s", body)
+ }
+ if strings.Contains(body, "/secret/path") {
+ t.Errorf("the reason leaked onto the page: %s", body)
+ }
+}
+
+// TestInstanceDetailShowsLayoutUnknownWithNoReport: a box that has never
+// reported this host's layout (predates layout reporting, or has armed no
+// pass) must not silently omit the fact — the approved fallback text says
+// so, byte for byte.
+func TestInstanceDetailShowsLayoutUnknownWithNoReport(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ httpStatus, _, body := get(t, c, fx.u("/apps/kass/instances/kass.carlosapp.dev"))
+ if httpStatus != http.StatusOK {
+ t.Fatalf("%d: %s", httpStatus, body)
+ }
+ if !strings.Contains(body, "<dt>Layout</dt><dd>not reported yet</dd>") {
+ t.Errorf("missing the unknown-layout fallback: %s", body)
+ }
+}
+
+// The nil-Releases shape is not separately tested here: appPage's own
+// Assemble already dereferences cfg.Releases (assembleChannels, view.go)
+// before handleAppInstance's layout read ever runs, so the whole instance
+// page panics on that configuration regardless of this feature — a
+// pre-existing gap in Assemble (R10: "do not touch"), not one this task
+// introduces. The `s.cfg.Releases != nil` guard above is still correct
+// defensive code for the day Assemble stops assuming a bucket.
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html
index 6fa8d7e0c..e5c4b97dc 100644
--- a/internal/console/templates/app_instance.html
+++ b/internal/console/templates/app_instance.html
@@ -14,6 +14,7 @@
<dt>Health</dt><dd>{{.Row.HealthWord}}</dd>
<dt>{{$.InstanceCopy.LastReading}}</dt><dd>{{if .Row.Metrics.ObservedAt.IsZero}}—{{else}}<time title="{{fmtTime .Row.Metrics.ObservedAt}}">{{ago .Row.Metrics.ObservedAt}}</time>{{end}}</dd>
<dt>Channel</dt><dd><code>{{.Row.Channel}}</code></dd>
+ <dt>Layout</dt><dd>{{.Layout}}</dd>
<dt>{{$.InstanceCopy.Observed}}</dt><dd>{{if .Row.Version}}<code>{{.Row.Version}}</code>{{else}}—{{end}}</dd>
<dt>{{$.InstanceCopy.Target}}</dt><dd>{{if .TargetErr}}Can't tell right now{{else if .TargetVersion}}<code>{{.TargetVersion}}</code>{{else}}—{{end}}</dd>
<dt>CPU</dt><dd>{{pct .Row.Metrics.CPUFrac}}{{if .Row.Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Row.Metrics.SparkCPU}}"/></svg>{{end}}</dd>