| 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. |
| 10 | package console |
| 11 | |
| 12 | import ( |
| 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. |
| 43 | var archLayer = map[string]int{ |
| 44 | "record": 0, "ip": 1, "external": 1, "router": 1, |
| 45 | "box": 2, "machine": 2, "bucket": 3, |
| 46 | } |
| 47 | |
| 48 | const ( |
| 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. |
| 71 | func 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. |
| 77 | func 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. |
| 220 | const ( |
| 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. |
| 233 | func 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. |
| 268 | func 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. |
| 272 | func 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 | |
| 288 | func 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 | |