// Layout and SVG emission for /fleet/architecture. Deterministic by // construction: layer by kind, order within a layer by one barycentre sweep // each way, then a stable sort by node id. The same fleet must produce a // byte-identical picture on every refresh — a diagram that reshuffles // cannot be read for change. // // No JavaScript and no layout library: the console vendors its icons and // loads nothing from a CDN, and a server-computed layout is the only way to // keep that promise while still drawing a graph. package console import ( "fmt" "html/template" "sort" "strings" ) // Layer assignment by node kind — the story one band tells, left to right, // in FOUR columns and no more. Four is not a taste: the console's main // column is 64rem = 1024px, and 4*190 + 3*70 + 2*24 = 1018 is the widest // picture that fits inside it. A fifth column is 260 units the page does // not have, and the page having them is the difference between reading the // diagram and dragging it. // // Two foldings bought the room. "apps" is gone as a kind: what a box runs // is a property of the box and now rides its card (Node.Extra), where it // used to be a whole column in which six of nine cards said "no apps". // "external" shares the address column with "ip" rather than standing to // its right: both answer the one question a reader has at that point in the // line — what does this name resolve to — and the distinction between an // address we allocated and one outside the estate is carried by the node's // kind, which is drawn, rather than by its column, which is not. // // "zone" is absent because zones are not drawn at all (dropSatellites). // Alarms are absent for a different reason: they were briefly given a // column of their own, and it was the one column every address-to-box edge // crosses. Nodes paint after edges, so each alarm covered the line running // under it and the diagram read as a request reaching its box THROUGH an // alarm. An alarm is not a hop; it is a property of the thing it watches, // so it rides that node as a mark (Node.Alarms) and no column can put it in // the way of a line. var archLayer = map[string]int{ "record": 0, "ip": 1, "external": 1, "router": 1, "box": 2, "machine": 2, "bucket": 3, } const ( nodeW = 190 // nodeH is the height of a node carrying a title and a subtitle; each // Extra line adds nodeLineH. Height is per NODE rather than one constant // for the whole picture because the box card needs four lines and the // records outnumber the boxes twenty to one — giving every node the // tallest node's height would add thousands of units of whitespace to // the estate with the most nodes and least to say about each. nodeH = 64 nodeLineH = 17 // gapX is 70 rather than the 90 the single-picture layout used, and // that 20 units per gap is what brings four columns inside 1024. It is // the gap, not the node, that gave way: the node width is what a // hostname fits in. gapX = 70 gapY = 26 padding = 24 satelliteKinds = "identity firewall network disk certificate other zone" ) // nodeHeight is how tall one node is drawn. One place, because layout // positions by it and RenderArchSVG writes text down it, and the two // disagreeing would put a line of text outside its own rect. func nodeHeight(n Node) int { return nodeH + len(n.Extra)*nodeLineH } // layout places one BAND. The satellite kinds are already gone by the time // it runs (dropSatellites, called before banding so a global zone cannot // carry a region along its edges), so every node it is handed is a node it // draws. func layout(v *ArchBand) { byLayer := map[int][]int{} for i := range v.Nodes { l := archLayer[v.Nodes[i].Kind] byLayer[l] = append(byLayer[l], i) } // Stable order first, so the barycentre sweeps start from a fixed // arrangement and cannot depend on map iteration order anywhere. for l := range byLayer { idx := byLayer[l] sort.Slice(idx, func(a, b int) bool { return v.Nodes[idx[a]].ID < v.Nodes[idx[b]].ID }) } // One sweep forward and one back, ordering each layer by the mean row of // its neighbours in the adjacent layer — the standard crossing // reduction, and enough for a graph this shape. Ties break on node id so // the result cannot depend on map or slice iteration order anywhere. row := map[string]int{} for _, idx := range byLayer { for r, i := range idx { row[v.Nodes[i].ID] = r } } // An edge whose other end this layout does not draw carries no row to // average, and BuildArch prunes such edges only once layout has run. // Counting them would place a node by the row of something that is not // on the page — deterministic, but wrong. drawn := make(map[string]bool, len(v.Nodes)) for _, n := range v.Nodes { drawn[n.ID] = true } neighbours := func(id string, forward bool) []string { var out []string for _, e := range v.Edges { if forward && e.To == id && drawn[e.From] { out = append(out, e.From) } if !forward && e.From == id && drawn[e.To] { out = append(out, e.To) } } return out } sweep := func(forward bool) { layers := make([]int, 0, len(byLayer)) for l := range byLayer { layers = append(layers, l) } sort.Ints(layers) if !forward { for i, j := 0, len(layers)-1; i < j; i, j = i+1, j-1 { layers[i], layers[j] = layers[j], layers[i] } } for _, l := range layers { idx := byLayer[l] bary := map[string]float64{} for _, i := range idx { id := v.Nodes[i].ID ns := neighbours(id, forward) if len(ns) == 0 { bary[id] = float64(row[id]) continue } sum := 0 for _, n := range ns { sum += row[n] } bary[id] = float64(sum) / float64(len(ns)) } sort.SliceStable(idx, func(a, b int) bool { ia, ib := v.Nodes[idx[a]].ID, v.Nodes[idx[b]].ID if bary[ia] != bary[ib] { return bary[ia] < bary[ib] } return ia < ib }) for r, i := range idx { row[v.Nodes[i].ID] = r } } } sweep(true) sweep(false) // Position. Rows stack by CUMULATIVE height rather than by row index // times a constant, because nodes are not all the same height any more // (Node.Extra). Edge routing reads Y+H/2 off the node, so it follows // this for free. tallest := 0 for l, idx := range byLayer { y := padding for _, i := range idx { v.Nodes[i].W = nodeW v.Nodes[i].H = nodeHeight(v.Nodes[i]) v.Nodes[i].X = padding + l*(nodeW+gapX) v.Nodes[i].Y = y y += v.Nodes[i].H + gapY } if len(idx) > 0 && y-gapY > tallest { tallest = y - gapY } } layers := 0 for l := range byLayer { if l+1 > layers { layers = l + 1 } } // An empty band (no boxes, no inventory yet — an ungranted console on // day one) still has to carry a valid, positive viewBox: it is the // FIRST page an operator sees, not the busiest. Without this clamp // layers==0 and tallest==0 drive Width/Height negative. if layers < 1 { layers = 1 } if tallest < padding+nodeH { tallest = padding + nodeH } v.Width = padding*2 + layers*nodeW + (layers-1)*gapX v.Height = tallest + padding } // RenderArchSVG emits one band's 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 carrying only a viewBox has no intrinsic size, // so a browser sizes it at 100% of its container and scales the drawing to // fit, rendering 13px node titles at whatever fraction that works out to. // With an intrinsic size the drawing renders 1:1. A band is at most 1018 // units wide by construction (archLayer), which is inside the console's // 64rem column, so at 1:1 it neither shrinks nor overflows: the page grows // DOWNWARDS as an estate gains regions and never sideways. // 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 // 0.58 is the ratio that keeps the longest real strings (a five-app list, // a full instance-count line) inside the box without cutting short ones // that would have fit. It is deliberately a little pessimistic — a string // cut one character early costs nothing, a string one character too long // bleeds across the neighbouring node. const ( titlePx = 13 subPx = 11 glyphRatio = 0.58 textPad = 12 alarmR = 5 alarmInset = 14 alarmStep = 14 ) // fitText cuts s to the pixels available, appending an ellipsis when it // has to. The full string is never lost — RenderArchSVG puts it in the // node's hover title. func fitText(s string, px, avail int) string { if avail <= 0 || s == "" { return "" } max := int(float64(avail) / (float64(px) * glyphRatio)) r := []rune(s) if max < 1 { return "…" } if len(r) <= max { return s } return string(r[:max-1]) + "…" } // alarmRow is the y centre of the alarm marks: near the node's BOTTOM edge, // below the subtitle's baseline, not on the title's line. // // They used to share the title's line, and the title had to stop before // running underneath them — an `alarmRoom` helper reserved // len(Alarms)*alarmStep + alarmR pixels of it. With the two alarms every // flagship box has carried since 2026-08-28 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 available place to run out of room. // // The alarms did not need that space. A node is nodeH (64) tall plus // nodeLineH per Extra line, and its LAST baseline — the subtitle's at +44, or // the last Extra line's — is always 20 above the rect's bottom edge // (nodeHeight). A 5px mark centred 8 above that edge clears the last line's // descenders and sits inside the rect's rounded corner, whatever the line // count. So the marks moved down and EVERY line 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. func nodeTitleText(n Node) string { out := n.Title if n.Subtitle != "" { out += " — " + n.Subtitle } for _, ex := range n.Extra { if ex != "" { out += " · " + ex } } for _, a := range n.Alarms { out += " · " + a.Name + " " + a.State } return out } func RenderArchSVG(v ArchBand) template.HTML { var b strings.Builder fmt.Fprintf(&b, ``, v.Width, v.Height, v.Width, v.Height) pos := map[string]Node{} for _, n := range v.Nodes { pos[n.ID] = n } // Edges first so nodes paint over them. for _, e := range v.Edges { from, okF := pos[e.From] to, okT := pos[e.To] if !okF || !okT { // The second net only: BuildArch prunes dangling edges after // layout, so a built view never reaches here. A caller that // hand-assembles an ArchView still cannot make this function // dereference a node it has not been given. continue } 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 (gapX // 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. // // Folding "external" into the address column (archLayer) removed // the ip -> box case #9 was caught on, but not the mechanism: a // record whose value is an address a machine holds DIRECTLY // (recordTarget) points at the box from the records column, two // layers away, and an ip in the same row would sit under it. lane := v.Height - padding/2 fmt.Fprintf(&b, ``, x1, y1, x1+gapX/2, lane, x2-gapX/2, y2, x2) continue } mid := (x1 + x2) / 2 fmt.Fprintf(&b, ``, x1, y1, mid, y2, x2) } for _, n := range v.Nodes { fmt.Fprintf(&b, ``, template.HTMLEscapeString(n.Kind)) // The hover title carries the node in full — every string below is // cut to the rect, and this is where the untruncated version lives // (it is also what a screen reader reads for the group). fmt.Fprintf(&b, `%s`, template.HTMLEscapeString(nodeTitleText(n))) fmt.Fprintf(&b, ``, n.X, n.Y, n.W, n.H) fmt.Fprintf(&b, `%s`, n.X+textPad, n.Y+24, template.HTMLEscapeString(fitText(n.Title, titlePx, n.W-2*textPad))) if n.Subtitle != "" { fmt.Fprintf(&b, `%s`, n.X+textPad, n.Y+44, template.HTMLEscapeString(fitText(n.Subtitle, subPx, n.W-2*textPad))) } // Extra lines carry on down the card at the same step layout used to // size it (nodeLineH), so the last line lands inside the rect. for i, ex := range n.Extra { if ex == "" { continue } fmt.Fprintf(&b, `%s`, n.X+textPad, n.Y+44+(i+1)*nodeLineH, template.HTMLEscapeString(fitText(ex, subPx, n.W-2*textPad))) } // 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 { cls := "archalarm" if a.Firing { cls = "archalarm archalarm-firing" } cx := n.X + n.W - alarmInset - i*alarmStep fmt.Fprintf(&b, `%s`, cls, cx, alarmRow(n), alarmR, template.HTMLEscapeString(a.Name+" "+a.State)) } b.WriteString(``) } b.WriteString(``) return template.HTML(b.String()) }