carlos / platform Public

/fleet/architecture: show the boxes, keep the names, stop drawing a path

that does not exist

Discussion #9, from the first time anyone opened this page in a browser
over a flagship-shaped estate. Four things were wrong and the smaller two
made the bigger two harder to see.

**The box layer was off-screen at every width.** The SVG is 1918 units
wide and .archwrap was capped by main's 64rem column, which does not move
with the window — so 47% of the picture was hidden on every desktop and
77% on mobile, and it was the same 47% every time: columns 5, 6 and 7,
which hold every box, every app and the deployment bucket. A page whose
subject is how a request reaches a process was showing the DNS and the
IPs and hiding the process. .archwrap is now sized to the VIEWPORT,
keeping a 1.5rem gutter, which on a 1920 screen shows essentially all of
it. Bounded by min() 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. It still
scrolls when the viewport is narrower, and the page now says so under
the drawing — horizontal scroll existed before and nothing announced it.

**Every flagship box rendered as `carlos-flagship-…`.** alarmRoom
reserved len(Alarms)*14+5 px of the title line, leaving 133px, which at
13px and the 0.58 glyph ratio is 17 characters. `carlos-flagship-ie` is
18. Each of the four truncated exactly one character before the
character that tells it apart from the other three, and this is the
production state — pinfra wired two alarms onto all four boxes on
2026-08-28. #322 fixed text overflowing its box and created this.

The alarms did not need that space. A node is 64 tall with baselines at
+24 and +44; a 5px mark centred at +56 clears the subtitle's descenders
and sits inside the rounded corner. So the marks moved to the bottom
right and BOTH lines of text got the node's full width back — the title
without putting the overflow back, and the subtitle checked too, so the
fix cannot have been paid for out of the line below.

**The diagram drew a path that does not exist.** A layer-skipping edge
turned at its midpoint, which for such an edge is inside a skipped
COLUMN, so the line ran under a node and out the other side. Nodes paint
after edges, so the flagship's top row read as one continuous chain
through `10 inbound-smtp…` — an MX target that leaves the estate and has
no outgoing edge at all. Two of those hops were fiction and a reader had
no way to know. Skipping edges now route through a lane below every node,
with both vertical runs in the middle of an inter-layer gap. This is the
same failure archgraph.go records as the reason alarms stopped being
nodes; it was fixed for the alarm column and not for edges.

**Nothing above the fold said an alarm was firing.** The marks ride
their nodes, which is right, but that put them off-screen per the first
item, and the Alarms table is ~4,300px down. ArchView now carries a
Firing summary naming each alarm and the node it watches (an alarm name
alone sends a reader to four near-identical box names), and the page
renders it as a banner beside the inventory line.

Two smaller ones from the same report: .archbox was 44rem against 64rem
tables, so the column edge stepped in and out down the page; and
"Refresh inventory", the page's only control, sat below every resource
table, thousands of pixels from the line telling you the inventory was
stale. It is now at the top, and deliberately outside {{with .Arch}} —
a page that could not read an inventory is exactly the page whose
refresh button has to still be there.

Tests: the flagship names and their subtitles survive two alarms; the
marks clear the subtitle baseline and stay inside the rect; a skipping
edge routes around the column it skips (with the #9 fixture: an ip -> box
edge with an external node between them on the same row); firing alarms
summarise with the node they watch. TestNoNodeSitsOnAnEdgePath now
models BOTH routings — it computed the three-segment path for every
edge, which after this change would have been checking geometry the
renderer does not emit, and passing.

Also fixed while here: RenderArchSVG's doc comment was in the file twice,
and both copies were attached to the const block rather than to the
function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5e1fdcf6f2ce6acacff9c521f5f8fbc0218dd635 parent f975b47
7 files changed, +328 −58
  • internal/console/archfits_test.go +92 −0
  • internal/console/archgraph.go +29 −0
  • internal/console/archgraph_test.go +6 −1
  • internal/console/archlayout.go +73 −41
  • internal/console/archlegibility_test.go +82 −10
  • internal/console/templates/architecture.html +27 −4
  • internal/console/templates/layout.html +19 −2

CI passed — 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}} &middot; 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; }