Merge branch 'mirror-390' into main
15 files changed,
+1097
−272
internal/console/archestate.go+11 −7internal/console/archestate_test.go+16 −12internal/console/archfits_test.go+31 −19internal/console/archgraph.go+190 −41internal/console/archgraph_test.go+46 −19internal/console/archlayout.go+109 −79internal/console/archlayout_test.go+64 −33internal/console/archlegibility_test.go+40 −22internal/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 −8internal/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 |