console: deterministic architecture layout, inline SVG, and record grouping
- archlayout.go: layer-by-kind layout (barycentre sweeps, stable id tiebreak) and RenderArchSVG, an escaped inline-SVG renderer with no JS and no layout library. BuildArch now calls layout(&v) as its last step. - archLayer gains "external": 3 (ruling 17) — without it the "leaves the estate" node fell into the zone column, drawing the spec's headline unjoined case as a right-to-left arrow. Layer 3 was otherwise empty (no router node exists yet). - archgraph.go: DNS record nodes are now grouped by (zone, resolved target) (ruling 19) — the flagship carries one A record per app host, all pointing at one Elastic IP, so the ungrouped layer was dozens of nodes wide. A group's node id is its lowest record id, by string sort, so a single-record group keeps the exact id/title/subtitle it had before grouping existed. The DNS resource group still lists every record individually — grouping is diagram-only. Tests: TestLayoutIsDeterministic, TestLayoutOrdersLayers (extended with the external-sits-right-of-its-record assertion), TestLayoutDoesNotOverlap, TestSVGIsSelfContainedAndEscaped, TestArchGroupsRecordsBySameZoneAndTarget (20 A records + the fixture's r1 collapse to one node, 22 DNS resource rows). All of Task 5's TestArch* tests pass unchanged — the fixture never exercises more than one record per (zone, target).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+405
−26
internal/console/archgraph.go+104 −26internal/console/archgraph_test.go+47 −0internal/console/archlayout.go+182 −0internal/console/archlayout_test.go+72 −0
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go| index 1e52a133f..51c4959f8 100644 |
| --- a/internal/console/archgraph.go |
| +++ b/internal/console/archgraph.go |
| @@ -148,6 +148,7 @@ func BuildArch(in ArchInput) ArchView { |
| addAlarmNodes(&v, in.Inv, claimed) |
| v.Groups = resourceGroups(in.Inv) |
| pruneDanglingEdges(&v) |
| + layout(&v) |
| return v |
| } |
| @@ -375,47 +376,124 @@ func addDNSNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]strin |
| v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"}) |
| } |
| } |
| - external := map[string]bool{} |
| + addRecordNodes(v, inv, zones, claimed) |
| +} |
| + |
| +// recordGroup collects every DNS record that shares a zone and a resolved |
| +// destination. The flagship carries one A record per app host, all pointing |
| +// at the same Elastic IP — without grouping that layer is dozens of nodes |
| +// wide and unreadable. Grouping happens only in the diagram: every record |
| +// still appears individually, in full, in the DNS resource group below |
| +// (resourceGroups walks the inventory directly and never sees a group). |
| +type recordGroup struct { |
| + ids []string // record ids, sorted; ids[0] names the drawn node |
| + names []string // record display names, sorted, for the example list |
| + zone string // the raw "zone" attr this group's records share |
| + typ string // record type, meaningful only when len(names) == 1 |
| + value string // record value, meaningful only when len(names) == 1 |
| + target string // edge destination: "", "ip:…", "box:…"/"machine:…", "external:…" |
| +} |
| + |
| +// addRecordNodes groups the estate's DNS records by (zone, resolved |
| +// destination) and draws one node per group, sorted by each group's lowest |
| +// record id — never by map iteration, which Go randomises on every run and |
| +// this diagram must not. |
| +func addRecordNodes(v *ArchView, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) { |
| + groups := map[string]*recordGroup{} |
| for _, rec := range inv.ByKind(infraview.KindDNSRecord) { |
| - id := "record:" + rec.ID |
| - v.Nodes = append(v.Nodes, Node{ |
| - ID: id, Kind: "record", Title: nameOf(rec), |
| - Subtitle: strings.TrimSpace(rec.Attr("type") + " " + rec.Attr("value")), |
| - }) |
| + zoneName := rec.Attr("zone") |
| + value := rec.Attr("value") |
| + target := recordTarget(inv, value, claimed) |
| + key := zoneName + "\x00" + target |
| + g := groups[key] |
| + if g == nil { |
| + g = &recordGroup{zone: zoneName, target: target} |
| + groups[key] = g |
| + } |
| + g.ids = append(g.ids, rec.ID) |
| + g.names = append(g.names, nameOf(rec)) |
| + g.typ, g.value = rec.Attr("type"), value |
| + } |
| + keys := make([]string, 0, len(groups)) |
| + for k, g := range groups { |
| + sort.Strings(g.ids) |
| + sort.Strings(g.names) |
| + keys = append(keys, k) |
| + } |
| + sort.Slice(keys, func(a, b int) bool { |
| + return groups[keys[a]].ids[0] < groups[keys[b]].ids[0] |
| + }) |
| + |
| + external := map[string]bool{} |
| + for _, k := range keys { |
| + g := groups[k] |
| + id := "record:" + g.ids[0] |
| + title, sub := recordNodeText(g) |
| + v.Nodes = append(v.Nodes, Node{ID: id, Kind: "record", Title: title, Subtitle: sub}) |
| for _, z := range zones { |
| - if z.Name != "" && rec.Attr("zone") == z.Name { |
| + if z.Name != "" && g.zone == z.Name { |
| v.Edges = append(v.Edges, Edge{From: "zone:" + z.ID, To: id, Kind: "resolves"}) |
| } |
| } |
| - // Find refuses to match on an empty value, so an absent record value |
| - // falls straight through both joins — and then out of the estate |
| - // check below, which only fires on a value there is one of. |
| - value := rec.Attr("value") |
| - ip := inv.Find(infraview.KindIP, "address", value) |
| - machine := inv.Find(infraview.KindMachine, "public_ip", value) |
| switch { |
| - case value == "": |
| + case g.target == "": |
| // An absent value is absence. Nothing is claimed about it. |
| - case ip != nil: |
| - v.Edges = append(v.Edges, Edge{From: id, To: "ip:" + ip.ID, Kind: "resolves"}) |
| - case machine != nil: |
| - // An address the estate owns but holds directly on a machine |
| - // rather than through an allocated IP. |
| - v.Edges = append(v.Edges, Edge{From: id, To: targetOf(machine.ID, claimed), Kind: "resolves"}) |
| - default: |
| - ext := "external:" + value |
| - if !external[value] { |
| - external[value] = true |
| + case strings.HasPrefix(g.target, "external:"): |
| + val := strings.TrimPrefix(g.target, "external:") |
| + if !external[val] { |
| + external[val] = true |
| v.Nodes = append(v.Nodes, Node{ |
| - ID: ext, Kind: "external", Title: value, |
| + ID: g.target, Kind: "external", Title: val, |
| Subtitle: "outside this estate", |
| }) |
| } |
| - v.Edges = append(v.Edges, Edge{From: id, To: ext, Kind: "resolves"}) |
| + v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"}) |
| + default: |
| + v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"}) |
| } |
| } |
| } |
| +// recordTarget is where a DNS record's value actually points: an allocated |
| +// IP, a machine holding that address directly, or outside the estate |
| +// entirely. Pure — it only reads the inventory — so the grouping pass above |
| +// can call it once per record before deciding which records share a |
| +// destination. Find refuses to match on an empty value, so an absent value |
| +// falls straight through to the "" case. |
| +func recordTarget(inv *infraview.Inventory, value string, claimed map[string]string) string { |
| + if value == "" { |
| + return "" |
| + } |
| + if ip := inv.Find(infraview.KindIP, "address", value); ip != nil { |
| + return "ip:" + ip.ID |
| + } |
| + if m := inv.Find(infraview.KindMachine, "public_ip", value); m != nil { |
| + // An address the estate owns but holds directly on a machine rather |
| + // than through an allocated IP. |
| + return targetOf(m.ID, claimed) |
| + } |
| + return "external:" + value |
| +} |
| + |
| +// recordNodeText is the node's label. A single record keeps exactly the |
| +// shape it had before grouping existed: its own name, and its type and |
| +// value as the subtitle. A group of more than one names the count and up to |
| +// three example names, sorted, with the remainder folded into a "+N". |
| +func recordNodeText(g *recordGroup) (title, subtitle string) { |
| + if len(g.names) == 1 { |
| + return g.names[0], strings.TrimSpace(g.typ + " " + g.value) |
| + } |
| + examples := g.names |
| + if len(examples) > 3 { |
| + examples = examples[:3] |
| + } |
| + sub := strings.Join(examples, ", ") |
| + if rest := len(g.names) - len(examples); rest > 0 { |
| + sub += fmt.Sprintf(", +%d", rest) |
| + } |
| + return fmt.Sprintf("%d records", len(g.names)), sub |
| +} |
| + |
| // addBoxNodes draws every box CARLOS reports — with or without a machine |
| // behind it — plus the one apps node per box that carries its instance |
| // counts, and the deployment-bucket edge when the caller named a bucket. |
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go| index a4f072c6e..b3dce8c09 100644 |
| --- a/internal/console/archgraph_test.go |
| +++ b/internal/console/archgraph_test.go |
| @@ -1,6 +1,7 @@ |
| package console |
| import ( |
| + "fmt" |
| "testing" |
| "github.com/carlosframework/platform/internal/economics" |
| @@ -390,3 +391,49 @@ func TestArchAlarmWatchesItsBox(t *testing.T) { |
| t.Errorf("an alarm naming a box's machine must edge to that box: %+v", v.Edges) |
| } |
| } |
| + |
| +// A zone can carry dozens of A records aimed at one address — one per app |
| +// host, all behind the same Elastic IP — and the diagram must draw that as |
| +// ONE arrow, not dozens, while the DNS resource group below still lists |
| +// every record individually: grouping happens only in the picture. |
| +func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) { |
| + in := archFixture() |
| + // r1 (from the fixture) already shares zone "carloku.com" and value |
| + // "52.0.0.1" with these — 21 records in all should collapse to one node. |
| + for i := 0; i < 20; i++ { |
| + in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| + Kind: infraview.KindDNSRecord, ID: fmt.Sprintf("bulk-%02d", i), |
| + Name: fmt.Sprintf("app%02d.carloku.com", i), |
| + Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "52.0.0.1"}, |
| + }) |
| + } |
| + v := BuildArch(in) |
| + |
| + recordNodes := 0 |
| + for _, n := range v.Nodes { |
| + if n.Kind == "record" { |
| + recordNodes++ |
| + } |
| + } |
| + if recordNodes != 1 { |
| + t.Errorf("record nodes = %d, want 1 (all 21 records share zone+target)", recordNodes) |
| + } |
| + // The grouped node's id is deterministic (the lowest record id in the |
| + // group, by string sort) rather than depending on input order — here |
| + // that is "bulk-00", which sorts before "r1". |
| + if !hasEdge(v, "record:bulk-00", "ip:eipalloc-1") { |
| + t.Errorf("grouped node must use its lowest-id member's id: %+v", v.Nodes) |
| + } |
| + |
| + var dnsRows int |
| + for _, g := range v.Groups { |
| + if g.Title == "DNS" { |
| + dnsRows = len(g.Rows) |
| + } |
| + } |
| + // 1 zone row + 21 record rows: nothing hidden, just not repeated in the |
| + // diagram. |
| + if dnsRows != 22 { |
| + t.Errorf("DNS resource group rows = %d, want 22 (every record listed individually)", dnsRows) |
| + } |
| +} |
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go| new file mode 100644 |
| index 000000000..dd37133f5 |
| --- /dev/null |
| +++ b/internal/console/archlayout.go |
| @@ -0,0 +1,182 @@ |
| +// Layout and SVG emission for /fleet/architecture. Deterministic by |
| +// construction: layer by kind, order within a layer by one barycentre sweep |
| +// each way, then a stable sort by node id. The same fleet must produce a |
| +// byte-identical picture on every refresh — a diagram that reshuffles |
| +// cannot be read for change. |
| +// |
| +// No JavaScript and no layout library: the console vendors its icons and |
| +// loads nothing from a CDN, and a server-computed layout is the only way to |
| +// keep that promise while still drawing a graph. |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "html/template" |
| + "sort" |
| + "strings" |
| +) |
| + |
| +// Layer assignment by node kind — the story the page tells, left to right. |
| +// "external" shares layer 3 with "router": nothing emits a router node yet, |
| +// so the layer is otherwise empty, and an address leaving the estate must |
| +// sit to the RIGHT of the record that names it, not in the zone column. |
| +var archLayer = map[string]int{ |
| + "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3, |
| + "box": 4, "machine": 4, "apps": 5, "bucket": 6, |
| +} |
| + |
| +const ( |
| + nodeW = 190 |
| + nodeH = 64 |
| + gapX = 90 |
| + gapY = 26 |
| + padding = 24 |
| + satelliteKinds = "alarm identity firewall network disk certificate other" |
| +) |
| + |
| +func layout(v *ArchView) { |
| + // Satellites (alarms, identities, firewalls…) are listed in the cards |
| + // below the diagram rather than drawn: a graph that draws every IAM role |
| + // is a hairball, and the resource groups already carry them in full. |
| + kept := v.Nodes[:0] |
| + for _, n := range v.Nodes { |
| + if strings.Contains(satelliteKinds, n.Kind) { |
| + continue |
| + } |
| + kept = append(kept, n) |
| + } |
| + v.Nodes = kept |
| + |
| + byLayer := map[int][]int{} |
| + for i := range v.Nodes { |
| + l := archLayer[v.Nodes[i].Kind] |
| + byLayer[l] = append(byLayer[l], i) |
| + } |
| + // Stable order first, so the barycentre sweeps start from a fixed |
| + // arrangement and cannot depend on map iteration order anywhere. |
| + for l := range byLayer { |
| + idx := byLayer[l] |
| + sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID }) |
| + } |
| + // One sweep forward and one back, ordering each layer by the mean row of |
| + // its neighbours in the adjacent layer — the standard crossing |
| + // reduction, and enough for a graph this shape. Ties break on node id so |
| + // the result cannot depend on map or slice iteration order anywhere. |
| + row := map[string]int{} |
| + for _, idx := range byLayer { |
| + for r, i := range idx { |
| + row[v.Nodes[i].ID] = r |
| + } |
| + } |
| + neighbours := func(id string, forward bool) []string { |
| + var out []string |
| + for _, e := range v.Edges { |
| + if forward && e.To == id { |
| + out = append(out, e.From) |
| + } |
| + if !forward && e.From == id { |
| + out = append(out, e.To) |
| + } |
| + } |
| + return out |
| + } |
| + sweep := func(forward bool) { |
| + layers := make([]int, 0, len(byLayer)) |
| + for l := range byLayer { |
| + layers = append(layers, l) |
| + } |
| + sort.Ints(layers) |
| + if !forward { |
| + for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 { |
| + layers[i], layers[j] = layers[j], layers[i] |
| + } |
| + } |
| + for _, l := range layers { |
| + idx := byLayer[l] |
| + bary := map[string]float64{} |
| + for _, i := range idx { |
| + id := v.Nodes[i].ID |
| + ns := neighbours(id, forward) |
| + if len(ns) == 0 { |
| + bary[id] = float64(row[id]) |
| + continue |
| + } |
| + sum := 0 |
| + for _, n := range ns { |
| + sum += row[n] |
| + } |
| + bary[id] = float64(sum) / float64(len(ns)) |
| + } |
| + sort.SliceStable(idx, func(a, b int) bool { |
| + ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID |
| + if bary[ia] != bary[ib] { |
| + return bary[ia] < bary[ib] |
| + } |
| + return ia < ib |
| + }) |
| + for r, i := range idx { |
| + row[v.Nodes[i].ID] = r |
| + } |
| + } |
| + } |
| + sweep(true) |
| + sweep(false) |
| + |
| + maxRows := 0 |
| + for l, idx := range byLayer { |
| + for r, i := range idx { |
| + v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH |
| + v.Nodes[i].X = padding + l*(nodeW+gapX) |
| + v.Nodes[i].Y = padding + r*(nodeH+gapY) |
| + } |
| + if len(idx) > maxRows { |
| + maxRows = len(idx) |
| + } |
| + } |
| + layers := 0 |
| + for l := range byLayer { |
| + if l+1 > layers { |
| + layers = l + 1 |
| + } |
| + } |
| + v.Width = padding*2 + layers*nodeW + (layers-1)*gapX |
| + v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY |
| +} |
| + |
| +// RenderArchSVG emits the diagram. Every string that came from a hostname, |
| +// a tag or a label goes through template.HTMLEscapeString — these are |
| +// operator-supplied values from a registry and a cloud account, and the one |
| +// thing this page must never do is execute one of them. |
| +func RenderArchSVG(v ArchView) template.HTML { |
| + var b strings.Builder |
| + fmt.Fprintf(&b, `<svg class="archsvg" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`, v.Width, v.Height) |
| + pos := map[string]Node{} |
| + for _, n := range v.Nodes { |
| + pos[n.ID] = n |
| + } |
| + // Edges first so nodes paint over them. |
| + for _, e := range v.Edges { |
| + from, okF := pos[e.From] |
| + to, okT := pos[e.To] |
| + if !okF || !okT { |
| + continue // a satellite edge whose node the layout dropped |
| + } |
| + x1, y1 := from.X+from.W, from.Y+from.H/2 |
| + x2, y2 := to.X, to.Y+to.H/2 |
| + mid := (x1 + x2) / 2 |
| + fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2) |
| + } |
| + for _, n := range v.Nodes { |
| + fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind)) |
| + fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H) |
| + fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`, |
| + n.X+12, n.Y+24, template.HTMLEscapeString(n.Title)) |
| + if n.Subtitle != "" { |
| + fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`, |
| + n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle)) |
| + } |
| + b.WriteString(`</g>`) |
| + } |
| + b.WriteString(`</svg>`) |
| + return template.HTML(b.String()) |
| +} |
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go| new file mode 100644 |
| index 000000000..d113e5792 |
| --- /dev/null |
| +++ b/internal/console/archlayout_test.go |
| @@ -0,0 +1,72 @@ |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// The same fleet must draw the same picture every time. A diagram that |
| +// reshuffles between refreshes cannot be read for change, which is most of |
| +// what an operator opens it for. |
| +func TestLayoutIsDeterministic(t *testing.T) { |
| + a := RenderArchSVG(BuildArch(archFixture())) |
| + b := RenderArchSVG(BuildArch(archFixture())) |
| + if a != b { |
| + t.Fatal("two builds of the same input produced different SVG") |
| + } |
| +} |
| + |
| +// Layer order is the story the page tells: DNS enters at the left, routers |
| +// and boxes in the middle, apps at the right. A record pointing outside the |
| +// estate must sit to the RIGHT of the record that names it — the diagram's |
| +// headline unjoined case reads left-to-right like everything else. |
| +func TestLayoutOrdersLayers(t *testing.T) { |
| + in := archFixture() |
| + in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| + Kind: infraview.KindDNSRecord, ID: "r2", Name: "mail.carloku.com", |
| + Attrs: map[string]string{"zone": "carloku.com", "type": "A", "value": "203.0.113.9"}, |
| + }) |
| + v := BuildArch(in) |
| + x := map[string]int{} |
| + for _, n := range v.Nodes { |
| + x[n.ID] = n.X |
| + } |
| + if !(x["zone:Z1"] < x["record:r1"] && x["record:r1"] < x["ip:eipalloc-1"] && |
| + x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"] && x["box:carlos-eu-west-1"] < x["apps:carlos-eu-west-1"]) { |
| + t.Fatalf("layers out of order: %+v", x) |
| + } |
| + if !(x["record:r2"] < x["external:203.0.113.9"]) { |
| + t.Fatalf("external must sit right of the record that points at it: %+v", x) |
| + } |
| +} |
| + |
| +// No node may overlap another: the layout is the only thing standing |
| +// between a growing fleet and an unreadable page. |
| +func TestLayoutDoesNotOverlap(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + for i := range v.Nodes { |
| + for j := i + 1; j < len(v.Nodes); j++ { |
| + a, b := v.Nodes[i], v.Nodes[j] |
| + if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { |
| + t.Fatalf("%s overlaps %s", a.ID, b.ID) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// The SVG must scale without script and must not carry raw user strings |
| +// unescaped. |
| +func TestSVGIsSelfContainedAndEscaped(t *testing.T) { |
| + in := archFixture() |
| + in.Fleet.Boxes[0].Label = `evil"><script>alert(1)</script>` |
| + in.Hosts[0].Label = in.Fleet.Boxes[0].Label |
| + out := string(RenderArchSVG(BuildArch(in))) |
| + if !strings.Contains(out, "viewBox=") { |
| + t.Error("SVG must carry a viewBox so it scales with no JS") |
| + } |
| + if strings.Contains(out, "<script") { |
| + t.Error("a box label must never reach the SVG unescaped") |
| + } |
| +} |