console: the architecture page is one picture per region, stacked (#390)
The diagram was 1918 units wide in a 1024px column, so reading it meant dragging it sideways. It was wide for one reason: seven fixed columns, whatever the estate held. And region — the thing that now tells nine boxes apart — appeared nowhere on it. Both are the same fix. A region is a physical fact about a MACHINE and about almost nothing else here: DNS is global (every record and zone comes back with no region at all), an address is only interesting for the box it is attached to, and a bucket is written to from everywhere. So a box or an unreported machine ANCHORS a band by stating its own region, and everything else FOLLOWS the anchors it is joined to (archregion.go). That buys a property rather than a preference: a follower takes the band of whatever it is joined to, so no edge can span two bands, and each band is a self-contained diagram that lays out and draws on its own. The page grows downwards as an estate gains regions instead of sideways as it gains nodes. Two columns were folded to make a band fit. What a box runs is a property of the box, so it rides the card (Node.Extra) rather than standing in a column where six of nine cards said "no apps"; and an address outside the estate shares the address column with one we allocated, because both answer the one question a reader has at that point in the line. Zones stop being drawn: a global node is either in no band, cutting every record loose, or in all of them, and the DNS table already lists every zone in full. Four columns at 190 with 70 between them is 1018 — inside the column, by construction, with a test that says so. A follower joined to two regions is drawn in both, exactly as a node straddling the two estate banks is. The deployment bucket is the everyday case: nine boxes in six regions write to it, and drawing it once would prune five of those edges away. A node that reaches no machine at all lands in a region-less band drawn last — a name pointing outside the estate genuinely has no region, and saying so is the answer. Measured on the live inventory: CARLOS goes from one 1918x832 picture to six bands of 1018x146-518. Nothing is filtered; every node and every table row is still on the page. Claude-Session: https://claude.ai/code/session_01R9rcHiNJZfbHsUar7J7J4A
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
14 files changed,
+1005
−203
internal/console/archestate.go+11 −7internal/console/archestate_test.go+16 −12internal/console/archgraph.go+178 −37internal/console/archgraph_test.go+44 −19internal/console/archlayout.go+91 −69internal/console/archlayout_test.go+64 −33internal/console/archlegibility_test.go+18 −6internal/console/archpage.go+6 −6internal/console/archpage_test.go+19 −2internal/console/archregion.go+179 −0internal/console/archregion_test.go+343 −0internal/console/server.go+6 −9internal/console/templates/architecture.html+20 −2internal/console/templates/layout.html+10 −1
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/archgraph.go b/internal/console/archgraph.go| index d8b891848..6a77e12fa 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 |
| @@ -189,7 +243,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) |
| @@ -197,34 +251,119 @@ 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) |
| } |
| 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 |
| @@ -234,7 +373,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 |
| @@ -471,7 +610,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 |
| } |
| @@ -517,7 +656,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") |
| @@ -614,22 +753,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"}) |
| } |
| @@ -657,7 +798,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 |
| } |
| @@ -681,7 +822,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 1d93f942e..7ea10530f 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) |
| @@ -455,7 +480,7 @@ 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) |
| 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))) |
| } |
| @@ -562,10 +587,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 9da06f8cb..51c003a7b 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. |
| +// 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. |
| -// 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. |
| +// 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 |
| @@ -253,13 +261,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) |
| @@ -296,6 +309,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, top-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 84be2a0c9..8e78aa33d 100644 |
| --- a/internal/console/archlegibility_test.go |
| +++ b/internal/console/archlegibility_test.go |
| @@ -19,7 +19,7 @@ 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())) { |
| pos := map[string]Node{} |
| for _, n := range est.Nodes { |
| pos[n.ID] = n |
| @@ -112,13 +112,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 ad63e2f14..5cdf15b92 100644 |
| --- a/internal/console/templates/architecture.html |
| +++ b/internal/console/templates/architecture.html |
| @@ -37,7 +37,16 @@ |
| page is still here, under one of the two headings — the split groups, |
| it does not hide (archestate.go). */}} |
| <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}} |
| @@ -96,7 +105,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 dd4ca0b4e..c38149b30 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -413,7 +413,16 @@ |
| 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. */ |
| - .archwrap { overflow-x: auto; margin: 1rem 0 2rem; } |
| + .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 |