carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1// Layout and SVG emission for /fleet/architecture. Deterministic by
2// construction: layer by kind, order within a layer by one barycentre sweep
3// each way, then a stable sort by node id. The same fleet must produce a
4// byte-identical picture on every refresh — a diagram that reshuffles
5// cannot be read for change.
6//
7// No JavaScript and no layout library: the console vendors its icons and
8// loads nothing from a CDN, and a server-computed layout is the only way to
9// keep that promise while still drawing a graph.
10package console
11
12import (
13 "fmt"
14 "html/template"
15 "sort"
16 "strings"
17)
18
19// Layer assignment by node kind — the story one band tells, left to right,
20// in FOUR columns and no more. Four is not a taste: the console's main
21// column is 64rem = 1024px, and 4*190 + 3*70 + 2*24 = 1018 is the widest
22// picture that fits inside it. A fifth column is 260 units the page does
23// not have, and the page having them is the difference between reading the
24// diagram and dragging it.
25//
26// Two foldings bought the room. "apps" is gone as a kind: what a box runs
27// is a property of the box and now rides its card (Node.Extra), where it
28// used to be a whole column in which six of nine cards said "no apps".
29// "external" shares the address column with "ip" rather than standing to
30// its right: both answer the one question a reader has at that point in the
31// line — what does this name resolve to — and the distinction between an
32// address we allocated and one outside the estate is carried by the node's
33// kind, which is drawn, rather than by its column, which is not.
34//
35// "zone" is absent because zones are not drawn at all (dropSatellites).
36// Alarms are absent for a different reason: they were briefly given a
37// column of their own, and it was the one column every address-to-box edge
38// crosses. Nodes paint after edges, so each alarm covered the line running
39// under it and the diagram read as a request reaching its box THROUGH an
40// alarm. An alarm is not a hop; it is a property of the thing it watches,
41// so it rides that node as a mark (Node.Alarms) and no column can put it in
42// the way of a line.
43var archLayer = map[string]int{
44 "record": 0, "ip": 1, "external": 1, "router": 1,
45 "box": 2, "machine": 2, "bucket": 3,
46}
47
48const (
49 nodeW = 190
50 // nodeH is the height of a node carrying a title and a subtitle; each
51 // Extra line adds nodeLineH. Height is per NODE rather than one constant
52 // for the whole picture because the box card needs four lines and the
53 // records outnumber the boxes twenty to one — giving every node the
54 // tallest node's height would add thousands of units of whitespace to
55 // the estate with the most nodes and least to say about each.
56 nodeH = 64
57 nodeLineH = 17
58 // gapX is 70 rather than the 90 the single-picture layout used, and
59 // that 20 units per gap is what brings four columns inside 1024. It is
60 // the gap, not the node, that gave way: the node width is what a
61 // hostname fits in.
62 gapX = 70
63 gapY = 26
64 padding = 24
65 satelliteKinds = "identity firewall network disk certificate other zone"
66)
67
68// nodeHeight is how tall one node is drawn. One place, because layout
69// positions by it and RenderArchSVG writes text down it, and the two
70// disagreeing would put a line of text outside its own rect.
71func nodeHeight(n Node) int { return nodeH + len(n.Extra)*nodeLineH }
72
73// layout places one BAND. The satellite kinds are already gone by the time
74// it runs (dropSatellites, called before banding so a global zone cannot
75// carry a region along its edges), so every node it is handed is a node it
76// draws.
77func layout(v *ArchBand) {
78 byLayer := map[int][]int{}
79 for i := range v.Nodes {
80 l := archLayer[v.Nodes[i].Kind]
81 byLayer[l] = append(byLayer[l], i)
82 }
83 // Stable order first, so the barycentre sweeps start from a fixed
84 // arrangement and cannot depend on map iteration order anywhere.
85 for l := range byLayer {
86 idx := byLayer[l]
87 sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID })
88 }
89 // One sweep forward and one back, ordering each layer by the mean row of
90 // its neighbours in the adjacent layer — the standard crossing
91 // reduction, and enough for a graph this shape. Ties break on node id so
92 // the result cannot depend on map or slice iteration order anywhere.
93 row := map[string]int{}
94 for _, idx := range byLayer {
95 for r, i := range idx {
96 row[v.Nodes[i].ID] = r
97 }
98 }
99 // An edge whose other end this layout does not draw carries no row to
100 // average, and BuildArch prunes such edges only once layout has run.
101 // Counting them would place a node by the row of something that is not
102 // on the page — deterministic, but wrong.
103 drawn := make(map[string]bool, len(v.Nodes))
104 for _, n := range v.Nodes {
105 drawn[n.ID] = true
106 }
107 neighbours := func(id string, forward bool) []string {
108 var out []string
109 for _, e := range v.Edges {
110 if forward && e.To == id && drawn[e.From] {
111 out = append(out, e.From)
112 }
113 if !forward && e.From == id && drawn[e.To] {
114 out = append(out, e.To)
115 }
116 }
117 return out
118 }
119 sweep := func(forward bool) {
120 layers := make([]int, 0, len(byLayer))
121 for l := range byLayer {
122 layers = append(layers, l)
123 }
124 sort.Ints(layers)
125 if !forward {
126 for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 {
127 layers[i], layers[j] = layers[j], layers[i]
128 }
129 }
130 for _, l := range layers {
131 idx := byLayer[l]
132 bary := map[string]float64{}
133 for _, i := range idx {
134 id := v.Nodes[i].ID
135 ns := neighbours(id, forward)
136 if len(ns) == 0 {
137 bary[id] = float64(row[id])
138 continue
139 }
140 sum := 0
141 for _, n := range ns {
142 sum += row[n]
143 }
144 bary[id] = float64(sum) / float64(len(ns))
145 }
146 sort.SliceStable(idx, func(a, b int) bool {
147 ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID
148 if bary[ia] != bary[ib] {
149 return bary[ia] < bary[ib]
150 }
151 return ia < ib
152 })
153 for r, i := range idx {
154 row[v.Nodes[i].ID] = r
155 }
156 }
157 }
158 sweep(true)
159 sweep(false)
160
161 // Position. Rows stack by CUMULATIVE height rather than by row index
162 // times a constant, because nodes are not all the same height any more
163 // (Node.Extra). Edge routing reads Y+H/2 off the node, so it follows
164 // this for free.
165 tallest := 0
166 for l, idx := range byLayer {
167 y := padding
168 for _, i := range idx {
169 v.Nodes[i].W = nodeW
170 v.Nodes[i].H = nodeHeight(v.Nodes[i])
171 v.Nodes[i].X = padding + l*(nodeW+gapX)
172 v.Nodes[i].Y = y
173 y += v.Nodes[i].H + gapY
174 }
175 if len(idx) > 0 && y-gapY > tallest {
176 tallest = y - gapY
177 }
178 }
179 layers := 0
180 for l := range byLayer {
181 if l+1 > layers {
182 layers = l + 1
183 }
184 }
185 // An empty band (no boxes, no inventory yet — an ungranted console on
186 // day one) still has to carry a valid, positive viewBox: it is the
187 // FIRST page an operator sees, not the busiest. Without this clamp
188 // layers==0 and tallest==0 drive Width/Height negative.
189 if layers < 1 {
190 layers = 1
191 }
192 if tallest < padding+nodeH {
193 tallest = padding + nodeH
194 }
195 v.Width = padding*2 + layers*nodeW + (layers-1)*gapX
196 v.Height = tallest + padding
197}
198
199// RenderArchSVG emits one band's diagram. Every string that came from a
200// hostname, a tag or a label goes through template.HTMLEscapeString — these
201// are operator-supplied values from a registry and a cloud account, and the
202// one thing this page must never do is execute one of them.
203//
204// width/height are emitted ALONGSIDE the viewBox, and they are what make the
205// picture readable. An <svg> carrying only a viewBox has no intrinsic size,
206// so a browser sizes it at 100% of its container and scales the drawing to
207// fit, rendering 13px node titles at whatever fraction that works out to.
208// With an intrinsic size the drawing renders 1:1. A band is at most 1018
209// units wide by construction (archLayer), which is inside the console's
210// 64rem column, so at 1:1 it neither shrinks nor overflows: the page grows
211// DOWNWARDS as an estate gains regions and never sideways.
212// The text-fitting budget. There are no font metrics in a server-rendered
213// SVG, so the width of a string is estimated: for the ui-sans stack these
214// nodes use, an average glyph runs a little over half its point size, and
215// 0.58 is the ratio that keeps the longest real strings (a five-app list,
216// a full instance-count line) inside the box without cutting short ones
217// that would have fit. It is deliberately a little pessimistic — a string
218// cut one character early costs nothing, a string one character too long
219// bleeds across the neighbouring node.
220const (
221 titlePx = 13
222 subPx = 11
223 glyphRatio = 0.58
224 textPad = 12
225 alarmR = 5
226 alarmInset = 14
227 alarmStep = 14
228)
229
230// fitText cuts s to the pixels available, appending an ellipsis when it
231// has to. The full string is never lost — RenderArchSVG puts it in the
232// node's hover title.
233func fitText(s string, px, avail int) string {
234 if avail <= 0 || s == "" {
235 return ""
236 }
237 max := int(float64(avail) / (float64(px) * glyphRatio))
238 r := []rune(s)
239 if max < 1 {
240 return "…"
241 }
242 if len(r) <= max {
243 return s
244 }
245 return string(r[:max-1]) + "…"
246}
247
248// alarmRow is the y centre of the alarm marks: near the node's BOTTOM edge,
249// below the subtitle's baseline, not on the title's line.
250//
251// They used to share the title's line, and the title had to stop before
252// running underneath them — an `alarmRoom` helper reserved
253// len(Alarms)*alarmStep + alarmR pixels of it. With the two alarms every
254// flagship box has carried since 2026-08-28 that left 133px of a 190px node,
255// which at 13px and the 0.58 glyph ratio is 17 characters, and every flagship
256// box is named `carlos-flagship-XX`: 18. Each one truncated exactly one
257// character before the character that tells it apart from the other three
258// (discussion #9), which is the worst available place to run out of room.
259//
260// The alarms did not need that space. A node is nodeH (64) tall plus
261// nodeLineH per Extra line, and its LAST baseline — the subtitle's at +44, or
262// the last Extra line's — is always 20 above the rect's bottom edge
263// (nodeHeight). A 5px mark centred 8 above that edge clears the last line's
264// descenders and sits inside the rect's rounded corner, whatever the line
265// count. So the marks moved down and EVERY line of text got the node's full
266// width back. #322 fixed text overflowing its box by taking width away from
267// the title; this gives it back without putting the overflow back.
268func alarmRow(n Node) int { return n.Y + n.H - 8 }
269
270// nodeTitleText is the node in full for the hover title: what the boxes
271// say, plus every alarm riding it, none of it truncated.
272func nodeTitleText(n Node) string {
273 out := n.Title
274 if n.Subtitle != "" {
275 out += " — " + n.Subtitle
276 }
277 for _, ex := range n.Extra {
278 if ex != "" {
279 out += " · " + ex
280 }
281 }
282 for _, a := range n.Alarms {
283 out += " · " + a.Name + " " + a.State
284 }
285 return out
286}
287
288func RenderArchSVG(v ArchBand) template.HTML {
289 var b strings.Builder
290 fmt.Fprintf(&b, `<svg class="archsvg" width="%d" height="%d" viewBox="0 0 %d %d" role="img" aria-label="Deployment architecture">`,
291 v.Width, v.Height, v.Width, v.Height)
292 pos := map[string]Node{}
293 for _, n := range v.Nodes {
294 pos[n.ID] = n
295 }
296 // Edges first so nodes paint over them.
297 for _, e := range v.Edges {
298 from, okF := pos[e.From]
299 to, okT := pos[e.To]
300 if !okF || !okT {
301 // The second net only: BuildArch prunes dangling edges after
302 // layout, so a built view never reaches here. A caller that
303 // hand-assembles an ArchView still cannot make this function
304 // dereference a node it has not been given.
305 continue
306 }
307 x1, y1 := from.X+from.W, from.Y+from.H/2
308 x2, y2 := to.X, to.Y+to.H/2
309 if archLayer[to.Kind]-archLayer[from.Kind] > 1 {
310 // A LAYER-SKIPPING edge, routed around the columns it skips
311 // rather than through them.
312 //
313 // The plain three-segment route below turns at the midpoint,
314 // which for a skipping edge is inside an intervening node
315 // COLUMN. Nodes paint after edges, so the line ran under a node
316 // and came out the other side, and the picture read as a
317 // continuous chain through a hop that does not exist: on the
318 // flagship, `13.55.0.44 -> carlos-flagship-au` passed straight
319 // under `10 inbound-smtp.eu-west-1…`, whose MX target is a dead
320 // end with no outgoing edge at all, and a reader had no way to
321 // know (discussion #9).
322 //
323 // This is the same failure archgraph.go records as the reason
324 // alarms stopped being nodes — an edge running under a node
325 // makes the picture read as a request flowing through it. It was
326 // fixed for the alarm column and not for edges.
327 //
328 // The lane is below every node, so it crosses nothing, and both
329 // vertical runs sit in the middle of an inter-layer GAP (gapX
330 // wide, nodes never enter it) rather than in a column. Skipping
331 // edges share one lane and read as a bus, which is honest: they
332 // are the edges that do not follow the left-to-right story.
333 //
334 // Folding "external" into the address column (archLayer) removed
335 // the ip -> box case #9 was caught on, but not the mechanism: a
336 // record whose value is an address a machine holds DIRECTLY
337 // (recordTarget) points at the box from the records column, two
338 // layers away, and an ip in the same row would sit under it.
339 lane := v.Height - padding/2
340 fmt.Fprintf(&b, `<path class="archedge archedge-skip" d="M%d %d H%d V%d H%d V%d H%d" />`,
341 x1, y1, x1+gapX/2, lane, x2-gapX/2, y2, x2)
342 continue
343 }
344 mid := (x1 + x2) / 2
345 fmt.Fprintf(&b, `<path class="archedge" d="M%d %d H%d V%d H%d" />`, x1, y1, mid, y2, x2)
346 }
347 for _, n := range v.Nodes {
348 fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, template.HTMLEscapeString(n.Kind))
349 // The hover title carries the node in full — every string below is
350 // cut to the rect, and this is where the untruncated version lives
351 // (it is also what a screen reader reads for the group).
352 fmt.Fprintf(&b, `<title>%s</title>`, template.HTMLEscapeString(nodeTitleText(n)))
353 fmt.Fprintf(&b, `<rect x="%d" y="%d" width="%d" height="%d" rx="8" />`, n.X, n.Y, n.W, n.H)
354 fmt.Fprintf(&b, `<text x="%d" y="%d" class="archtitle">%s</text>`,
355 n.X+textPad, n.Y+24, template.HTMLEscapeString(fitText(n.Title, titlePx, n.W-2*textPad)))
356 if n.Subtitle != "" {
357 fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
358 n.X+textPad, n.Y+44, template.HTMLEscapeString(fitText(n.Subtitle, subPx, n.W-2*textPad)))
359 }
360 // Extra lines carry on down the card at the same step layout used to
361 // size it (nodeLineH), so the last line lands inside the rect.
362 for i, ex := range n.Extra {
363 if ex == "" {
364 continue
365 }
366 fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`,
367 n.X+textPad, n.Y+44+(i+1)*nodeLineH, template.HTMLEscapeString(fitText(ex, subPx, n.W-2*textPad)))
368 }
369 // One mark per alarm, bottom-right, filling leftwards. A firing alarm
370 // gets its own class so it reads as different at a glance rather
371 // than only in the words of its hover title.
372 for i, a := range n.Alarms {
373 cls := "archalarm"
374 if a.Firing {
375 cls = "archalarm archalarm-firing"
376 }
377 cx := n.X + n.W - alarmInset - i*alarmStep
378 fmt.Fprintf(&b, `<circle class="%s" cx="%d" cy="%d" r="%d"><title>%s</title></circle>`,
379 cls, cx, alarmRow(n), alarmR,
380 template.HTMLEscapeString(a.Name+" "+a.State))
381 }
382 b.WriteString(`</g>`)
383 }
384 b.WriteString(`</svg>`)
385 return template.HTML(b.String())
386}
387