Merge branch 'arch-page-fits' into main
7 files changed,
+328
−58
internal/console/archfits_test.go+92 −0internal/console/archgraph.go+29 −0internal/console/archgraph_test.go+6 −1internal/console/archlayout.go+73 −41internal/console/archlegibility_test.go+82 −10internal/console/templates/architecture.html+27 −4internal/console/templates/layout.html+19 −2
CI cancelled — superseded by a newer push — run details
diff --git a/internal/console/archfits_test.go b/internal/console/archfits_test.go| new file mode 100644 |
| index 000000000..db1a5354b |
| --- /dev/null |
| +++ b/internal/console/archfits_test.go |
| @@ -0,0 +1,92 @@ |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// The three legibility failures #9 caught in a browser that the render tests |
| +// could not see, because each of them is a fact about pixels rather than |
| +// about strings. |
| + |
| +// TestFlagshipBoxNamesSurviveTheirAlarms is the truncation, pinned with the |
| +// exact strings it was caught on. |
| +// |
| +// The four flagship boxes are `carlos-flagship-au/ie/uk/us` — 18 characters, |
| +// differing only in the last two. #322 stopped text overflowing its box by |
| +// making the title stop before the alarm marks, which reserved |
| +// len(Alarms)*14+5 pixels of the title line; with the two alarms every box on |
| +// the flagship has carried since 2026-08-28 that leaves 133px, and 133px at |
| +// 13px and the 0.58 glyph ratio is 17 characters. Every box truncated exactly |
| +// one character before the character that tells it apart from the other |
| +// three, which is the worst available place to run out of room. |
| +func TestFlagshipBoxNamesSurviveTheirAlarms(t *testing.T) { |
| + two := []NodeAlarm{ |
| + {Name: "carlos-flagship-ie-mem-low", State: "OK"}, |
| + {Name: "carlos-flagship-ie-reachability", State: "OK"}, |
| + } |
| + v := ArchView{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"} { |
| + if !strings.Contains(out, ">"+want+"<") { |
| + t.Errorf("box name %q was truncated — the region is the character that tells these boxes apart:\n%s", want, out) |
| + } |
| + } |
| + // The subtitle keeps the full width too: moving the marks down must not |
| + // pay for the title out of the line below it. |
| + for _, want := range []string{"t4g.medium running", "t4g.small running"} { |
| + if !strings.Contains(out, ">"+want+"<") { |
| + t.Errorf("subtitle %q was truncated:\n%s", want, out) |
| + } |
| + } |
| +} |
| + |
| +// 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) |
| + } |
| +} |
| + |
| +// TestFiringAlarmsAreSummarisedForTheHeader: the marks ride their nodes, |
| +// which is right, but on a drawing wider than the window they are off-screen |
| +// and the Alarms table is thousands of pixels down. A page that knows |
| +// something is firing has to be able to say so above the fold. |
| +func TestFiringAlarmsAreSummarisedForTheHeader(t *testing.T) { |
| + nodes := []Node{ |
| + {ID: "box:ie", Title: "carlos-flagship-ie", Alarms: []NodeAlarm{ |
| + {Name: "mem-low", State: "ALARM", Firing: true}, |
| + {Name: "reachability", State: "OK"}, |
| + }}, |
| + {ID: "box:au", Title: "carlos-flagship-au", Alarms: []NodeAlarm{ |
| + {Name: "reachability", State: "OK"}, |
| + }}, |
| + } |
| + got := firingAlarms(nodes) |
| + if len(got) != 1 { |
| + t.Fatalf("firingAlarms = %v, want exactly the firing one", got) |
| + } |
| + // It names the alarm AND the thing it watches: "mem-low" on its own sends |
| + // a reader to four near-identical box names. |
| + if !strings.Contains(got[0], "mem-low") || !strings.Contains(got[0], "carlos-flagship-ie") { |
| + t.Errorf("summary %q must name both the alarm and the node it watches", got[0]) |
| + } |
| + if len(firingAlarms(nil)) != 0 { |
| + t.Error("no nodes must summarise to nothing, not to an empty-but-present banner") |
| + } |
| +} |
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go| index fdcd2abf9..5aafe0081 100644 |
| --- a/internal/console/archgraph.go |
| +++ b/internal/console/archgraph.go |
| @@ -134,6 +134,30 @@ type ArchView struct { |
| Account string |
| Partial []string |
| Missing string // "" | "never" | "ungranted" | "unreadable" |
| + // Firing names every alarm currently in ALARM, with the node it watches, |
| + // so the page can say so above the fold. |
| + // |
| + // The marks themselves ride their nodes, which is right — an alarm is a |
| + // property of the thing it watches — but it made a firing alarm invisible |
| + // on arrival: the marks are off to the right of a drawing wider than the |
| + // window, and the Alarms table is thousands of pixels down (discussion |
| + // #9). A page that knows something is on fire has to say so where the |
| + // reader already is. |
| + Firing []string |
| +} |
| + |
| +// firingAlarms collects the ALARM-state marks across the drawn nodes, in the |
| +// nodes' own order so the summary is as deterministic as the diagram. |
| +func firingAlarms(nodes []Node) []string { |
| + var out []string |
| + for _, n := range nodes { |
| + for _, a := range n.Alarms { |
| + if a.Firing { |
| + out = append(out, a.Name+" ("+n.Title+")") |
| + } |
| + } |
| + } |
| + return out |
| } |
| // BuildArch joins the estate to the platform's own model of itself. It reads |
| @@ -178,6 +202,11 @@ func BuildArch(in ArchInput) ArchView { |
| // left the shipped ArchView carrying edges to nodes it does not draw. |
| layout(&v) |
| pruneDanglingEdges(&v) |
| + // After layout, for the same reason the pruning is: layout is what makes |
| + // the node set final, and a summary that named an alarm on a node the |
| + // diagram does not draw would send a reader looking for a mark that is |
| + // not there. |
| + v.Firing = firingAlarms(v.Nodes) |
| return v |
| } |
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go| index 56a90122a..fcc45852c 100644 |
| --- a/internal/console/archgraph_test.go |
| +++ b/internal/console/archgraph_test.go |
| @@ -405,7 +405,12 @@ func TestEveryEdgeReachesThePage(t *testing.T) { |
| }) |
| v := BuildArch(in) |
| out := string(RenderArchSVG(v)) |
| - if got := strings.Count(out, `<path class="archedge"`); got != len(v.Edges) { |
| + // Counted on the class PREFIX, not on `class="archedge"` exactly: a |
| + // layer-skipping edge is drawn with `archedge archedge-skip` (it routes |
| + // around the columns it skips rather than under the nodes in them), and |
| + // an exact match silently stopped counting those — which is this test's |
| + // own failure mode, an edge that reaches the page and is not counted. |
| + if got := strings.Count(out, `<path class="archedge`); got != len(v.Edges) { |
| t.Errorf("page draws %d edges, view carries %d", got, len(v.Edges)) |
| } |
| } |
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go| index 1bc88f76c..0e6260602 100644 |
| --- a/internal/console/archlayout.go |
| +++ b/internal/console/archlayout.go |
| @@ -171,36 +171,6 @@ func layout(v *ArchView) { |
| v.Height = padding*2 + maxRows*nodeH + (maxRows-1)*gapY |
| } |
| -// RenderArchSVG emits the diagram. Every string that came from a hostname, |
| -// a tag or a label goes through template.HTMLEscapeString — these are |
| -// operator-supplied values from a registry and a cloud account, and the one |
| -// thing this page must never do is execute one of them. |
| -// |
| -// width/height are emitted ALONGSIDE the viewBox, and they are what make the |
| -// picture readable. An <svg> carrying only a viewBox has no intrinsic size, |
| -// so a browser sizes it at 100% of its container and scales the drawing to |
| -// fit: a full estate is 1918 units wide and the console's main column is |
| -// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever |
| -// appears, because the SVG never overflows anything. With an intrinsic size |
| -// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the |
| -// viewport is narrower — legible everywhere, at the cost of a sideways |
| -// scroll on the widest graphs, which is the right trade for a diagram whose |
| -// labels are the content. |
| -// RenderArchSVG emits the diagram. Every string that came from a hostname, |
| -// a tag or a label goes through template.HTMLEscapeString — these are |
| -// operator-supplied values from a registry and a cloud account, and the one |
| -// thing this page must never do is execute one of them. |
| -// |
| -// width/height are emitted ALONGSIDE the viewBox, and they are what make the |
| -// picture readable. An <svg> carrying only a viewBox has no intrinsic size, |
| -// so a browser sizes it at 100% of its container and scales the drawing to |
| -// fit: a full estate is 1918 units wide and the console's main column is |
| -// ~1024px, which renders 13px node titles at ~7px and no scrollbar ever |
| -// appears, because the SVG never overflows anything. With an intrinsic size |
| -// the drawing renders 1:1 and .archwrap (overflow-x: auto) scrolls when the |
| -// viewport is narrower — legible everywhere, at the cost of a sideways |
| -// scroll on the widest graphs, which is the right trade for a diagram whose |
| -// labels are the content. |
| // 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 |
| @@ -237,14 +207,24 @@ func fitText(s string, px, avail int) string { |
| return string(r[:max-1]) + "…" |
| } |
| -// alarmRoom is the width the alarm marks occupy on the title line, so a |
| -// title stops before running underneath them. |
| -func alarmRoom(n Node) int { |
| - if len(n.Alarms) == 0 { |
| - return 0 |
| - } |
| - return len(n.Alarms)*alarmStep + alarmR |
| -} |
| +// alarmRow is the y centre of the alarm marks: the node's BOTTOM edge, below |
| +// the subtitle's baseline, not the title's line. |
| +// |
| +// They used to share the title's line, and the title had to stop before |
| +// running underneath them — `alarmRoom` reserved len(Alarms)*14 + 5 pixels of |
| +// it. With two alarms that left 133px of a 190px node, which at 13px and the |
| +// 0.58 glyph ratio is 17 characters, and every flagship box is named |
| +// `carlos-flagship-XX`: 18. Each one truncated exactly one character before |
| +// the character that tells it apart from the other three (discussion #9), |
| +// which is the worst possible 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. |
| +func alarmRow(n Node) int { return n.Y + n.H - 8 } |
| // nodeTitleText is the node in full for the hover title: what the boxes |
| // say, plus every alarm riding it, none of it truncated. |
| @@ -259,6 +239,29 @@ func nodeTitleText(n Node) string { |
| return out |
| } |
| +// 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, which at ~1024px renders 13px node |
| +// titles at ~7px, with no scrollbar ever appearing because the SVG never |
| +// overflows anything. With an intrinsic size the drawing renders 1:1 and |
| +// .archwrap scrolls instead — 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. |
| +// |
| +// What changed in #9: .archwrap is now sized to the VIEWPORT rather than the |
| +// 64rem content column (layout.html). The cap did not move with the window, |
| +// so 1024px of a 1918-unit drawing 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. It still scrolls |
| +// when even the viewport is narrower, and the page now says so under the |
| +// drawing rather than leaving a reader to guess the diagram continues. |
| func RenderArchSVG(v ArchView) 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">`, |
| @@ -280,6 +283,35 @@ func RenderArchSVG(v ArchView) template.HTML { |
| } |
| 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 { |
| + // A LAYER-SKIPPING edge, routed around the columns it skips |
| + // rather than through them. |
| + // |
| + // The plain three-segment route below turns at the midpoint, |
| + // which for a skipping edge is inside an intervening node |
| + // COLUMN. Nodes paint after edges, so the line ran under a node |
| + // and came out the other side, and the picture read as a |
| + // continuous chain through a hop that does not exist: on the |
| + // flagship, `13.55.0.44 -> carlos-flagship-au` passed straight |
| + // under `10 inbound-smtp.eu-west-1…`, whose MX target is a dead |
| + // end with no outgoing edge at all, and a reader had no way to |
| + // know (discussion #9). |
| + // |
| + // This is the same failure archgraph.go records as the reason |
| + // alarms stopped being nodes — an edge running under a node |
| + // makes the picture read as a request flowing through it. It was |
| + // 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. |
| + 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) |
| + continue |
| + } |
| mid := (x1 + x2) / 2 |
| fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2) |
| } |
| @@ -291,12 +323,12 @@ func RenderArchSVG(v ArchView) template.HTML { |
| fmt.Fprintf(&b, `<title>%s</title>`, template.HTMLEscapeString(nodeTitleText(n))) |
| fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H) |
| fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`, |
| - n.X+textPad, n.Y+24, template.HTMLEscapeString(fitText(n.Title, titlePx, n.W-2*textPad-alarmRoom(n)))) |
| + n.X+textPad, n.Y+24, template.HTMLEscapeString(fitText(n.Title, titlePx, n.W-2*textPad))) |
| if n.Subtitle != "" { |
| 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))) |
| } |
| - // One mark per alarm, top-right, filling leftwards. A firing alarm |
| + // 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. |
| for i, a := range n.Alarms { |
| @@ -306,7 +338,7 @@ func RenderArchSVG(v ArchView) template.HTML { |
| } |
| cx := n.X + n.W - alarmInset - i*alarmStep |
| fmt.Fprintf(&b, `<circle class="%s" cx="%d" cy="%d" r="%d"><title>%s</title></circle>`, |
| - cls, cx, n.Y+alarmInset, alarmR, |
| + cls, cx, alarmRow(n), alarmR, |
| template.HTMLEscapeString(a.Name+" "+a.State)) |
| } |
| b.WriteString(`</g>`) |
diff --git a/internal/console/archlegibility_test.go b/internal/console/archlegibility_test.go| index 039536fa9..8f9a65218 100644 |
| --- a/internal/console/archlegibility_test.go |
| +++ b/internal/console/archlegibility_test.go |
| @@ -13,33 +13,105 @@ import ( |
| // line hides it and the picture reads as a connection that does not |
| // exist — an IP appearing to reach its box THROUGH an alarm. |
| // |
| -// The edge is drawn as three segments (out, across, in), so the check is |
| -// the two horizontal runs at the endpoints' own y values plus the vertical |
| -// hop between them. |
| +// An adjacent-layer edge is drawn as three segments (out, across, in), so |
| +// the check is the two horizontal runs at the endpoints' own y values plus |
| +// the vertical hop between them. A LAYER-SKIPPING edge is drawn as five, |
| +// around a lane below every node — see edgeSegments, which mirrors |
| +// RenderArchSVG exactly. Mirroring matters: a test that modelled the |
| +// three-segment path for both would be checking geometry the renderer does |
| +// not emit, and would pass while the picture lied. |
| func TestNoNodeSitsOnAnEdgePath(t *testing.T) { |
| v := BuildArch(archFixture()) |
| + assertNoNodeOnAnEdge(t, v) |
| +} |
| + |
| +// 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. |
| +// |
| +// 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 |
| +// and out the other side. Nodes paint after edges, so the top row read as one |
| +// 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. |
| +func TestLayerSkippingEdgeRoutesAroundTheColumnsItSkips(t *testing.T) { |
| + v := ArchView{ |
| + 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"}}, |
| + } |
| + layout(&v) |
| + assertNoNodeOnAnEdge(t, v) |
| + |
| + // And it is the skip routing that is drawn, not the plain one. |
| + out := string(RenderArchSVG(v)) |
| + if !strings.Contains(out, "archedge-skip") { |
| + t.Errorf("a layer-skipping edge must be routed around, not straight through:\n%s", out) |
| + } |
| +} |
| + |
| +// assertNoNodeOnAnEdge checks every drawn edge against every node that is not |
| +// one of its endpoints. |
| +func assertNoNodeOnAnEdge(t *testing.T, v ArchView) { |
| + t.Helper() |
| pos := map[string]Node{} |
| for _, n := range v.Nodes { |
| pos[n.ID] = n |
| } |
| for _, e := range v.Edges { |
| from, to := pos[e.From], pos[e.To] |
| - x1, y1 := from.X+from.W, from.Y+from.H/2 |
| - x2, y2 := to.X, to.Y+to.H/2 |
| - mid := (x1 + x2) / 2 |
| + hs, vs := edgeSegments(v, from, to) |
| for _, n := range v.Nodes { |
| if n.ID == e.From || n.ID == e.To { |
| continue |
| } |
| - if segmentHitsNode(x1, mid, y1, n) || segmentHitsNode(mid, x2, y2, n) || |
| - verticalHitsNode(mid, y1, y2, n) { |
| - t.Errorf("edge %s -> %s passes through node %s (%d,%d %dx%d)", |
| - e.From, e.To, n.ID, n.X, n.Y, n.W, n.H) |
| + for _, h := range hs { |
| + if segmentHitsNode(h.a, h.b, h.at, n) { |
| + t.Errorf("edge %s -> %s runs horizontally through node %s (%d,%d %dx%d)", |
| + e.From, e.To, n.ID, n.X, n.Y, n.W, n.H) |
| + } |
| + } |
| + for _, vv := range vs { |
| + if verticalHitsNode(vv.at, vv.a, vv.b, n) { |
| + t.Errorf("edge %s -> %s runs vertically through node %s (%d,%d %dx%d)", |
| + e.From, e.To, n.ID, n.X, n.Y, n.W, n.H) |
| + } |
| } |
| } |
| } |
| } |
| +// seg is one axis-aligned run: `at` is the fixed coordinate, a..b the span. |
| +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 ArchView, 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 { |
| + lane := v.Height - padding/2 |
| + return []seg{ |
| + {at: y1, a: x1, b: x1 + gapX/2}, |
| + {at: lane, a: x1 + gapX/2, b: x2 - gapX/2}, |
| + {at: y2, a: x2 - gapX/2, b: x2}, |
| + }, []seg{ |
| + {at: x1 + gapX/2, a: y1, b: lane}, |
| + {at: x2 - gapX/2, a: lane, b: y2}, |
| + } |
| + } |
| + mid := (x1 + x2) / 2 |
| + return []seg{{at: y1, a: x1, b: mid}, {at: y2, a: mid, b: x2}}, |
| + []seg{{at: mid, a: y1, b: y2}} |
| +} |
| + |
| func segmentHitsNode(xa, xb, y int, n Node) bool { |
| if xa > xb { |
| xa, xb = xb, xa |
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html| index c7b0fa52f..868d5163b 100644 |
| --- a/internal/console/templates/architecture.html |
| +++ b/internal/console/templates/architecture.html |
| @@ -12,6 +12,16 @@ |
| <h1>Architecture</h1> |
| {{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{/* The page's only control, at the top with the inventory timestamp it acts |
| + on. It used to sit below every resource table, several thousand pixels |
| + from the line telling you the inventory was stale (discussion #9). |
| + Outside the {{with .Arch}} below on purpose: a page that could not read |
| + an inventory is exactly the page whose refresh button has to still be |
| + there. */}} |
| +<form method="post" action="/fleet/architecture/refresh"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit">Refresh inventory</button> |
| +</form> |
| {{with .Arch}} |
| {{/* The three missing states are named apart, never collapsed into one |
| "nothing here": never-taken, ungranted and unreadable are three |
| @@ -26,7 +36,24 @@ |
| <p class="small">Estate {{.Account}} · inventoried {{fmtTime .TakenAt}}</p> |
| {{end}} |
| +{{/* A firing alarm, said where the reader already is. The marks ride the |
| + nodes they watch, which is right, but that put them off to the right of |
| + a drawing wider than the window and left the Alarms table thousands of |
| + pixels below — so the page could know something was on fire and show |
| + nothing above the fold. */}} |
| +{{if .Firing}} |
| +<p class="banner error">{{icon "triangle-alert"}} |
| + {{len .Firing}} alarm{{if ne (len .Firing) 1}}s{{end}} firing: |
| + {{range $i, $a := .Firing}}{{if $i}}, {{end}}{{$a}}{{end}} |
| +</p> |
| +{{end}} |
| + |
| <div class="archwrap">{{$.ArchSVG}}</div> |
| +{{/* Said out loud because nothing else says it. The drawing is as wide as |
| + the 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 diagram scrolls sideways: DNS on the left, then the addresses, the boxes, their apps, and the deployment bucket.</p> |
| <h2>Boxes</h2> |
| {{range .Boxes}} |
| @@ -89,8 +116,4 @@ |
| {{end}} |
| {{end}} |
| -<form method="post" action="/fleet/architecture/refresh"> |
| - <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| - <button type="submit">Refresh inventory</button> |
| -</form> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index dd4ca0b4e..3fe07f78a 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -413,7 +413,21 @@ |
| with no second palette to keep in sync, and the SVG scrolls inside its |
| own wrapper rather than making the page scroll sideways — the drawing |
| has a minimum readable width the viewport may not have. */ |
| - .archwrap { overflow-x: auto; margin: 1rem 0 2rem; } |
| + /* 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; } |
| /* 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 |
| @@ -434,9 +448,12 @@ |
| rather than only in the words of its hover title. */ |
| .archsvg .archalarm { fill: var(--good); } |
| .archsvg .archalarm-firing { fill: var(--bad); } |
| + /* 64rem, not 44rem: the resource tables below run the full width of the |
| + column, and a 44rem card left the column edge stepping in and out down |
| + the page (discussion #9). */ |
| .archbox { |
| border: 1px solid var(--rule); border-radius: 0.6rem; |
| - padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 44rem; |
| + padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 64rem; |
| } |
| .archbox h3 { margin: 0 0 0.3rem; } |
| .archbox p { margin: 0 0 0.2rem; } |