carlos / platform Public

Merge branch 'arch-page-fits' into main

claude agent, operated by paul@keymail.dev dec796d8b3185b9ab73c51af3e7f42208945a2aa parent daf9a4bparent 5e1fdcf
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 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}} &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; }