carlos / platform Public

Merge GitHub PR #390 (architecture page per region) into amadan main

#390 was merged on the GitHub mirror after the mains were reconciled
this morning, on top of the pre-integration arch page. Kept #390's
one-picture-per-region structure whole and carried the three earlier
fixes onto it: fitted titles with alarm marks on their own bottom row,
layer-skipping edges routed around the columns they skip, and the
firing-alarms banner with the refresh form at the top. The viewport-
sized diagram wrapper and the "scrolls sideways" note are dropped:
#390's own tests pin every band inside the 64rem column.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QCUCpfxbCyEAVQ2m51dH5
Paul Campbell pushed by paul@keymail.dev 1c5a57fffdfe4d41d14eb0487365066566f380fe parent 66587faparent d72ac7f
15 files changed, +1097 −272
  • internal/console/archestate.go +11 −7
  • internal/console/archestate_test.go +16 −12
  • internal/console/archfits_test.go +31 −19
  • internal/console/archgraph.go +190 −41
  • internal/console/archgraph_test.go +46 −19
  • internal/console/archlayout.go +109 −79
  • internal/console/archlayout_test.go +64 −33
  • internal/console/archlegibility_test.go +40 −22
  • internal/console/archpage.go +6 −6
  • internal/console/archpage_test.go +19 −2
  • internal/console/archregion.go +179 −0
  • internal/console/archregion_test.go +343 −0
  • internal/console/server.go +6 −9
  • internal/console/templates/architecture.html +20 −8
  • internal/console/templates/layout.html +17 −15

CI passed — run details

diff --git a/internal/console/archestate.go b/internal/console/archestate.go
index 2f49b5386..fcfbcc4ce 100644
--- a/internal/console/archestate.go
+++ b/internal/console/archestate.go
@@ -192,13 +192,17 @@ func arnTail(arn string) string {
// splitNodes deals the drawn nodes into the two banks.
//
-// Most nodes answer directly: a box, its apps node and the deployment bucket
-// are CARLOS by definition; an IP, a record or an unclaimed machine is
-// whatever the closure said its resource is. Two kinds straddle, and both do
-// so legitimately — a DNS zone that answers for both halves, and an external
+// Most nodes answer directly: a box and the deployment bucket are CARLOS by
+// definition; an IP, a record or an unclaimed machine is whatever the
+// closure said its resource is. Two kinds straddle, and both do so
+// legitimately — a DNS zone that answers for both halves, and an external
// address named by records in both — so a node with no direct answer takes
-// the union of its neighbours' banks. That is what puts tito.io in both
-// pictures without either picture claiming to own it.
+// the union of its neighbours' banks. That is what keeps an address named
+// from both halves in both, without either picture claiming to own it.
+//
+// A zone is banked here and then not drawn (dropSatellites): it is global,
+// so it belongs to no region band. Its bank still decides which side lists
+// it in the DNS table, which is the one place it reaches the page.
//
// A node that answers neither directly nor through a neighbour goes in BOTH
// rather than neither: on this page an unplaceable thing is drawn twice, never
@@ -212,7 +216,7 @@ func splitNodes(nodes []Node, edges []Edge, in map[string]bool) map[string]int {
continue
}
switch kind {
- case "box", "apps", "bucket":
+ case "box", "bucket":
bank[n.ID], direct[n.ID] = estateCARLOS, true
case "ip", "record", "machine":
if in[ref] {
diff --git a/internal/console/archestate_test.go b/internal/console/archestate_test.go
index b74451603..40f382fa3 100644
--- a/internal/console/archestate_test.go
+++ b/internal/console/archestate_test.go
@@ -173,10 +173,10 @@ func TestNoEdgeSpansTheTwoBanks(t *testing.T) {
v := BuildArch(sharedAccountFixture())
bank := map[string]string{}
- for _, n := range v.CARLOS.Nodes {
+ for _, n := range v.CARLOS.Nodes() {
bank[n.ID] = "CARLOS"
}
- for _, n := range v.Rest.Nodes {
+ for _, n := range v.Rest.Nodes() {
if bank[n.ID] == "CARLOS" {
bank[n.ID] = "both"
continue
@@ -192,12 +192,10 @@ func TestNoEdgeSpansTheTwoBanks(t *testing.T) {
e.From, f, e.To, to)
}
- // And the zone really is the node that straddles: it answers for both
- // halves, so both pictures show where their records resolve FROM.
- if bank["zone:Z1"] != "both" {
- t.Errorf("the shared zone is in %q, want both pictures", bank["zone:Z1"])
- }
- // Drawn twice, listed once: a node is not a resource row.
+ // The zone straddles both halves — it answers for records on either side
+ // — and is drawn in NEITHER, because a zone belongs to no region and a
+ // region band is what a picture is now (archregion.go). It reaches the
+ // page as a table row, exactly once, on the side that can prove it.
if got := bankOfEachRow(v)["DNS/tito.io"]; got != "CARLOS" {
t.Errorf("the shared zone's table row is %q, want exactly one row, on the CARLOS side", got)
}
@@ -212,7 +210,7 @@ func TestBothPicturesAreDeterministic(t *testing.T) {
if renderBoth(a) != renderBoth(b) {
t.Fatal("two builds of the same shared account produced different SVG")
}
- if string(RenderArchSVG(a.CARLOS)) == string(RenderArchSVG(a.Rest)) {
+ if renderEstate(a.CARLOS) == renderEstate(a.Rest) {
t.Fatal("fixture drift: the two pictures should differ, or this test proves nothing")
}
}
@@ -237,11 +235,17 @@ func TestAnAccountWithOnlyCARLOSHasAnEmptySecondBank(t *testing.T) {
for _, g := range v.Rest.Groups {
t.Errorf("nothing should be in the second bank, found %s: %+v", g.Title, g.Rows)
}
- for _, n := range v.Rest.Nodes {
+ for _, n := range v.Rest.Nodes() {
t.Errorf("nothing should be drawn in the second picture, found %s", n.ID)
}
- if v.Rest.Width <= 0 || v.Rest.Height <= 0 {
- t.Errorf("an empty second picture must still carry a positive viewBox: %dx%d", v.Rest.Width, v.Rest.Height)
+ // An empty estate still draws ONE band, so the page has a picture where
+ // one belongs rather than a gap, and that band still carries a positive
+ // viewBox.
+ if len(v.Rest.Bands) != 1 {
+ t.Fatalf("an empty second estate should still draw one band, got %d", len(v.Rest.Bands))
+ }
+ if b := v.Rest.Bands[0]; b.Width <= 0 || b.Height <= 0 {
+ t.Errorf("an empty second picture must still carry a positive viewBox: %dx%d", b.Width, b.Height)
}
}
diff --git a/internal/console/archfits_test.go b/internal/console/archfits_test.go
index a484f3c88..231de5525 100644
--- a/internal/console/archfits_test.go
+++ b/internal/console/archfits_test.go
@@ -25,16 +25,18 @@ func TestFlagshipBoxNamesSurviveTheirAlarms(t *testing.T) {
{Name: "carlos-flagship-ie-mem-low", State: "OK"},
{Name: "carlos-flagship-ie-reachability", State: "OK"},
}
- // ArchEstate, not ArchView: #387 split the page into two estates drawn one
- // above the other, and layout/RenderArchSVG each take one estate now.
- // These three pins are about a box's own pixels, so they belong to an
- // estate and are otherwise unchanged.
- v := ArchEstate{Nodes: []Node{
+ // ArchBand, not ArchView: #387 split the page into two estates drawn one
+ // above the other and #390 split each estate into one band per region,
+ // and layout/RenderArchSVG each take one band now. These pins are about
+ // a box's own pixels, so they belong to a band and are otherwise
+ // unchanged.
+ var v ArchBand
+ v.Nodes = []Node{
{ID: "box:ie", Kind: "box", Title: "carlos-flagship-ie",
Subtitle: "t4g.medium running", Alarms: two},
{ID: "box:au", Kind: "box", Title: "carlos-flagship-au",
Subtitle: "t4g.small running", Alarms: two},
- }}
+ }
layout(&v)
out := string(RenderArchSVG(v))
for _, want := range []string{"carlos-flagship-ie", "carlos-flagship-au"} {
@@ -51,19 +53,29 @@ func TestFlagshipBoxNamesSurviveTheirAlarms(t *testing.T) {
}
}
-// TestAlarmMarksClearBothTextLines: the marks moved off the title's line, so
-// they have to sit below the subtitle's baseline rather than merely somewhere
-// else — otherwise the fix trades a truncated title for a mark drawn over the
-// words — and inside the rect they belong to.
-func TestAlarmMarksClearBothTextLines(t *testing.T) {
- n := Node{ID: "box:ie", Kind: "box", Title: "carlos-flagship-ie", Y: 24, H: nodeH,
- Alarms: []NodeAlarm{{Name: "a", State: "OK"}}}
- const subtitleBaseline = 44 // where RenderArchSVG draws .archsub
- if got := alarmRow(n) - n.Y; got <= subtitleBaseline {
- t.Errorf("alarm centre at +%d, want below the subtitle baseline (+%d)", got, subtitleBaseline)
- }
- if alarmRow(n)+alarmR > n.Y+n.H {
- t.Errorf("alarm at +%d (r=%d) overflows a node %d tall", alarmRow(n)-n.Y, alarmR, n.H)
+// TestAlarmMarksClearEveryTextLine: the marks moved off the title's line, so
+// they have to sit below the LAST baseline rather than merely somewhere else
+// — otherwise the fix trades a truncated title for a mark drawn over the
+// words — and inside the rect they belong to. Since #390 a box card carries
+// Extra lines (its apps, its instance counts) under the subtitle, each
+// nodeLineH further down, so the last baseline is not the subtitle's and the
+// check runs at every line count a card can have.
+func TestAlarmMarksClearEveryTextLine(t *testing.T) {
+ for _, extra := range [][]string{nil, {"console, kass"}, {"console, kass", "static 3 · resident 4"}} {
+ n := Node{ID: "box:ie", Kind: "box", Title: "carlos-flagship-ie", Y: 24,
+ Extra: extra, Alarms: []NodeAlarm{{Name: "a", State: "OK"}}}
+ n.H = nodeHeight(n)
+ // Where RenderArchSVG draws the last .archsub: the subtitle at +44,
+ // then one nodeLineH per Extra line. The mark's TOP edge has to clear
+ // the descenders below that baseline, not just its centre.
+ last := 44 + len(extra)*nodeLineH
+ const descender = 3
+ if top := alarmRow(n) - alarmR - n.Y; top <= last+descender {
+ t.Errorf("%d extra lines: alarm top at +%d, want below the last baseline (+%d) and its descenders", len(extra), top, last)
+ }
+ if alarmRow(n)+alarmR > n.Y+n.H {
+ t.Errorf("%d extra lines: alarm at +%d (r=%d) overflows a node %d tall", len(extra), alarmRow(n)-n.Y, alarmR, n.H)
+ }
}
}
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go
index b5ab3c3a7..86583ba56 100644
--- a/internal/console/archgraph.go
+++ b/internal/console/archgraph.go
@@ -28,6 +28,7 @@ package console
import (
"fmt"
+ "html/template"
"sort"
"strconv"
"strings"
@@ -72,7 +73,14 @@ type Node struct {
// picture read as a request flowing THROUGH the alarm; and an alarm is
// not a hop a request takes, it is a property of the thing it watches.
// The Alarms resource table still lists every one of them in full.
- Alarms []NodeAlarm
+ Alarms []NodeAlarm
+ // Extra are further muted lines under the subtitle, and they are why a
+ // node's height is computed rather than constant. They exist so a fact
+ // that is a PROPERTY of a node can sit on it instead of standing in the
+ // flow as a node of its own: the apps a box runs used to be their own
+ // column, six of nine cards in it reading "no apps", and that column was
+ // a fifth of the width of a picture too wide for the page.
+ Extra []string
X, Y, W, H int
}
@@ -123,16 +131,62 @@ type ResourceGroup struct {
// ResourceRow is one resource in one of those tables.
type ResourceRow struct{ Name, NativeType, Region, Detail string }
-// ArchEstate is one half of the page: its own picture and its own tables.
-// Nothing else in this type knows which half it is — layout and
-// RenderArchSVG take one of these and have no opinion about the other,
-// which is what makes running them twice free and each picture
-// independently deterministic.
-type ArchEstate struct {
- Nodes []Node
- Edges []Edge
+// archGraph is a set of nodes and the edges between them — the shape every
+// pass below builds into, before anything has been dealt into a bank or a
+// band. It is unexported because it is a stage, not a thing the page shows.
+type archGraph struct {
+ Nodes []Node
+ Edges []Edge
+}
+
+// ArchBand is one region's picture: the nodes that reach a machine in that
+// region, the edges between them, and its own size. It is a whole diagram
+// on its own — laid out, sized and rendered without reference to any other
+// band — which is what lets the page stack six of them down the screen
+// instead of drawing one picture wider than the column.
+//
+// Region is the code the cloud uses ("eu-west-1") or "" for the band of
+// things that reach no machine at all; Label is how that reads to a person.
+type ArchBand struct {
+ archGraph
+ Region string
+ Label string
Width, Height int
- Groups []ResourceGroup
+}
+
+// SVG renders this band. A method rather than a field on the page struct
+// because a band knows how to draw itself and the number of bands is not
+// known until the estate is built — the template ranges over them.
+func (b ArchBand) SVG() template.HTML { return RenderArchSVG(b) }
+
+// ArchEstate is one half of the page: its own bands, and its own tables.
+// Nothing in this type knows which half it is — layout and RenderArchSVG
+// take one BAND and have no opinion about the estate it came from, which is
+// what makes running them per band free and each picture independently
+// deterministic.
+type ArchEstate struct {
+ Bands []ArchBand
+ Groups []ResourceGroup
+}
+
+// Nodes and Edges are the estate whole again, across every band. A node
+// drawn in two bands (the deployment bucket, which every region writes to)
+// appears once per band here — the callers that count resources dedupe by
+// id, exactly as they already do for a node drawn in both banks.
+func (e ArchEstate) Nodes() []Node {
+ var out []Node
+ for _, b := range e.Bands {
+ out = append(out, b.Nodes...)
+ }
+ return out
+}
+
+func (e ArchEstate) Edges() []Edge {
+ var out []Edge
+ for _, b := range e.Bands {
+ out = append(out, b.Edges...)
+ }
+ return out
}
// ArchView is the whole page: two estates drawn one above the other, and
@@ -167,10 +221,17 @@ type ArchView struct {
}
// firingAlarms collects the ALARM-state marks across the drawn nodes, in the
-// nodes' own order so the summary is as deterministic as the diagram.
+// nodes' own order so the summary is as deterministic as the diagram. A node
+// drawn in more than one band (ArchEstate.Nodes) is counted once: the banner
+// names alarms, and an alarm does not fire twice for being drawn twice.
func firingAlarms(nodes []Node) []string {
var out []string
+ seen := map[string]bool{}
for _, n := range nodes {
+ if seen[n.ID] {
+ continue
+ }
+ seen[n.ID] = true
for _, a := range n.Alarms {
if a.Firing {
out = append(out, a.Name+" ("+n.Title+")")
@@ -216,7 +277,7 @@ func BuildArch(in ArchInput) ArchView {
// place: a record still finds its IP, and an alarm still finds its box,
// without either pass having to know which half of the account it is
// looking at.
- var whole ArchEstate
+ var whole archGraph
addDNSNodes(&whole, in.Inv, claimed)
addBoxNodes(&whole, v.Boxes, in)
addMachineNodes(&whole, in.Inv, claimed)
@@ -224,42 +285,128 @@ func BuildArch(in ArchInput) ArchView {
carlos := carlosResources(in.Inv, claimed, in.Bucket)
bank := splitNodes(whole.Nodes, whole.Edges, carlos)
+ var forBank [2]archGraph
for _, n := range whole.Nodes {
if bank[n.ID]&estateCARLOS != 0 {
- v.CARLOS.Nodes = append(v.CARLOS.Nodes, n)
+ forBank[0].Nodes = append(forBank[0].Nodes, n)
}
if bank[n.ID]&estateRest != 0 {
- v.Rest.Nodes = append(v.Rest.Nodes, n)
+ forBank[1].Nodes = append(forBank[1].Nodes, n)
}
}
// Both banks get every edge and each then prunes what it does not draw.
// An edge whose ends are in different banks therefore survives in
// neither — which is why archestate_test.go asserts no such edge exists,
// rather than this loop quietly deciding it does not matter.
- v.CARLOS.Edges = append([]Edge(nil), whole.Edges...)
- v.Rest.Edges = append([]Edge(nil), whole.Edges...)
+ forBank[0].Edges = append([]Edge(nil), whole.Edges...)
+ forBank[1].Edges = append([]Edge(nil), whole.Edges...)
v.CARLOS.Groups, v.Rest.Groups = splitResourceGroups(in.Inv, carlos)
- // AFTER layout, not before: layout is what makes the node set final (it
- // drops the satellite kinds the diagram lists in tables instead), so an
- // edge is only knowably dangling once layout has run. Pruning earlier
- // left the shipped ArchView carrying edges to nodes it does not draw.
- for _, e := range []*ArchEstate{&v.CARLOS, &v.Rest} {
- layout(e)
- pruneDanglingEdges(e)
+ seeds := regionSeeds(in.Inv, v.Boxes)
+ for i, e := range []*ArchEstate{&v.CARLOS, &v.Rest} {
+ e.Bands = bandsFrom(forBank[i], seeds)
}
// After layout, for the same reason the pruning is: layout is what makes
// the node set final, and a summary naming an alarm on a node the diagram
// does not draw would send a reader looking for a mark that is not there.
//
- // Both estates, CARLOS first, so the order is the order the page draws
- // them in — a firing alarm should be findable by reading downwards.
- v.Firing = append(firingAlarms(v.CARLOS.Nodes), firingAlarms(v.Rest.Nodes)...)
+ // Both estates, CARLOS first, and each estate's bands in page order
+ // (ArchEstate.Nodes walks them so), so the order is the order the page
+ // draws them in — a firing alarm should be findable by reading downwards.
+ v.Firing = append(firingAlarms(v.CARLOS.Nodes()), firingAlarms(v.Rest.Nodes())...)
return v
}
+// bandsFrom deals one bank's graph into its region bands and lays each one
+// out. The ORDER of the three steps is the whole of it:
+//
+// 1. drop the satellite kinds, because banding must not propagate a region
+// along an edge to a node that is not going to be drawn — a zone is
+// global and joined to records in every region, and letting it carry
+// bands would put every record in every band;
+// 2. band what is left, which is what makes an edge unable to span two
+// bands;
+// 3. lay each band out and only THEN prune its dangling edges, because
+// layout is what makes a band's node set final.
+func bandsFrom(g archGraph, seeds map[string]string) []ArchBand {
+ g.Nodes = dropSatellites(g.Nodes)
+ in := bandsOf(g.Nodes, g.Edges, seeds)
+
+ present := map[string]bool{}
+ for _, n := range g.Nodes {
+ if len(in[n.ID]) == 0 {
+ present[noRegion] = true
+ continue
+ }
+ for r := range in[n.ID] {
+ present[r] = true
+ }
+ }
+ // An estate with nothing in it still gets one empty band, so the page
+ // has a picture to draw rather than a gap where one should be — the
+ // same reason layout clamps an empty graph to a positive viewBox.
+ if len(present) == 0 {
+ present[noRegion] = true
+ }
+
+ var bands []ArchBand
+ for _, region := range bandOrder(present) {
+ b := ArchBand{Region: region, Label: bandLabel(region)}
+ for _, n := range g.Nodes {
+ set := in[n.ID]
+ if (len(set) == 0 && region == noRegion) || set[region] {
+ b.Nodes = append(b.Nodes, n)
+ }
+ }
+ b.Edges = append([]Edge(nil), g.Edges...)
+ layout(&b)
+ pruneDanglingEdges(&b)
+ bands = append(bands, b)
+ }
+ // One width for every band in an estate, so the columns line up down the
+ // page: the box column is in the same place in eu-west-1 as in
+ // us-east-1, and a reader's eye can travel down it. Layout sizes each
+ // band to its own deepest column, which left a band of two columns half
+ // the width of one with four and the stack looking ragged. Node
+ // positions do not move — X is the column index, not a fraction of the
+ // width — so this only ever widens the viewBox.
+ widest := 0
+ for _, b := range bands {
+ if b.Width > widest {
+ widest = b.Width
+ }
+ }
+ for i := range bands {
+ bands[i].Width = widest
+ }
+ return bands
+}
+
+// dropSatellites removes the kinds the page lists in a table instead of
+// drawing. Identities, firewalls, networks, disks and certificates because
+// a graph that draws every IAM role is a hairball and the resource groups
+// carry them in full; ZONES because a zone is global — Route 53 hands one
+// back with no region and records in six of them — so a drawn zone is
+// either in no band, cutting every record loose from its name, or in all of
+// them, which is six copies of one node whose only edge is to a record
+// already labelled with the zone's own name. The DNS table below still
+// lists every zone.
+//
+// Alarms are not in this list because they are no longer nodes at all —
+// they ride the node they watch (see archLayer).
+func dropSatellites(nodes []Node) []Node {
+ kept := nodes[:0]
+ for _, n := range nodes {
+ if strings.Contains(satelliteKinds, n.Kind) {
+ continue
+ }
+ kept = append(kept, n)
+ }
+ return kept
+}
+
// pruneDanglingEdges drops any edge whose endpoints this view does not
// draw. One rule in one place, applied last — after layout, which is what
// decides which nodes are drawn — so every pass above can join on whatever
@@ -269,7 +416,7 @@ func BuildArch(in ArchInput) ArchView {
// terminated, would otherwise leave an arrow pointing at nothing — and an
// arrow pointing at nothing is exactly the kind of claim this page exists
// to avoid making.
-func pruneDanglingEdges(v *ArchEstate) {
+func pruneDanglingEdges(v *ArchBand) {
drawn := make(map[string]bool, len(v.Nodes))
for _, n := range v.Nodes {
drawn[n.ID] = true
@@ -506,7 +653,7 @@ func buildBoxCards(in ArchInput) []BoxCard {
// does not own gets an external node — an arrow leaving the diagram, per
// spec §6.3 — rather than being dropped, because "this name points somewhere
// we do not run" is exactly the thing an operator opens this page to see.
-func addDNSNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
+func addDNSNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -552,7 +699,7 @@ type recordGroup struct {
// 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 *ArchEstate, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
+func addRecordNodes(v *archGraph, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) {
groups := map[string]*recordGroup{}
for _, rec := range inv.ByKind(infraview.KindDNSRecord) {
zoneName := rec.Attr("zone")
@@ -649,22 +796,24 @@ func recordNodeText(g *recordGroup) (title, subtitle string) {
}
// 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.
+// behind it — carrying its apps and instance counts on the card itself, and
+// the deployment-bucket edge when the caller named a bucket.
//
-// One apps node per box, never one per instance: a fleet of a hundred
-// tenants must still produce a diagram a person can read (spec §6.4).
-func addBoxNodes(v *ArchEstate, boxes []BoxCard, in ArchInput) {
+// The counts are one line per box, never one per instance: a fleet of a
+// hundred tenants must still produce a diagram a person can read (spec
+// §6.4).
+func addBoxNodes(v *archGraph, boxes []BoxCard, in ArchInput) {
for _, b := range boxes {
boxID := "box:" + b.Label
+ // The apps and the counts ride the box (Node.Extra) rather than
+ // standing as an "apps" node beside it. They were never a hop a
+ // request takes — they are what this box IS — and as a column they
+ // cost the picture 280 units of width to tell six boxes out of nine
+ // that they run nothing.
v.Nodes = append(v.Nodes, Node{
ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b),
+ Extra: []string{appsTitle(b), countsSubtitle(b)},
})
- appsID := "apps:" + b.Label
- v.Nodes = append(v.Nodes, Node{
- ID: appsID, Kind: "apps", Title: appsTitle(b), Subtitle: countsSubtitle(b),
- })
- v.Edges = append(v.Edges, Edge{From: boxID, To: appsID, Kind: "runs"})
if in.Bucket != "" {
v.Edges = append(v.Edges, Edge{From: boxID, To: "bucket:" + in.Bucket, Kind: "stores"})
}
@@ -692,7 +841,7 @@ func addBoxNodes(v *ArchEstate, boxes []BoxCard, in ArchInput) {
// dark, is drift — and drift is only visible if it is drawn. A machine a box
// does report is already on the page as that box, and drawing it twice would
// say the estate holds two things where it holds one.
-func addMachineNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
+func addMachineNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
@@ -716,7 +865,7 @@ func addMachineNodes(v *ArchEstate, inv *infraview.Inventory, claimed map[string
// nothing this page draws is still listed in the resource groups, and one
// naming something the page DOES draw becomes a mark on that node rather
// than a node in the flow (see Node.Alarms for why).
-func addAlarmBadges(v *ArchEstate, inv *infraview.Inventory, claimed map[string]string) {
+func addAlarmBadges(v *archGraph, inv *infraview.Inventory, claimed map[string]string) {
if inv == nil {
return
}
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go
index 546835f1b..1d5c312c2 100644
--- a/internal/console/archgraph_test.go
+++ b/internal/console/archgraph_test.go
@@ -88,13 +88,15 @@ func TestArchJoinsDNSThroughIPToBox(t *testing.T) {
// three read the whole page, so those tests keep asking exactly what they
// always asked. Which half is which is archestate_test.go's subject.
//
-// Nodes are deduped by id because one legitimately appears in both pictures:
-// a DNS zone that answers for both halves of the account.
+// Nodes are deduped by id because one legitimately appears in more than one
+// picture: the deployment bucket is drawn in the band of every region that
+// writes to it, and before zones stopped being drawn a shared zone appeared
+// in both banks.
func archNodes(v ArchView) []Node {
seen := map[string]bool{}
var out []Node
for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
- for _, n := range e.Nodes {
+ for _, n := range e.Nodes() {
if !seen[n.ID] {
seen[n.ID] = true
out = append(out, n)
@@ -104,11 +106,21 @@ func archNodes(v ArchView) []Node {
return out
}
-// Edges are NOT deduped: no edge may span the two banks (archestate_test.go
-// pins that), so every edge belongs to exactly one of them and the
-// concatenation is the whole edge set with nothing counted twice.
+// Edges are NOT deduped by id — they have none — but they ARE deduped as
+// values, because a node drawn in two bands brings its edges into both (the
+// deployment bucket, which every region writes to). No edge spans two banks
+// or two bands, so after that the concatenation is the whole edge set with
+// nothing counted twice.
func archEdges(v ArchView) []Edge {
- return append(append([]Edge(nil), v.CARLOS.Edges...), v.Rest.Edges...)
+ seen := map[Edge]bool{}
+ var out []Edge
+ for _, e := range append(v.CARLOS.Edges(), v.Rest.Edges()...) {
+ if !seen[e] {
+ seen[e] = true
+ out = append(out, e)
+ }
+ }
+ return out
}
// archGroups merges the two banks' tables back into one table per title, so
@@ -191,8 +203,16 @@ func TestArchClaimedMachineIsNotDrawnTwice(t *testing.T) {
if !hasNode(v, "box:pi-dublin") {
t.Error("a BYO box must still be drawn")
}
- if !hasNode(v, "apps:carlos-eu-west-1") || !hasEdge(v, "box:carlos-eu-west-1", "apps:carlos-eu-west-1") {
- t.Error("every box must reach its apps node")
+ // The apps a box runs are ON the box, not a node beside it. They used to
+ // be a column; a picture that fits the page cannot spare one for a fact
+ // that is a property of something already drawn.
+ if hasNode(v, "apps:carlos-eu-west-1") {
+ t.Error("apps are a line on the box card, not a node of their own")
+ }
+ if n := nodeByID(v, "box:carlos-eu-west-1"); n == nil {
+ t.Fatal("no box node")
+ } else if len(n.Extra) != 2 {
+ t.Errorf("box card Extra = %q, want the apps line and the counts line", n.Extra)
}
}
@@ -211,8 +231,11 @@ func TestArchDrawsRecordLeavingTheEstate(t *testing.T) {
if !hasEdge(v, "record:r2", "external:203.0.113.9") {
t.Errorf("the record must still point somewhere: %+v", archEdges(v))
}
- if !hasEdge(v, "zone:Z1", "record:r2") {
- t.Error("a record must hang off its zone")
+ // The zone is NOT drawn — it is global, and a global node cannot sit in a
+ // region band without being either nowhere or everywhere (archregion.go).
+ // It is still listed in full in the DNS table.
+ if hasNode(v, "zone:Z1") {
+ t.Error("a zone is table-only; it belongs to no region and must not be drawn")
}
}
@@ -425,13 +448,15 @@ func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) {
Attrs: map[string]string{"target": "i-gone"}},
)
v := BuildArch(in)
- // Per PICTURE, not across the two: an edge is only not-dangling if both
- // its ends are drawn in the same diagram it is drawn in.
- for _, est := range []ArchEstate{v.CARLOS, v.Rest} {
+ // Per PICTURE, not across the page: an edge is only not-dangling if both
+ // its ends are drawn in the same diagram it is drawn in, and a picture
+ // is now one region band rather than one estate.
+ for name, est := range eachBand(v) {
drawn := map[string]bool{}
for _, n := range est.Nodes {
drawn[n.ID] = true
}
+ _ = name
for _, e := range est.Edges {
if !drawn[e.From] || !drawn[e.To] {
t.Errorf("edge %+v points at a node this picture does not draw", e)
@@ -463,7 +488,9 @@ func TestEveryEdgeReachesThePage(t *testing.T) {
Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"},
})
v := BuildArch(in)
- out := string(RenderArchSVG(v.CARLOS)) + string(RenderArchSVG(v.Rest))
+ out := renderBoth(v)
+ // No closing quote on the class: a layer-skipping edge is drawn as
+ // `archedge archedge-skip` and is an edge the page draws all the same.
if got := strings.Count(out, `<path class="archedge`); got != len(archEdges(v)) {
t.Errorf("page draws %d edges, view carries %d", got, len(archEdges(v)))
}
@@ -570,10 +597,10 @@ func TestArchCountsAreUnknownWhenTheFleetNeverDescribedTheBox(t *testing.T) {
if got := countsSubtitle(undescribed); got != "counts unknown" {
t.Errorf("countsSubtitle = %q, want %q — a read that never happened is not a zero", got, "counts unknown")
}
- if n := nodeByID(v, "apps:extra-box"); n == nil {
- t.Fatal("no apps node for the host-only box")
- } else if strings.Contains(n.Subtitle, "static") {
- t.Errorf("the diagram states counts for a box the fleet never described: %q", n.Subtitle)
+ if n := nodeByID(v, "box:extra-box"); n == nil {
+ t.Fatal("no box node for the host-only box")
+ } else if strings.Contains(strings.Join(n.Extra, " "), "static") {
+ t.Errorf("the diagram states counts for a box the fleet never described: %q", n.Extra)
}
}
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go
index 26d25e407..1f34be28b 100644
--- a/internal/console/archlayout.go
+++ b/internal/console/archlayout.go
@@ -16,47 +16,65 @@ import (
"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.
+// Layer assignment by node kind — the story one band tells, left to right,
+// in FOUR columns and no more. Four is not a taste: the console's main
+// column is 64rem = 1024px, and 4*190 + 3*70 + 2*24 = 1018 is the widest
+// picture that fits inside it. A fifth column is 260 units the page does
+// not have, and the page having them is the difference between reading the
+// diagram and dragging it.
//
-// Alarms are deliberately absent from this map. They were briefly given
-// layer 3 — the column between the IPs and the boxes — and that column is
-// the one every IP-to-box edge crosses. Nodes paint after edges, so each
-// alarm covered the line running under it and the diagram read as a
-// request reaching its box THROUGH an alarm. An alarm is not a hop; it is
-// a property of the thing it watches, so it now rides that node as a mark
-// (Node.Alarms) and no layer can put it in the way of a line.
+// Two foldings bought the room. "apps" is gone as a kind: what a box runs
+// is a property of the box and now rides its card (Node.Extra), where it
+// used to be a whole column in which six of nine cards said "no apps".
+// "external" shares the address column with "ip" rather than standing to
+// its right: both answer the one question a reader has at that point in the
+// line — what does this name resolve to — and the distinction between an
+// address we allocated and one outside the estate is carried by the node's
+// kind, which is drawn, rather than by its column, which is not.
+//
+// "zone" is absent because zones are not drawn at all (dropSatellites).
+// Alarms are absent for a different reason: they were briefly given a
+// column of their own, and it was the one column every address-to-box edge
+// crosses. Nodes paint after edges, so each alarm covered the line running
+// under it and the diagram read as a request reaching its box THROUGH an
+// alarm. An alarm is not a hop; it is a property of the thing it watches,
+// so it rides that node as a mark (Node.Alarms) and no column can put it in
+// the way of a line.
var archLayer = map[string]int{
- "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3,
- "box": 4, "machine": 4, "apps": 5, "bucket": 6,
+ "record": 0, "ip": 1, "external": 1, "router": 1,
+ "box": 2, "machine": 2, "bucket": 3,
}
const (
- nodeW = 190
- nodeH = 64
- gapX = 90
+ nodeW = 190
+ // nodeH is the height of a node carrying a title and a subtitle; each
+ // Extra line adds nodeLineH. Height is per NODE rather than one constant
+ // for the whole picture because the box card needs four lines and the
+ // records outnumber the boxes twenty to one — giving every node the
+ // tallest node's height would add thousands of units of whitespace to
+ // the estate with the most nodes and least to say about each.
+ nodeH = 64
+ nodeLineH = 17
+ // gapX is 70 rather than the 90 the single-picture layout used, and
+ // that 20 units per gap is what brings four columns inside 1024. It is
+ // the gap, not the node, that gave way: the node width is what a
+ // hostname fits in.
+ gapX = 70
gapY = 26
padding = 24
- satelliteKinds = "identity firewall network disk certificate other"
+ satelliteKinds = "identity firewall network disk certificate other zone"
)
-func layout(v *ArchEstate) {
- // Identities, firewalls, networks, disks and certificates are listed in
- // the tables 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. Alarms are not in this list because they are no longer
- // nodes at all — they ride the node they watch (see archLayer above).
- kept := v.Nodes[:0]
- for _, n := range v.Nodes {
- if strings.Contains(satelliteKinds, n.Kind) {
- continue
- }
- kept = append(kept, n)
- }
- v.Nodes = kept
+// nodeHeight is how tall one node is drawn. One place, because layout
+// positions by it and RenderArchSVG writes text down it, and the two
+// disagreeing would put a line of text outside its own rect.
+func nodeHeight(n Node) int { return nodeH + len(n.Extra)*nodeLineH }
+// layout places one BAND. The satellite kinds are already gone by the time
+// it runs (dropSatellites, called before banding so a global zone cannot
+// carry a region along its edges), so every node it is handed is a node it
+// draws.
+func layout(v *ArchBand) {
byLayer := map[int][]int{}
for i := range v.Nodes {
l := archLayer[v.Nodes[i].Kind]
@@ -140,15 +158,22 @@ func layout(v *ArchEstate) {
sweep(true)
sweep(false)
- maxRows := 0
+ // Position. Rows stack by CUMULATIVE height rather than by row index
+ // times a constant, because nodes are not all the same height any more
+ // (Node.Extra). Edge routing reads Y+H/2 off the node, so it follows
+ // this for free.
+ tallest := 0
for l, idx := range byLayer {
- for r, i := range idx {
- v.Nodes[i].W, v.Nodes[i].H = nodeW, nodeH
+ y := padding
+ for _, i := range idx {
+ v.Nodes[i].W = nodeW
+ v.Nodes[i].H = nodeHeight(v.Nodes[i])
v.Nodes[i].X = padding + l*(nodeW+gapX)
- v.Nodes[i].Y = padding + r*(nodeH+gapY)
+ v.Nodes[i].Y = y
+ y += v.Nodes[i].H + gapY
}
- if len(idx) > maxRows {
- maxRows = len(idx)
+ if len(idx) > 0 && y-gapY > tallest {
+ tallest = y - gapY
}
}
layers := 0
@@ -157,50 +182,33 @@ func layout(v *ArchEstate) {
layers = l + 1
}
}
- // An empty graph (no boxes, no inventory yet — an ungranted console on
+ // An empty band (no boxes, no inventory yet — an ungranted console on
// day one) still has to carry a valid, positive viewBox: it is the
// FIRST page an operator sees, not the busiest. Without this clamp
- // layers==0 and maxRows==0 drive Width/Height negative.
+ // layers==0 and tallest==0 drive Width/Height negative.
if layers < 1 {
layers = 1
}
- if maxRows < 1 {
- maxRows = 1
+ if tallest < padding+nodeH {
+ tallest = padding + nodeH
}
v.Width = padding*2 + layers*nodeW + (layers-1)*gapX
- v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY
+ v.Height = tallest + padding
}
-// 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.
-//
-// width/height are emitted ALONGSIDE the viewBox, and they are what make the
-// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
-// so a browser sizes it at 100% of its container and scales the drawing to
-// fit: a full estate is 1918 units wide and the console's main column is
-// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever
-// appears, because the SVG never overflows anything. With an intrinsic size
-// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the
-// viewport is narrower — legible everywhere, at the cost of a sideways
-// scroll on the widest graphs, which is the right trade for a diagram whose
-// labels are the content.
-// 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.
+// RenderArchSVG emits one band's 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.
//
// width/height are emitted ALONGSIDE the viewBox, and they are what make the
// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
// so a browser sizes it at 100% of its container and scales the drawing to
-// fit: a full estate is 1918 units wide and the console's main column is
-// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever
-// appears, because the SVG never overflows anything. With an intrinsic size
-// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the
-// viewport is narrower — legible everywhere, at the cost of a sideways
-// scroll on the widest graphs, which is the right trade for a diagram whose
-// labels are the content.
+// fit, rendering 13px node titles at whatever fraction that works out to.
+// With an intrinsic size the drawing renders 1:1. A band is at most 1018
+// units wide by construction (archLayer), which is inside the console's
+// 64rem column, so at 1:1 it neither shrinks nor overflows: the page grows
+// DOWNWARDS as an estate gains regions and never sideways.
// The text-fitting budget. There are no font metrics in a server-rendered
// SVG, so the width of a string is estimated: for the ui-sans stack these
// nodes use, an average glyph runs a little over half its point size, and
@@ -249,12 +257,14 @@ func fitText(s string, px, avail int) string {
// character before the character that tells it apart from the other three
// (discussion #9), which is the worst available place to run out of room.
//
-// The alarms did not need that space. A node is 64 tall and carries two
-// baselines, at +24 and +44; a 5px mark centred at +56 clears the subtitle's
-// descenders and sits inside the rect's rounded corner. So the marks moved
-// down and BOTH lines of text got the node's full width back. #322 fixed text
-// overflowing its box by taking width away from the title; this gives it back
-// without putting the overflow back.
+// The alarms did not need that space. A node is nodeH (64) tall plus
+// nodeLineH per Extra line, and its LAST baseline — the subtitle's at +44, or
+// the last Extra line's — is always 20 above the rect's bottom edge
+// (nodeHeight). A 5px mark centred 8 above that edge clears the last line's
+// descenders and sits inside the rect's rounded corner, whatever the line
+// count. So the marks moved down and EVERY line of text got the node's full
+// width back. #322 fixed text overflowing its box by taking width away from
+// the title; this gives it back without putting the overflow back.
func alarmRow(n Node) int { return n.Y + n.H - 8 }
// nodeTitleText is the node in full for the hover title: what the boxes
@@ -264,13 +274,18 @@ func nodeTitleText(n Node) string {
if n.Subtitle != "" {
out += " — " + n.Subtitle
}
+ for _, ex := range n.Extra {
+ if ex != "" {
+ out += " · " + ex
+ }
+ }
for _, a := range n.Alarms {
out += " · " + a.Name + " " + a.State
}
return out
}
-func RenderArchSVG(v ArchEstate) template.HTML {
+func RenderArchSVG(v ArchBand) template.HTML {
var b strings.Builder
fmt.Fprintf(&b, `<svg class="archsvg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`,
v.Width, v.Height, v.Width, v.Height)
@@ -311,10 +326,16 @@ func RenderArchSVG(v ArchEstate) template.HTML {
// fixed for the alarm column and not for edges.
//
// The lane is below every node, so it crosses nothing, and both
- // vertical runs sit in the middle of an inter-layer GAP (90 wide,
- // nodes never enter it) rather than in a column. Skipping edges
- // share one lane and read as a bus, which is honest: they are the
- // edges that do not follow the left-to-right story.
+ // vertical runs sit in the middle of an inter-layer GAP (gapX
+ // wide, nodes never enter it) rather than in a column. Skipping
+ // edges share one lane and read as a bus, which is honest: they
+ // are the edges that do not follow the left-to-right story.
+ //
+ // Folding "external" into the address column (archLayer) removed
+ // the ip -> box case #9 was caught on, but not the mechanism: a
+ // record whose value is an address a machine holds DIRECTLY
+ // (recordTarget) points at the box from the records column, two
+ // layers away, and an ip in the same row would sit under it.
lane := v.Height - padding/2
fmt.Fprintf(&b, `<path class="archedge archedge-skip" d="M%d %d H%d V%d H%d V%d H%d" />`,
x1, y1, x1+gapX/2, lane, x2-gapX/2, y2, x2)
@@ -336,6 +357,15 @@ func RenderArchSVG(v ArchEstate) template.HTML {
fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
n.X+textPad, n.Y+44, template.HTMLEscapeString(fitText(n.Subtitle, subPx, n.W-2*textPad)))
}
+ // Extra lines carry on down the card at the same step layout used to
+ // size it (nodeLineH), so the last line lands inside the rect.
+ for i, ex := range n.Extra {
+ if ex == "" {
+ continue
+ }
+ fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
+ n.X+textPad, n.Y+44+(i+1)*nodeLineH, template.HTMLEscapeString(fitText(ex, subPx, n.W-2*textPad)))
+ }
// One mark per alarm, bottom-right, filling leftwards. A firing alarm
// gets its own class so it reads as different at a glance rather
// than only in the words of its hover title.
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go
index f4899041a..81b10f93a 100644
--- a/internal/console/archlayout_test.go
+++ b/internal/console/archlayout_test.go
@@ -19,24 +19,46 @@ func TestLayoutIsDeterministic(t *testing.T) {
}
}
-// renderBoth is the whole page's drawing: the CARLOS picture and the
-// account's other one. Determinism is a property of the PAGE, so every test
-// about it renders both — a split that made one picture stable by letting
-// the other reshuffle would be no improvement on one that reshuffled.
+// renderBoth is the whole page's drawing: every band of the CARLOS estate
+// and every band of the account's other one, in page order. Determinism is a
+// property of the PAGE, so every test about it renders all of it — a split
+// that made one picture stable by letting another reshuffle would be no
+// improvement on one that reshuffled. It also covers the band ORDER, which
+// is part of what a reader would see move.
func renderBoth(v ArchView) string {
- return string(RenderArchSVG(v.CARLOS)) + "\n" + string(RenderArchSVG(v.Rest))
+ return renderEstate(v.CARLOS) + "\n" + renderEstate(v.Rest)
}
-// eachEstate names the two pictures, so a test that must hold of both says
-// which one failed.
-func eachEstate(v ArchView) map[string]ArchEstate {
- return map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest}
+// renderEstate is one estate's drawing: every band of it, in page order.
+func renderEstate(e ArchEstate) string {
+ var out []string
+ for _, b := range e.Bands {
+ out = append(out, b.Label+"\n"+string(RenderArchSVG(b)))
+ }
+ return strings.Join(out, "\n")
+}
+
+// eachBand names every picture on the page — one per estate per region — so
+// a test that must hold of all of them says which one failed. It is the
+// unit those tests want: a band is what gets laid out, sized and drawn, and
+// the properties they assert (nothing overlaps, nothing sits on an edge,
+// the viewBox is positive) are properties of one picture.
+func eachBand(v ArchView) map[string]ArchBand {
+ out := map[string]ArchBand{}
+ for name, e := range map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest} {
+ for _, b := range e.Bands {
+ out[name+" / "+b.Label] = b
+ }
+ }
+ return out
}
-// 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.
+// Layer order is the story one band tells: a DNS name enters at the left,
+// resolves to an address, and reaches the box that answers for it. Four
+// columns, because four is what fits the page (archLayer). 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{
@@ -48,10 +70,17 @@ func TestLayoutOrdersLayers(t *testing.T) {
for _, n := range archNodes(v) {
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"]) {
+ if !(x["record:r1"] < x["ip:eipalloc-1"] && x["ip:eipalloc-1"] < x["box:carlos-eu-west-1"]) {
t.Fatalf("layers out of order: %+v", x)
}
+ // And the whole band fits the console's 64rem main column, which is the
+ // entire reason the columns were folded down to four. A picture wider
+ // than this is one the operator has to drag.
+ for name, b := range eachBand(v) {
+ if b.Width > 1024 {
+ t.Errorf("%s: band is %d units wide — it must fit the 1024px main column without scrolling sideways", name, b.Width)
+ }
+ }
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)
}
@@ -63,7 +92,7 @@ func TestLayoutDoesNotOverlap(t *testing.T) {
v := BuildArch(archFixture())
// Per picture: two nodes in DIFFERENT diagrams sharing a coordinate is
// not an overlap, it is two diagrams.
- for name, est := range eachEstate(v) {
+ for name, est := range eachBand(v) {
for i := range est.Nodes {
for j := i + 1; j < len(est.Nodes); j++ {
a, b := est.Nodes[i], est.Nodes[j]
@@ -95,7 +124,7 @@ func TestSVGIsSelfContainedAndEscaped(t *testing.T) {
// page an operator sees after this ships, not a contrived corner case.
func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
v := BuildArch(ArchInput{})
- for name, est := range eachEstate(v) {
+ for name, est := range eachBand(v) {
if len(est.Nodes) != 0 {
t.Fatalf("%s: expected to draw no nodes at all, got %d", name, len(est.Nodes))
}
@@ -122,7 +151,7 @@ func TestLayoutEmptyGraphHasValidViewBox(t *testing.T) {
func TestSVGCarriesIntrinsicSize(t *testing.T) {
v := BuildArch(archFixture())
out := renderBoth(v)
- for name, est := range eachEstate(v) {
+ for name, est := range eachBand(v) {
if want := fmt.Sprintf(`width="%d" height="%d"`, est.Width, est.Height); !strings.Contains(out, want) {
t.Errorf("%s: SVG must carry %s so it renders 1:1 and .archwrap scrolls; got:\n%s", name, want, out)
}
@@ -175,7 +204,7 @@ func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
}
}
- for name, est := range eachEstate(v) {
+ for name, est := range eachBand(v) {
for i := range est.Nodes {
for j := i + 1; j < len(est.Nodes); j++ {
a, b := est.Nodes[i], est.Nodes[j]
@@ -190,29 +219,31 @@ func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) {
}
}
-// The ordering contract between layout and pruneDanglingEdges. LAYOUT is
-// what decides which nodes are drawn — it drops the satellite kinds the page
-// lists in tables instead of drawing — so the prune has to run after it, or
-// the shipped ArchView carries edges to nodes nobody draws and the invariant
-// rests entirely on RenderArchSVG's skip. Two halves: the two functions
-// compose in that order, and a built view is already clean when it ships.
+// The ordering contract inside bandsFrom: satellites are dropped, THEN the
+// bands are decided, THEN each is laid out, and only then are its dangling
+// edges pruned. Get it wrong and the page carries edges to nodes nobody
+// draws, leaving the invariant to rest entirely on RenderArchSVG's skip.
+// Two halves: the steps compose in that order, and a built view is already
+// clean when it ships.
func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
- e := ArchEstate{
+ g := archGraph{
Nodes: []Node{
{ID: "box:a", Kind: "box", Title: "a"},
{ID: "identity:role", Kind: "identity", Title: "carlos-box-role"},
},
Edges: []Edge{{From: "box:a", To: "identity:role", Kind: "runs"}},
}
- layout(&e)
- pruneDanglingEdges(&e)
- for _, n := range e.Nodes {
+ bands := bandsFrom(g, map[string]string{"box:a": "eu-west-1"})
+ if len(bands) != 1 || bands[0].Region != "eu-west-1" {
+ t.Fatalf("bands = %+v, want one eu-west-1 band", bands)
+ }
+ for _, n := range bands[0].Nodes {
if n.ID == "identity:role" {
- t.Fatal("an identity is table-only; layout must drop it")
+ t.Fatal("an identity is table-only; it must be dropped before it is drawn")
}
}
- if len(e.Edges) != 0 {
- t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", e.Edges)
+ if len(bands[0].Edges) != 0 {
+ t.Errorf("edges = %+v, want none — the only edge pointed at a dropped node", bands[0].Edges)
}
// And on a real build carrying both drawn alarms and a partial estate:
@@ -224,7 +255,7 @@ func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) {
Attrs: map[string]string{"target": "i-terminated", "state": "OK"},
})
built := BuildArch(in)
- for name, est := range eachEstate(built) {
+ for name, est := range eachBand(built) {
before := len(est.Edges)
pruneDanglingEdges(&est)
if len(est.Edges) != before {
diff --git a/internal/console/archlegibility_test.go b/internal/console/archlegibility_test.go
index fab5ef321..b51d1f1e8 100644
--- a/internal/console/archlegibility_test.go
+++ b/internal/console/archlegibility_test.go
@@ -23,15 +23,15 @@ import (
func TestNoNodeSitsOnAnEdgePath(t *testing.T) {
// Per picture: a node in the other diagram cannot obscure this one's
// lines, and coordinates only mean anything inside one picture.
- for name, est := range eachEstate(BuildArch(archFixture())) {
+ for name, est := range eachBand(BuildArch(archFixture())) {
assertNoNodeOnAnEdge(t, name, est)
}
}
// TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips is the case #9 caught in
-// a browser and this file's fixture did not have: an ip -> box edge with an
-// `external` node (an MX target, a dead end that leaves the estate) sitting
-// in the layer between them, on the same row.
+// a browser and this file's fixture did not have: an edge that skips a
+// layer, with an unrelated node sitting in the layer between its ends, on
+// the same row.
//
// With the plain three-segment route the line turned at the midpoint, which
// for such an edge is INSIDE the skipped column, so it ran under that node
@@ -39,16 +39,22 @@ func TestNoNodeSitsOnAnEdgePath(t *testing.T) {
// continuous chain — `carloku.com -> au.oncarlos.com -> 13.55.0.44 -> 10
// inbound-smtp… -> carlos-flagship-au -> kass -> bucket` — of which two hops
// are fiction.
+//
+// #9's own edge was ip -> box past an `external` column; #390 folded
+// external into the address column, so that pair is adjacent now. The skip
+// that remains is record -> box: a record whose value is an address the
+// machine holds directly rather than through an allocated IP (recordTarget),
+// which points at the box from two columns away, past whatever ip or
+// external node shares its row.
func TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips(t *testing.T) {
- est := ArchEstate{
- Nodes: []Node{
- {ID: "ip:13.55.0.44", Kind: "ip", Title: "13.55.0.44"},
- {ID: "ext:mx", Kind: "external", Title: "10 inbound-smtp.eu-west-1.amazonaws.com"},
- {ID: "box:au", Kind: "box", Title: "carlos-flagship-au"},
- },
- // The one edge, skipping the external column the MX node occupies.
- Edges: []Edge{{From: "ip:13.55.0.44", To: "box:au", Kind: "routes"}},
- }
+ est := ArchBand{}
+ est.Nodes = []Node{
+ {ID: "record:au", Kind: "record", Title: "au.oncarlos.com"},
+ {ID: "ext:mx", Kind: "external", Title: "10 inbound-smtp.eu-west-1.amazonaws.com"},
+ {ID: "box:au", Kind: "box", Title: "carlos-flagship-au"},
+ }
+ // The one edge, skipping the address column the MX node occupies.
+ est.Edges = []Edge{{From: "record:au", To: "box:au", Kind: "resolves"}}
layout(&est)
assertNoNodeOnAnEdge(t, "skip", est)
@@ -65,7 +71,7 @@ type seg struct{ at, a, b int }
// edgeSegments is RenderArchSVG's routing, in the same two branches and with
// the same numbers. Kept beside the renderer's own code deliberately: the
// value of this whole file is that it checks what is DRAWN.
-func edgeSegments(v ArchEstate, from, to Node) (horizontal, vertical []seg) {
+func edgeSegments(v ArchBand, from, to Node) (horizontal, vertical []seg) {
x1, y1 := from.X+from.W, from.Y+from.H/2
x2, y2 := to.X, to.Y+to.H/2
if archLayer[to.Kind]-archLayer[from.Kind] > 1 {
@@ -85,9 +91,9 @@ func edgeSegments(v ArchEstate, from, to Node) (horizontal, vertical []seg) {
}
// assertNoNodeOnAnEdge checks every drawn edge against every node that is not
-// one of its endpoints. name says which picture, since the two estates are
-// checked separately and their coordinates are unrelated.
-func assertNoNodeOnAnEdge(t *testing.T, name string, v ArchEstate) {
+// one of its endpoints. name says which picture, since every band is checked
+// separately and their coordinates are unrelated.
+func assertNoNodeOnAnEdge(t *testing.T, name string, v ArchBand) {
t.Helper()
pos := map[string]Node{}
for _, n := range v.Nodes {
@@ -185,13 +191,25 @@ func TestSVGTextIsFittedToItsBox(t *testing.T) {
in := archFixture()
v := BuildArch(in)
long := "console, kass, keymail, platform-site, woodstar, status, carloku-site"
- // apps nodes are CARLOS by definition — a box's apps belong to the box.
- for i := range v.CARLOS.Nodes {
- if v.CARLOS.Nodes[i].Kind == "apps" {
- v.CARLOS.Nodes[i].Title = long
- v.CARLOS.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3"
+ // A box's apps line is the longest string on the page and the one most
+ // likely to bleed. Written into the BAND's own node slice — Nodes() is a
+ // fresh copy per call, so mutating what it returns would change nothing
+ // and this test would pass by drawing the untouched fixture.
+ touched := false
+ for bi := range v.CARLOS.Bands {
+ for i := range v.CARLOS.Bands[bi].Nodes {
+ if v.CARLOS.Bands[bi].Nodes[i].Kind != "box" {
+ continue
+ }
+ v.CARLOS.Bands[bi].Nodes[i].Extra = []string{
+ long, "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3",
+ }
+ touched = true
}
}
+ if !touched {
+ t.Fatal("no box node to widen — the fixture stopped exercising this")
+ }
out := renderBoth(v)
if strings.Contains(out, ">"+long+"<") {
t.Error("a title wider than its rect must be truncated, not drawn past the box")
diff --git a/internal/console/archpage.go b/internal/console/archpage.go
index 2f1e351f1..eeab80750 100644
--- a/internal/console/archpage.go
+++ b/internal/console/archpage.go
@@ -94,13 +94,13 @@ func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) {
if len(failed) > 0 {
v.Partial = append(append([]string{}, v.Partial...), failed...)
}
+ // The pictures are NOT rendered here any more: an estate has one band
+ // per region and the count is not known until it is built, so the
+ // template ranges over the bands and each draws itself (ArchBand.SVG).
+ // The SVG is still built in Go, which is what keeps it deterministic
+ // and testable as a whole.
p := page{Title: "Architecture", Address: addr, CSRF: raw,
- Arch: &v, ArchBoxesUnread: boxesUnread,
- // One picture per estate, rendered here rather than in the template
- // for the reason the single picture was: html/template cannot call
- // RenderArchSVG, and the SVG must be built in Go so it stays
- // deterministic and testable as a whole.
- ArchSVG: RenderArchSVG(v.CARLOS), ArchRestSVG: RenderArchSVG(v.Rest)}
+ Arch: &v, ArchBoxesUnread: boxesUnread}
p.Flash, p.FlashErr = s.takeFlash(w, r)
s.render(w, http.StatusOK, "architecture", p)
}
diff --git a/internal/console/archpage_test.go b/internal/console/archpage_test.go
index 9266b2836..54b27ac56 100644
--- a/internal/console/archpage_test.go
+++ b/internal/console/archpage_test.go
@@ -13,6 +13,8 @@ import (
"net/http"
"net/url"
"path/filepath"
+ "regexp"
+ "strconv"
"strings"
"testing"
"time"
@@ -250,8 +252,23 @@ func TestArchitectureDrawsBothEstates(t *testing.T) {
if strings.Contains(body, "legacy") || strings.Contains(body, "Legacy") {
t.Errorf("the second bank must not be named for one deployment's history:\n%s", body)
}
- if n := strings.Count(body, `class="archsvg"`); n != 2 {
- t.Errorf("page draws %d diagrams, want 2 — one per estate", n)
+ // One diagram per REGION BAND, each under its own heading — not one per
+ // estate. That is what makes the page scroll down instead of across, so
+ // a page that had quietly gone back to one picture per estate would draw
+ // the same content and be the bug this shape exists to fix.
+ bands := strings.Count(body, `<h4 class="archband">`)
+ if bands < 2 {
+ t.Fatalf("page draws %d bands — a page back to one picture per estate is the bug this shape exists to fix:\n%s", bands, body)
+ }
+ if n := strings.Count(body, `class="archsvg"`); n != bands {
+ t.Errorf("page draws %d diagrams under %d band headings — every band has exactly one picture", n, bands)
+ }
+ // Every drawn picture fits the console's 64rem main column, which is the
+ // whole user-visible point: the page grows downwards, never sideways.
+ for _, w := range regexp.MustCompile(`<svg class="archsvg" width="(\d+)"`).FindAllStringSubmatch(body, -1) {
+ if px, _ := strconv.Atoi(w[1]); px > 1024 {
+ t.Errorf("a band is %dpx wide — the page would scroll sideways", px)
+ }
}
}
diff --git a/internal/console/archregion.go b/internal/console/archregion.go
new file mode 100644
index 000000000..da5e89324
--- /dev/null
+++ b/internal/console/archregion.go
@@ -0,0 +1,179 @@
+// Which region band each drawn node belongs to.
+//
+// THE GOVERNING RULE: A NODE'S BAND IS THE BAND OF THE MACHINE IT REACHES.
+//
+// A region is a physical fact about a MACHINE and about almost nothing else
+// on this page. DNS is global — every one of Route 53's records and zones
+// comes back from the estate read with no region at all — an Elastic IP is
+// in a region but is only interesting for the box it is attached to, and a
+// bucket is written to from everywhere. So exactly two kinds ANCHOR a band
+// by stating their own region (a box, and a machine no box reports), and
+// everything else FOLLOWS the anchors it is joined to.
+//
+// The payoff is a property, not a preference: because a follower takes the
+// band of whatever it is joined to, no edge can span two bands, and each
+// band is therefore a self-contained little diagram that can be laid out
+// and drawn on its own. That is what lets the page scroll down instead of
+// across — the width of the picture stops being the width of the whole
+// estate and becomes the width of one region's flow.
+//
+// A follower joined to two regions is drawn in BOTH, exactly as the zone
+// shared by both estate banks is (archestate.go). The deployment bucket is
+// the everyday case: nine boxes in six regions all write to it, and the
+// alternative — drawing it once and letting five edges dangle — would hide
+// a relationship the console knows first-hand.
+//
+// A node that reaches no anchor at all lands in the region-less band. That
+// is not a failure state: a DNS name pointing outside the estate genuinely
+// has no region, and saying so is the honest answer. It is drawn last,
+// under its own heading, and never dropped.
+package console
+
+import (
+ "sort"
+
+ "github.com/carlosframework/platform/internal/infraview"
+)
+
+// noRegion is the band for everything that reaches no machine. It sorts
+// last and is labelled in words rather than left blank, because an unlabelled
+// band reads as a rendering fault.
+const noRegion = ""
+
+// anchorKinds state their own region. Deliberately just these two: they are
+// the only nodes on the page that stand for something with a physical
+// location. Adding a kind here would make it a source of truth about where
+// its NEIGHBOURS live, which is how a bucket in eu-west-1 would drag nine
+// boxes out of five other regions into one band.
+func isAnchor(kind string) bool { return kind == "box" || kind == "machine" }
+
+// regionSeeds is what each node says about its OWN region, before any
+// propagation. Anchors are believed; a follower's own region is kept only
+// as the last resort in bandsOf (an allocated address attached to nothing
+// still has a region, and putting it in the region-less band would be a
+// worse answer than the one the estate gave us).
+func regionSeeds(inv *infraview.Inventory, boxes []BoxCard) map[string]string {
+ seeds := map[string]string{}
+ for _, b := range boxes {
+ if b.Region != "" {
+ seeds["box:"+b.Label] = b.Region
+ }
+ }
+ if inv == nil {
+ return seeds
+ }
+ for _, r := range inv.Resources {
+ if r.Region == "" {
+ continue
+ }
+ switch r.Kind {
+ case infraview.KindMachine:
+ seeds["machine:"+r.ID] = r.Region
+ case infraview.KindIP:
+ seeds["ip:"+r.ID] = r.Region
+ case infraview.KindBucket:
+ seeds["bucket:"+nameOf(r)] = r.Region
+ }
+ }
+ return seeds
+}
+
+// bandsOf decides, for every node, the set of bands it is drawn in.
+//
+// Anchors take their seed and are then fixed. Followers accumulate the
+// bands of their neighbours — in BOTH directions along an edge, because a
+// record reaches its box forwards and a bucket reaches its boxes backwards,
+// and a rule that only looked one way would put half the followers in the
+// region-less band. The sweep repeats until nothing changes; it is bounded
+// by the node count, since each pass either adds a band to some node or is
+// the last one.
+//
+// Union is commutative, so the result cannot depend on the order edges or
+// nodes are visited — which is what keeps the whole picture deterministic
+// (archlayout.go's contract) without this pass having to sort anything.
+func bandsOf(nodes []Node, edges []Edge, seeds map[string]string) map[string]map[string]bool {
+ in := make(map[string]map[string]bool, len(nodes))
+ kind := make(map[string]string, len(nodes))
+ for _, n := range nodes {
+ in[n.ID] = map[string]bool{}
+ kind[n.ID] = n.Kind
+ if isAnchor(n.Kind) {
+ if r, ok := seeds[n.ID]; ok && r != "" {
+ in[n.ID][r] = true
+ }
+ }
+ }
+ for pass := 0; pass <= len(nodes); pass++ {
+ changed := false
+ for _, e := range edges {
+ from, okF := in[e.From]
+ to, okT := in[e.To]
+ if !okF || !okT {
+ continue
+ }
+ if !isAnchor(kind[e.To]) {
+ for r := range from {
+ if !to[r] {
+ to[r] = true
+ changed = true
+ }
+ }
+ }
+ if !isAnchor(kind[e.From]) {
+ for r := range to {
+ if !from[r] {
+ from[r] = true
+ changed = true
+ }
+ }
+ }
+ }
+ if !changed {
+ break
+ }
+ }
+ // Last resort, and only now: a follower that reached no anchor keeps
+ // whatever region the estate gave it. Applying this before the sweep
+ // would let an Elastic IP's own region compete with the box it is
+ // attached to, which is the same mistake as making it an anchor.
+ for id, set := range in {
+ if len(set) == 0 {
+ if r := seeds[id]; r != "" {
+ set[r] = true
+ }
+ }
+ }
+ return in
+}
+
+// bandOrder is the order the bands are drawn: alphabetical by region code,
+// with the region-less band last.
+//
+// Alphabetical rather than by size, and rather than by any home-region
+// notion, for the reason the whole diagram is deterministic: an operator
+// reads this page to see what CHANGED, and an order that reshuffles when a
+// box is added makes every band look like it moved. Region CODES rather
+// than city names, because a code-to-city table would be a second source of
+// truth about a cloud's geography and the code is what every other console,
+// every CLI and every bill already says.
+func bandOrder(bands map[string]bool) []string {
+ var named []string
+ for r := range bands {
+ if r != noRegion {
+ named = append(named, r)
+ }
+ }
+ sort.Strings(named)
+ if bands[noRegion] {
+ named = append(named, noRegion)
+ }
+ return named
+}
+
+// bandLabel is the heading over one band.
+func bandLabel(region string) string {
+ if region == noRegion {
+ return "Not in a region"
+ }
+ return region
+}
diff --git a/internal/console/archregion_test.go b/internal/console/archregion_test.go
new file mode 100644
index 000000000..fb3e05be5
--- /dev/null
+++ b/internal/console/archregion_test.go
@@ -0,0 +1,343 @@
+package console
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/infraview"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+// multiRegionFixture is the estate this feature exists for: boxes in four
+// regions, two of them in the SAME region (so a band has to hold more than
+// one flow), one machine no box reports, one DNS name pointing outside the
+// estate entirely, and one deployment bucket every box writes to.
+func multiRegionFixture() ArchInput {
+ res := []infraview.Resource{
+ {Kind: infraview.KindDNSZone, ID: "Z1", Name: "tito.io"},
+ {Kind: infraview.KindBucket, ID: "carlos-tito-platform", Name: "carlos-tito-platform", Region: "eu-west-1"},
+
+ // A name that leaves the estate. It reaches no machine, so it and
+ // the record naming it belong to no region at all.
+ {Kind: infraview.KindDNSRecord, ID: "rec-away", Name: "mail.tito.io",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": "203.0.113.9"}},
+
+ // A machine nothing reports: drift, and it anchors its own band.
+ {Kind: infraview.KindMachine, ID: "i-orphan", Name: "old-thing", Region: "us-east-1",
+ Attrs: map[string]string{"type": "t3.micro", "state": "stopped"}},
+ }
+ type box struct{ label, region, machine, addr string }
+ boxes := []box{
+ {"carlos-tito-ie", "eu-west-1", "i-ie", "52.0.0.1"},
+ {"carlos-tito-svc-pdf", "eu-west-1", "i-pdf", "52.0.0.2"},
+ {"carlos-tito-uk", "eu-west-2", "i-uk", "52.0.0.3"},
+ {"carlos-tito-se", "eu-north-1", "i-se", "52.0.0.4"},
+ }
+ var fleet []status.FleetBox
+ var hosts []HostRow
+ for _, b := range boxes {
+ res = append(res,
+ infraview.Resource{Kind: infraview.KindMachine, ID: b.machine, Name: b.label, Region: b.region,
+ Attrs: map[string]string{"type": "t4g.small", "state": "running"}},
+ infraview.Resource{Kind: infraview.KindIP, ID: "eip-" + b.label, Region: b.region,
+ Attrs: map[string]string{"address": b.addr, "attached_to": b.machine}},
+ infraview.Resource{Kind: infraview.KindDNSRecord, ID: "rec-" + b.label, Name: b.label + ".tito.io",
+ Attrs: map[string]string{"zone": "tito.io", "type": "A", "value": b.addr}},
+ )
+ fleet = append(fleet, status.FleetBox{Label: b.label, Region: b.region})
+ hosts = append(hosts, HostRow{Label: b.label, Region: b.region,
+ Metrics: &status.BoxMetrics{Release: "abc1234", InstanceID: b.machine}})
+ }
+ return ArchInput{
+ Inv: &infraview.Inventory{Provider: "aws", Account: "665106592142", Resources: res},
+ Bucket: "carlos-tito-platform",
+ Fleet: &status.Fleet{Boxes: fleet},
+ Hosts: hosts,
+ }
+}
+
+// bandNames is every picture on the page, in the order it is drawn.
+func bandNames(e ArchEstate) []string {
+ var out []string
+ for _, b := range e.Bands {
+ out = append(out, b.Region)
+ }
+ return out
+}
+
+// THE property the whole band design rests on, and it has two halves that
+// must BOTH be asserted.
+//
+// A band is drawn as a picture of its own, so an edge with one end in
+// another band cannot be drawn — and pruneDanglingEdges duly removes it from
+// both. That is the trap: checking only that every DRAWN edge has both ends
+// in its band is a test a totally broken banding rule passes, because the
+// spanning edges are gone by the time it looks. So the second half asks the
+// question that actually matters — every edge that ought to be on the page
+// IS on the page — and it is the half that fails when a follower stops
+// following.
+func TestNoEdgeSpansTwoBands(t *testing.T) {
+ in := multiRegionFixture()
+ v := BuildArch(in)
+
+ drawn := map[string]bool{}
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, n := range e.Nodes() {
+ drawn[n.ID] = true
+ }
+ }
+ kept := map[Edge]bool{}
+ for _, e := range archEdges(v) {
+ kept[e] = true
+ }
+ // Both ends drawn somewhere on the page means the edge belongs on it:
+ // an edge across the two BANKS is impossible (archestate_test.go pins
+ // that), so anything missing here went missing across two BANDS.
+ for _, e := range wholeGraphOf(in, v).Edges {
+ if drawn[e.From] && drawn[e.To] && !kept[e] {
+ t.Errorf("edge %s -> %s is drawn by no band: both its ends are on the page and the relationship is not",
+ e.From, e.To)
+ }
+ }
+
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ // Where each node is drawn. A node may legitimately be in several
+ // bands (the deployment bucket), so this is a set per node.
+ bands := map[string]map[string]bool{}
+ for _, b := range e.Bands {
+ for _, n := range b.Nodes {
+ if bands[n.ID] == nil {
+ bands[n.ID] = map[string]bool{}
+ }
+ bands[n.ID][b.Region] = true
+ }
+ }
+ for _, b := range e.Bands {
+ for _, ed := range b.Edges {
+ if !bands[ed.From][b.Region] || !bands[ed.To][b.Region] {
+ t.Errorf("edge %s -> %s is drawn in band %q, which does not hold both its ends (%v / %v)",
+ ed.From, ed.To, b.Region, keysOf(bands[ed.From]), keysOf(bands[ed.To]))
+ }
+ }
+ }
+ }
+}
+
+// wholeGraphOf rebuilds the graph as it stands BEFORE banding — every node
+// and edge the joins produced — so a test can ask what the page ought to be
+// showing rather than only what it is.
+func wholeGraphOf(in ArchInput, v ArchView) archGraph {
+ claimed := map[string]string{}
+ for _, b := range v.Boxes {
+ if b.CloudID != "" {
+ claimed[b.CloudID] = b.Label
+ }
+ }
+ var g archGraph
+ addDNSNodes(&g, in.Inv, claimed)
+ addBoxNodes(&g, v.Boxes, in)
+ addMachineNodes(&g, in.Inv, claimed)
+ g.Nodes = dropSatellites(g.Nodes)
+ return g
+}
+
+func keysOf(m map[string]bool) []string {
+ var out []string
+ for k := range m {
+ out = append(out, k)
+ }
+ sort.Strings(out)
+ return out
+}
+
+// Banding GROUPS, it does not hide. Every node the estate draws must reach
+// at least one band — a node whose region could not be worked out belongs in
+// the region-less band, never nowhere.
+func TestEveryDrawnNodeReachesABand(t *testing.T) {
+ in := multiRegionFixture()
+ v := BuildArch(in)
+ drawn := map[string]bool{}
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, n := range e.Nodes() {
+ drawn[n.ID] = true
+ }
+ }
+ for _, n := range wholeGraphOf(in, v).Nodes {
+ if !drawn[n.ID] {
+ t.Errorf("%s (%s) is drawn by no band — banding groups the page, it must not drop from it", n.ID, n.Kind)
+ }
+ }
+}
+
+// A box states its band; everything joined to it follows. This is the rule
+// in one assertion: the address, the DNS name and the box all land together,
+// in the region the BOX is in, and they do so for every region at once.
+func TestAFlowStaysInTheBandOfItsBox(t *testing.T) {
+ v := BuildArch(multiRegionFixture())
+ for _, c := range []struct{ node, want string }{
+ {"box:carlos-tito-ie", "eu-west-1"},
+ {"ip:eip-carlos-tito-ie", "eu-west-1"},
+ {"record:rec-carlos-tito-ie", "eu-west-1"},
+ {"box:carlos-tito-svc-pdf", "eu-west-1"},
+ {"box:carlos-tito-uk", "eu-west-2"},
+ {"ip:eip-carlos-tito-uk", "eu-west-2"},
+ {"record:rec-carlos-tito-uk", "eu-west-2"},
+ {"box:carlos-tito-se", "eu-north-1"},
+ {"record:rec-carlos-tito-se", "eu-north-1"},
+ // Reaches no machine at all, in either direction. The region-less
+ // band is the honest answer, not a bug to be tidied away.
+ {"record:rec-away", noRegion},
+ {"external:203.0.113.9", noRegion},
+ } {
+ got := bandsHolding(v, c.node)
+ if len(got) != 1 || got[0] != c.want {
+ t.Errorf("%s is drawn in %v, want only %q", c.node, got, c.want)
+ }
+ }
+}
+
+// The deployment bucket is written to from every region, so it is drawn in
+// every band that writes to it. Drawing it once would leave three of four
+// boxes with an edge to a node not in their picture — pruned away, and the
+// one relationship the console knows FIRST-HAND gone from three quarters of
+// the page.
+func TestTheSharedBucketIsDrawnInEveryBandThatWritesToIt(t *testing.T) {
+ v := BuildArch(multiRegionFixture())
+ got := bandsHolding(v, "bucket:carlos-tito-platform")
+ want := []string{"eu-north-1", "eu-west-1", "eu-west-2"}
+ if strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Errorf("the deployment bucket is drawn in %v, want %v — one band per region that writes to it", got, want)
+ }
+}
+
+// Bands are alphabetical, with the region-less band last, and an estate
+// draws one band per region it actually occupies. Alphabetical
+// because this page is read for CHANGE: an order that moved when a box was
+// added would make every band look like it had moved.
+func TestBandsAreOrderedAlphabeticallyWithTheRegionlessBandLast(t *testing.T) {
+ v := BuildArch(multiRegionFixture())
+ if got, want := bandNames(v.CARLOS), []string{"eu-north-1", "eu-west-1", "eu-west-2"}; strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Errorf("CARLOS band order = %v, want %v", got, want)
+ }
+ // The name pointing outside the estate joins no CARLOS box, so it is in
+ // the OTHER bank — and there it is the last band, after the region that
+ // has one.
+ if got, want := bandNames(v.Rest), []string{"us-east-1", noRegion}; strings.Join(got, ",") != strings.Join(want, ",") {
+ t.Errorf("second-bank band order = %v, want %v — the region-less band is drawn last", got, want)
+ }
+ // The region-less band is LABELLED, never left blank: a heading with no
+ // words in it reads as a rendering fault rather than as a fact.
+ last := v.Rest.Bands[len(v.Rest.Bands)-1]
+ if last.Label == "" || last.Label == last.Region {
+ t.Errorf("the region-less band's label is %q, want words a person can read", last.Label)
+ }
+}
+
+// Only a box or a machine may decide a band. An address is IN a region and
+// is still not allowed to say so while it is attached to something, because
+// the thing it is attached to is the one that is really there — and a
+// follower promoted to an anchor stops following, which is how a bucket in
+// eu-west-1 would drag every box in the fleet into one band.
+func TestOnlyMachinesAnchorABand(t *testing.T) {
+ for _, kind := range []string{"box", "machine"} {
+ if !isAnchor(kind) {
+ t.Errorf("%s must anchor a band", kind)
+ }
+ }
+ for _, kind := range []string{"ip", "record", "external", "bucket", "zone", "apps"} {
+ if isAnchor(kind) {
+ t.Errorf("%s must not anchor a band — it follows what it is joined to", kind)
+ }
+ }
+ // And the consequence, on a graph built by hand so the failure is about
+ // the rule rather than about a fixture: an address whose OWN region
+ // disagrees with its box follows the box.
+ g := archGraph{
+ Nodes: []Node{
+ {ID: "box:a", Kind: "box"},
+ {ID: "ip:1", Kind: "ip"},
+ },
+ Edges: []Edge{{From: "ip:1", To: "box:a", Kind: "routes"}},
+ }
+ bands := bandsFrom(g, map[string]string{"box:a": "eu-west-1", "ip:1": "us-east-1"})
+ if len(bands) != 1 || bands[0].Region != "eu-west-1" {
+ t.Fatalf("bands = %v, want one eu-west-1 band holding both", bandRegions(bands))
+ }
+ if len(bands[0].Nodes) != 2 {
+ t.Errorf("the eu-west-1 band holds %d nodes, want the box and its address", len(bands[0].Nodes))
+ }
+}
+
+// An unattached address reaches no machine, so it keeps the region the
+// estate gave it rather than falling into the region-less band. Absence of a
+// join is not absence of a region.
+func TestAnUnattachedAddressKeepsItsOwnRegion(t *testing.T) {
+ g := archGraph{Nodes: []Node{{ID: "ip:loose", Kind: "ip"}}}
+ bands := bandsFrom(g, map[string]string{"ip:loose": "sa-east-1"})
+ if len(bands) != 1 || bands[0].Region != "sa-east-1" {
+ t.Fatalf("bands = %v, want one sa-east-1 band", bandRegions(bands))
+ }
+}
+
+// Every band fits the console's main column (64rem = 1024px). This is the
+// whole user-visible point of the shape: the page scrolls DOWN as an estate
+// gains regions, and never sideways.
+func TestEveryBandFitsTheMainColumn(t *testing.T) {
+ const mainColumnPx = 1024
+ for _, in := range []ArchInput{multiRegionFixture(), sharedAccountFixture(), archFixture()} {
+ v := BuildArch(in)
+ for name, b := range eachBand(v) {
+ if b.Width > mainColumnPx {
+ t.Errorf("%s is %d units wide, want <= %d — a wider band is one the operator has to drag sideways",
+ name, b.Width, mainColumnPx)
+ }
+ if b.Width <= 0 || b.Height <= 0 {
+ t.Errorf("%s has a degenerate viewBox %dx%d", name, b.Width, b.Height)
+ }
+ }
+ }
+}
+
+// Every band in an estate is the same width, so the columns line up down
+// the page. A reader scanning for boxes should not have to find the box
+// column again in each picture.
+func TestBandsInAnEstateShareOneWidth(t *testing.T) {
+ for _, in := range []ArchInput{multiRegionFixture(), sharedAccountFixture(), archFixture()} {
+ v := BuildArch(in)
+ for name, e := range map[string]ArchEstate{"CARLOS": v.CARLOS, "rest of the account": v.Rest} {
+ for _, b := range e.Bands {
+ if b.Width != e.Bands[0].Width {
+ t.Errorf("%s: band %q is %d wide but band %q is %d — the columns will not line up",
+ name, b.Region, b.Width, e.Bands[0].Region, e.Bands[0].Width)
+ }
+ }
+ }
+ }
+}
+
+// bandsHolding names every band one node is drawn in, across both estates,
+// in page order.
+func bandsHolding(v ArchView, id string) []string {
+ var out []string
+ for _, e := range []ArchEstate{v.CARLOS, v.Rest} {
+ for _, b := range e.Bands {
+ for _, n := range b.Nodes {
+ if n.ID == id {
+ out = append(out, b.Region)
+ break
+ }
+ }
+ }
+ }
+ return out
+}
+
+func bandRegions(bands []ArchBand) []string {
+ var out []string
+ for _, b := range bands {
+ out = append(out, fmt.Sprintf("%q(%d nodes)", b.Region, len(b.Nodes)))
+ }
+ return out
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 6e6401caa..fc2aacd4e 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1249,22 +1249,19 @@ type page struct {
MetricAlerts []AlertRow
// /fleet/architecture (archgraph.go): the deployment's map. Arch is nil
- // only on paths this handler never takes; the two SVGs are pre-rendered
- // in Go rather than assembled in the template because the layout has to
- // be deterministic and testable as a whole.
+ // on every other page, and the template's {{with .Arch}} is what keeps
+ // this page's blocks off them.
//
- // Two of them because the page is two pictures: ArchSVG is the CARLOS
- // deployment, ArchRestSVG is whatever else the account is carrying
- // (archestate.go). Neither is ever the other's summary — both are drawn
- // in full.
+ // There is no pre-rendered SVG here: an estate is drawn as one picture
+ // per region band and the number of bands is a property of the estate,
+ // so the template ranges over Arch.CARLOS.Bands / Arch.Rest.Bands and
+ // each band renders itself.
//
// ArchBoxesUnread is why the box list is empty: an empty Boxes with a
// failed fleet/host read behind it must say "could not be read", never
// "no box has reported". The distinction cannot be made from Boxes
// alone — both cases are len 0 — so the handler carries it here.
Arch *ArchView
- ArchSVG template.HTML
- ArchRestSVG template.HTML
ArchBoxesUnread bool
// /fleet/economics: the operator unit-economics dashboard (Task 13),
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html
index e247e4272..1ae423930 100644
--- a/internal/console/templates/architecture.html
+++ b/internal/console/templates/architecture.html
@@ -58,14 +58,17 @@
NOTHING IS FILTERED. Every node and every resource on the old single
page is still here, under one of the two headings — the split groups,
it does not hide (archestate.go). */}}
-{{/* Said out loud because nothing else says it. Each drawing is as wide as
- its estate is deep, and on a narrow window the right-hand end — the
- boxes, the apps, the bucket — is off-screen with no scrollbar visible
- until you try. */}}
-<p class="small archscroll">The diagrams scroll sideways: DNS on the left, then the addresses, the boxes, their apps, and the deployment bucket.</p>
-
<h2>CARLOS</h2>
-<div class="archwrap">{{$.ArchSVG}}</div>
+{{/* One picture per region, stacked down the page. Region is the
+ STRUCTURE here, not a word on a card: a box is the only thing on this
+ diagram that is really in a region, and everything else is drawn in the
+ band of the box it reaches (archregion.go). That is also what keeps
+ each picture inside the 64rem column — a band is at most four nodes
+ wide — so this page scrolls down and never across. */}}
+{{range .CARLOS.Bands}}
+<h4 class="archband">{{.Label}}</h4>
+<div class="archwrap">{{.SVG}}</div>
+{{end}}
<h3>Boxes</h3>
{{range .Boxes}}
@@ -124,7 +127,16 @@
<p class="small">Everything else the account is carrying. The console has no
first-hand knowledge of these — it can see them, and it cannot trace a path
to any of them from a box it runs or the bucket it deploys from.</p>
-<div class="archwrap">{{$.ArchRestSVG}}</div>
+{{/* One picture per region, stacked down the page. Region is the
+ STRUCTURE here, not a word on a card: a box is the only thing on this
+ diagram that is really in a region, and everything else is drawn in the
+ band of the box it reaches (archregion.go). That is also what keeps
+ each picture inside the 64rem column — a band is at most four nodes
+ wide — so this page scrolls down and never across. */}}
+{{range .Rest.Bands}}
+<h4 class="archband">{{.Label}}</h4>
+<div class="archwrap">{{.SVG}}</div>
+{{end}}
{{range .Rest.Groups}}
<h3>{{.Title}}</h3>
{{template "archtable" .}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 3fe07f78a..6f872f1b8 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -413,21 +413,23 @@
with no second palette to keep in sync, and the SVG scrolls inside its
own wrapper rather than making the page scroll sideways — the drawing
has a minimum readable width the viewport may not have. */
- /* Sized to the VIEWPORT, not to main's 64rem column. The cap did not move
- with the window, so a full estate — 1918 units — showed the DNS and the
- IPs and hid every box, every app and the deployment bucket: 47% of the
- picture on every desktop and 77% on mobile, always the same 47%. A page
- whose subject is how a request reaches a process was hiding the process.
- Bounded by the viewport rather than set to 100vw, deliberately: 100vw
- includes the scrollbar on several browsers and would give the whole PAGE
- a sideways scroll to fix a diagram that already had its own. The 1.5rem
- gutter is kept on both sides at every width. */
- .archwrap {
- overflow-x: auto;
- margin: 1rem 0 2rem calc(50% - min(61.875rem, 50vw - 1.5rem));
- width: min(123.75rem, calc(100vw - 3rem));
- }
- .archscroll { margin: -1.5rem 0 2rem; }
+ /* Sized to main's 64rem column, and no wider: a band is at most four
+ nodes across (archLayer), 1018 units, which fits inside it. The wrapper
+ used to be sized to the viewport instead, because the single-picture
+ page drew an estate 1918 units wide and the column showed the DNS and
+ the IPs and hid every box; one picture per region is what made that
+ unnecessary, and overflow-x only has work to do on a window narrower
+ than the column. */
+ .archwrap { overflow-x: auto; margin: 0.2rem 0 1.6rem; }
+ /* The band heading. A region code is a machine string an operator reads
+ dozens of times a day, so it is set in the mono face and quietly — the
+ picture under it is the content, and a loud heading every 400px down a
+ six-region page reads as six separate documents. */
+ h4.archband {
+ border-top: 1px solid var(--rule); color: var(--muted);
+ font: 600 0.8rem var(--mono); letter-spacing: 0.02em;
+ margin: 1.5rem 0 0; padding-top: 0.5rem;
+ }
/* No width rule: the SVG carries its own width/height (RenderArchSVG), so
it renders 1:1 and .archwrap scrolls when the viewport is narrower.
Sizing it to the container instead would scale a 1918-unit-wide drawing