Merge pull request #322 from carlosframework/arch-legibility
arch page: alarms ride the node they watch, text fits its box, BYO boxes carry no price
7 files changed,
+378
−90
docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md+27 −13internal/console/archgraph.go+50 −13internal/console/archgraph_test.go+28 −30internal/console/archlayout.go+102 −15internal/console/archlayout_test.go+17 −19internal/console/archlegibility_test.go+149 −0internal/console/templates/layout.html+5 −0
diff --git a/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md b/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md| index 0598d14c5..81ce6b4a3 100644 |
| --- a/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md |
| +++ b/docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md |
| @@ -229,7 +229,12 @@ three inputs and owns every join: |
| box label (falling back to the `""` default spec, exactly as |
| `AllocateBox` does), priced `HourUSD × 730 + EBSGB × EBSGBMonthUSD`. |
| The discovered `machine.type` and summed `disk.size_gb` are shown |
| - beside it. |
| + beside it. A box with **no cloud machine behind it** — a customer's |
| + fleet box, a Pi on a desk — is not priced at all (amended 2026-08-27): |
| + the rate card prices what this platform pays for, so a monthly figure |
| + there would be a claim about someone else's bill, and a "priced by the |
| + default spec" chip would warn about a pricing that should not be |
| + happening. The card still says plainly that there is no cloud machine. |
| - **box → bucket**: every box that reports edges to the deployment bucket |
| the console itself is configured with (`s.cfg.Releases`) — the one |
| bucket relationship the console can state as fact. Per-box replication |
| @@ -259,18 +264,27 @@ A deterministic layered pass in Go, no JavaScript library, no CDN |
| (the vendored-icons rule): |
| 1. **Layer by kind** — left to right: `zone`, `record`, `ip`, |
| - `router`/`external`/`alarm`, `box`/`machine`, `apps`, `bucket`. Of the |
| - satellites, `bucket` and `alarm` are DRAWN and `identity` is not: |
| - `alarm` sits in the column immediately left of the box or machine it |
| - watches — few enough per fleet (one auto-recover alarm per box) that an |
| - armed alarm beside its box is worth the ink, and left of it so the |
| - `watches` edge reads in the same direction as every other edge. |
| - `identity`, and likewise `firewall`, `network`, `disk` and |
| - `certificate`, are listed in the resource tables below the diagram |
| - instead: a graph that draws every IAM role is a hairball. "Anchored to |
| - its owner" in a layered layout means ordered by the row of the thing it |
| - watches, which is what the barycentre sweep in (2) does — not pinned to |
| - the same Y. |
| + `router`/`external`, `box`/`machine`, `apps`, `bucket`. `bucket` is the |
| + one satellite drawn as a node; `identity`, `firewall`, `network`, |
| + `disk` and `certificate` are listed in the resource tables below the |
| + diagram instead, because a graph that draws every IAM role is a |
| + hairball. |
| + |
| + **Alarms are drawn ON the node they watch, not as nodes of their own** |
| + (amended 2026-08-27, after seeing the page rendered). They were first |
| + given the column immediately left of the boxes — the only gap in the |
| + flow — and that column is the one every IP-to-box edge crosses. Nodes |
| + paint after edges, so each alarm covered the line running beneath it |
| + and the picture read as a request reaching its box *through* an alarm. |
| + The deeper problem was the modelling: an alarm is not a hop a request |
| + takes, it is a property of the thing it watches. Each alarm is now a |
| + mark on its subject's node, coloured when firing, with its name and |
| + state in the node's hover title — which is what "anchored to its owner" |
| + should have meant all along, and which no layer can put in the way of a |
| + line. The Alarms table still lists every one in full. |
| + |
| + The invariant this cost us is now enforced directly: **no node may sit |
| + on an edge's path** unless it is one of that edge's endpoints. |
| 2. **Order within a layer** to reduce crossings: one barycentre sweep |
| down and one up, then a stable sort by name so the same fleet always |
| produces the same picture. A diagram that reshuffles between refreshes |
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go| index 64fdd186e..754921af5 100644 |
| --- a/internal/console/archgraph.go |
| +++ b/internal/console/archgraph.go |
| @@ -64,7 +64,25 @@ type KV struct{ K, V string } |
| type Node struct { |
| ID, Kind, Title, Subtitle string |
| Detail []KV |
| - X, Y, W, H int |
| + // Alarms are the alarms watching THIS node's subject. They ride the |
| + // node rather than standing as nodes of their own, for two reasons an |
| + // earlier draft learned the hard way: a satellite column placed |
| + // between the IP and the box sits underneath every IP-to-box edge, and |
| + // nodes paint after edges, so the line vanished behind it and the |
| + // picture read as a request flowing THROUGH the alarm; and an alarm is |
| + // not a hop a request takes, it is a property of the thing it watches. |
| + // The Alarms resource table still lists every one of them in full. |
| + Alarms []NodeAlarm |
| + X, Y, W, H int |
| +} |
| + |
| +// NodeAlarm is one alarm as its subject wears it: enough to say which |
| +// alarm and whether it is firing, and nothing else — the table below the |
| +// diagram carries the metric, the region and the ARN. |
| +type NodeAlarm struct { |
| + Name string |
| + State string |
| + Firing bool |
| } |
| // Edge is one relationship the console can state as fact. Kind is the |
| @@ -152,7 +170,7 @@ func BuildArch(in ArchInput) ArchView { |
| addDNSNodes(&v, in.Inv, claimed) |
| addBoxNodes(&v, in) |
| addMachineNodes(&v, in.Inv, claimed) |
| - addAlarmNodes(&v, in.Inv, claimed) |
| + addAlarmBadges(&v, in.Inv, claimed) |
| v.Groups = resourceGroups(in.Inv) |
| // AFTER layout, not before: layout is what makes the node set final (it |
| // drops the satellite kinds the diagram lists in tables instead), so an |
| @@ -353,10 +371,19 @@ func buildBoxCards(in ArchInput) []BoxCard { |
| card.DiscoveredDiskGB = diskGBFor(in.Inv, m.ID) |
| } |
| + // A box with no cloud machine behind it is somebody else's |
| + // hardware — a customer's fleet box, the Pi on a desk. The rate |
| + // card prices what THIS platform pays for, so putting a monthly |
| + // figure on one of those is a claim about a bill we do not pay, |
| + // and "priced by the default spec" is a warning about a pricing |
| + // that should not be happening at all. Both are withheld; the |
| + // card still says plainly that there is no cloud machine. |
| usd, spec, explicit := boxCost(in.Rates, label) |
| - card.USDMonth = usd |
| - card.Cores, card.MemGB, card.EBSGB = spec.Cores, spec.MemGB, spec.EBSGB |
| - card.Drift = driftFor(card, spec, explicit) |
| + if card.CloudID != "" { |
| + card.USDMonth = usd |
| + card.Cores, card.MemGB, card.EBSGB = spec.Cores, spec.MemGB, spec.EBSGB |
| + card.Drift = driftFor(card, spec, explicit) |
| + } |
| cards = append(cards, card) |
| } |
| return cards |
| @@ -574,24 +601,34 @@ func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]s |
| } |
| } |
| -// addAlarmNodes hangs each alarm off whatever it watches — the box when a |
| +// addAlarmBadges hangs each alarm on whatever it watches — the box when a |
| // box reports that machine, the bare machine otherwise. An alarm naming |
| -// nothing this page draws is still listed in the resource groups. |
| -func addAlarmNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) { |
| +// nothing this page draws is still listed in the resource groups, and one |
| +// naming something the page DOES draw becomes a mark on that node rather |
| +// than a node in the flow (see Node.Alarms for why). |
| +func addAlarmBadges(v *ArchView, inv *infraview.Inventory, claimed map[string]string) { |
| if inv == nil { |
| return |
| } |
| + at := map[string]int{} |
| + for i := range v.Nodes { |
| + at[v.Nodes[i].ID] = i |
| + } |
| for _, a := range inv.ByKind(infraview.KindAlarm) { |
| to := targetOf(a.Attr("target"), claimed) |
| if to == "" { |
| continue |
| } |
| - id := "alarm:" + a.ID |
| - v.Nodes = append(v.Nodes, Node{ |
| - ID: id, Kind: "alarm", Title: firstNonEmpty(a.Name, a.ID), |
| - Subtitle: strings.TrimSpace(a.Attr("metric") + " " + a.Attr("state")), |
| + i, drawn := at[to] |
| + if !drawn { |
| + continue |
| + } |
| + state := a.Attr("state") |
| + v.Nodes[i].Alarms = append(v.Nodes[i].Alarms, NodeAlarm{ |
| + Name: a.Name, |
| + State: state, |
| + Firing: state == "ALARM", |
| }) |
| - v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "watches"}) |
| } |
| } |
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go| index f127d9015..56a90122a 100644 |
| --- a/internal/console/archgraph_test.go |
| +++ b/internal/console/archgraph_test.go |
| @@ -189,16 +189,29 @@ func TestArchCostAndDrift(t *testing.T) { |
| t.Error("a 30 GB rate-card entry against a 40 GB discovered disk must raise drift") |
| } |
| - // A reporting box with no rate-card entry of its own is also drift: |
| - // it is being priced by the default spec, which is a guess. |
| - var pi BoxCard |
| - for _, b := range v.Boxes { |
| - if b.Label == "pi-dublin" { |
| - pi = b |
| + // A box WE pay for that has no rate-card entry of its own is also |
| + // drift: it is being priced by the default spec, which is a guess. |
| + // Machine-backed on purpose — a bring-your-own box is not priced at |
| + // all, so it has no pricing to be wrong about (TestByoBoxIsNotPriced). |
| + noEntry := archFixture() |
| + delete(noEntry.Rates.Boxes, "carlos-eu-west-1") |
| + var defaulted BoxCard |
| + for _, b := range BuildArch(noEntry).Boxes { |
| + if b.Label == "carlos-eu-west-1" { |
| + defaulted = b |
| + } |
| + } |
| + if defaulted.CloudID == "" { |
| + t.Fatal("fixture drift: carlos-eu-west-1 must be machine-backed for this case") |
| + } |
| + var said bool |
| + for _, d := range defaulted.Drift { |
| + if strings.Contains(d, "default spec") { |
| + said = true |
| } |
| } |
| - if len(pi.Drift) == 0 { |
| - t.Error("a box priced by the default spec must say so") |
| + if !said { |
| + t.Errorf("a box we pay for, priced by the default spec, must say so: %v", defaulted.Drift) |
| } |
| } |
| @@ -379,36 +392,21 @@ func TestArchDrawsNoEdgeToAnUndrawnNode(t *testing.T) { |
| } |
| } |
| -// An alarm watching a machine is an edge to the BOX running on it — the |
| -// operator's question is "is this box watched?", not "is i-123 watched?". |
| -// |
| -// Asserted on the RENDERED page, not merely on the join: an armed |
| -// auto-recover alarm beside the box it watches is the operator-facing fact |
| -// this page exists to surface, and a build that produced the edge while the |
| -// layout dropped the node would satisfy a join-only assertion and show the |
| -// operator nothing. |
| -func TestArchAlarmWatchesItsBox(t *testing.T) { |
| +// Every edge on a built view must reach the page. RenderArchSVG skips an |
| +// edge whose endpoints it cannot place, and that skip is the second net, |
| +// not the mechanism: BuildArch prunes dangling edges after layout, so by |
| +// the time the renderer runs there is nothing left for the skip to catch. |
| +// A build that quietly lost an edge to the skip would otherwise look fine. |
| +func TestEveryEdgeReachesThePage(t *testing.T) { |
| in := archFixture() |
| in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| Kind: infraview.KindAlarm, ID: "auto-recover-i-123", Name: "auto-recover", |
| Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"}, |
| }) |
| v := BuildArch(in) |
| - if !hasEdge(v, "alarm:auto-recover-i-123", "box:carlos-eu-west-1") { |
| - t.Errorf("an alarm naming a box's machine must edge to that box: %+v", v.Edges) |
| - } |
| - if !hasNode(v, "alarm:auto-recover-i-123") { |
| - t.Fatalf("the alarm must survive layout and be drawn: %+v", v.Nodes) |
| - } |
| out := string(RenderArchSVG(v)) |
| - if !strings.Contains(out, "archnode-alarm") || !strings.Contains(out, "auto-recover") { |
| - t.Errorf("the alarm never reaches the SVG:\n%s", out) |
| - } |
| - // RenderArchSVG skips an edge whose endpoints it cannot place. That skip |
| - // is the second net, not the mechanism: every edge on a built view must |
| - // reach the page. |
| if got := strings.Count(out, `<path class="archedge"`); got != len(v.Edges) { |
| - t.Errorf("%d edge paths drawn for %d edges — the renderer is silently dropping edges", 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 860e441f4..1bc88f76c 100644 |
| --- a/internal/console/archlayout.go |
| +++ b/internal/console/archlayout.go |
| @@ -21,16 +21,15 @@ import ( |
| // so the layer is otherwise empty, and an address leaving the estate must |
| // sit to the RIGHT of the record that names it, not in the zone column. |
| // |
| -// "alarm" is the one satellite that is DRAWN (spec §6.4). It shares that |
| -// same layer 3, which puts it in the column immediately LEFT of the boxes |
| -// and machines: its `watches` edge then reads left-to-right like every |
| -// other edge on the page, it costs no extra width because that column is |
| -// reserved anyway, and the barycentre sweep below orders it by the row of |
| -// the thing it watches — which is what "anchored to its owner" amounts to |
| -// in a layered layout. Non-overlap is structural: every node in a layer |
| -// gets its own row index. |
| +// Alarms are deliberately absent from this map. They were briefly given |
| +// layer 3 — the column between the IPs and the boxes — and that column is |
| +// the one every IP-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 now rides that node as a mark |
| +// (Node.Alarms) and no layer can put it in the way of a line. |
| var archLayer = map[string]int{ |
| - "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3, "alarm": 3, |
| + "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3, |
| "box": 4, "machine": 4, "apps": 5, "bucket": 6, |
| } |
| @@ -47,10 +46,8 @@ func layout(v *ArchView) { |
| // Identities, firewalls, networks, disks and certificates are listed in |
| // the tables below the diagram rather than drawn: a graph that draws |
| // every IAM role is a hairball, and the resource groups already carry |
| - // them in full. Alarms are the exception and are drawn — they are few |
| - // (one auto-recover alarm per box on this fleet) and an armed alarm |
| - // beside the box it watches is exactly the operator-facing fact this |
| - // page exists to surface. |
| + // them in full. Alarms are not in this list because they are no longer |
| + // nodes at all — they ride the node they watch (see archLayer above). |
| kept := v.Nodes[:0] |
| for _, n := range v.Nodes { |
| if strings.Contains(satelliteKinds, n.Kind) { |
| @@ -189,6 +186,79 @@ func layout(v *ArchView) { |
| // 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 |
| +// 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]) + "…" |
| +} |
| + |
| +// 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 |
| +} |
| + |
| +// 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 _, a := range n.Alarms { |
| + out += " · " + a.Name + " " + a.State |
| + } |
| + return out |
| +} |
| + |
| 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">`, |
| @@ -215,12 +285,29 @@ func RenderArchSVG(v ArchView) template.HTML { |
| } |
| for _, n := range v.Nodes { |
| fmt.Fprintf(&b, `<g class="archnode archnode-%s">`, 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, `<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+12, n.Y+24, template.HTMLEscapeString(n.Title)) |
| + n.X+textPad, n.Y+24, template.HTMLEscapeString(fitText(n.Title, titlePx, n.W-2*textPad-alarmRoom(n)))) |
| if n.Subtitle != "" { |
| fmt.Fprintf(&b, `<text x="%d" y="%d" class="archsub">%s</text>`, |
| - n.X+12, n.Y+44, template.HTMLEscapeString(n.Subtitle)) |
| + 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 |
| + // 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, `<circle class="%s" cx="%d" cy="%d" r="%d"><title>%s</title></circle>`, |
| + cls, cx, n.Y+alarmInset, alarmR, |
| + template.HTMLEscapeString(a.Name+" "+a.State)) |
| } |
| b.WriteString(`</g>`) |
| } |
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go| index 1e2bde30d..a011c6bb8 100644 |
| --- a/internal/console/archlayout_test.go |
| +++ b/internal/console/archlayout_test.go |
| @@ -128,30 +128,28 @@ func archAlarmFixture() ArchInput { |
| return in |
| } |
| -// Alarms are DRAWN, as satellites in the column immediately left of the |
| -// thing they watch — an armed auto-recover alarm beside its box is the |
| -// operator-facing fact this page exists to surface. Left of, not right of, |
| -// so the `watches` edge reads in the same direction as every other edge. |
| -func TestLayoutDrawsAlarmSatellites(t *testing.T) { |
| +// An alarm-bearing estate stays clean: the alarms ride the nodes they |
| +// watch instead of standing in the flow (which is what put them under the |
| +// IP-to-box edges), and adding them changes neither the overlap-free |
| +// property nor determinism. |
| +func TestLayoutAlarmBearingEstateStaysClean(t *testing.T) { |
| v := BuildArch(archAlarmFixture()) |
| - x := map[string]int{} |
| + |
| for _, n := range v.Nodes { |
| - x[n.ID] = n.X |
| - } |
| - for _, c := range []struct{ alarm, watched string }{ |
| - {"alarm:a-1", "box:carlos-eu-west-1"}, |
| - {"alarm:a-2", "machine:i-999"}, |
| - } { |
| - if _, ok := x[c.alarm]; !ok { |
| - t.Fatalf("%s was dropped by layout: %+v", c.alarm, v.Nodes) |
| - } |
| - if !hasEdge(v, c.alarm, c.watched) { |
| - t.Fatalf("%s must watch %s: %+v", c.alarm, c.watched, v.Edges) |
| + if n.Kind == "alarm" { |
| + t.Fatalf("an alarm must not be a node in the flow: %+v", n) |
| } |
| - if x[c.alarm] >= x[c.watched] { |
| - t.Errorf("%s at x=%d must sit left of %s at x=%d", c.alarm, x[c.alarm], c.watched, x[c.watched]) |
| + } |
| + worn := map[string]int{} |
| + for _, n := range v.Nodes { |
| + worn[n.ID] = len(n.Alarms) |
| + } |
| + for _, id := range []string{"box:carlos-eu-west-1", "machine:i-999"} { |
| + if worn[id] == 0 { |
| + t.Errorf("%s should wear the alarm that watches it: %+v", id, v.Nodes) |
| } |
| } |
| + |
| for i := range v.Nodes { |
| for j := i + 1; j < len(v.Nodes); j++ { |
| a, b := v.Nodes[i], v.Nodes[j] |
diff --git a/internal/console/archlegibility_test.go b/internal/console/archlegibility_test.go| new file mode 100644 |
| index 000000000..039536fa9 |
| --- /dev/null |
| +++ b/internal/console/archlegibility_test.go |
| @@ -0,0 +1,149 @@ |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// TestNoNodeSitsOnAnEdgePath is the invariant the alarm column broke: a |
| +// node that is not an endpoint of an edge must not sit ON that edge's |
| +// path. Nodes paint after edges, so a node standing in the middle of a |
| +// 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. |
| +func TestNoNodeSitsOnAnEdgePath(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + 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 |
| + 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) |
| + } |
| + } |
| + } |
| +} |
| + |
| +func segmentHitsNode(xa, xb, y int, n Node) bool { |
| + if xa > xb { |
| + xa, xb = xb, xa |
| + } |
| + return y > n.Y && y < n.Y+n.H && xb > n.X && xa < n.X+n.W |
| +} |
| + |
| +func verticalHitsNode(x, ya, yb int, n Node) bool { |
| + if ya > yb { |
| + ya, yb = yb, ya |
| + } |
| + return x > n.X && x < n.X+n.W && yb > n.Y && ya < n.Y+n.H |
| +} |
| + |
| +// TestAlarmsRideTheNodeTheyWatch: an alarm is a property OF the thing it |
| +// watches, so it is drawn on that node rather than as a node of its own in |
| +// the flow. A firing alarm must be distinguishable from a healthy one by |
| +// something other than its wording. |
| +func TestAlarmsRideTheNodeTheyWatch(t *testing.T) { |
| + in := archFixture() |
| + in.Inv.Resources = append(in.Inv.Resources, |
| + infraview.Resource{Provider: "aws", Kind: infraview.KindAlarm, NativeType: "cloudwatch:alarm", |
| + ID: "arn:alarm/ok", Name: "carlos-eu-west-1-auto-recover", Region: "eu-west-1", |
| + Attrs: map[string]string{"state": "OK", "metric": "StatusCheckFailed_System", "target": "i-123"}}, |
| + infraview.Resource{Provider: "aws", Kind: infraview.KindAlarm, NativeType: "cloudwatch:alarm", |
| + ID: "arn:alarm/firing", Name: "carlos-eu-west-1-cpu", Region: "eu-west-1", |
| + Attrs: map[string]string{"state": "ALARM", "metric": "CPUUtilization", "target": "i-123"}}, |
| + ) |
| + v := BuildArch(in) |
| + |
| + if n := nodeByID(v, "alarm:arn:alarm/ok"); n != nil { |
| + t.Fatalf("an alarm must not be drawn as its own node in the flow: %+v", n) |
| + } |
| + box := nodeByID(v, "box:carlos-eu-west-1") |
| + if box == nil { |
| + t.Fatal("no box node") |
| + } |
| + if len(box.Alarms) != 2 { |
| + t.Fatalf("box carries %d alarms, want 2: %+v", len(box.Alarms), box.Alarms) |
| + } |
| + var firing, ok bool |
| + for _, a := range box.Alarms { |
| + if a.Name == "carlos-eu-west-1-cpu" && a.Firing { |
| + firing = true |
| + } |
| + if a.Name == "carlos-eu-west-1-auto-recover" && !a.Firing { |
| + ok = true |
| + } |
| + } |
| + if !firing || !ok { |
| + t.Errorf("firing state not carried per alarm: %+v", box.Alarms) |
| + } |
| + |
| + out := string(RenderArchSVG(v)) |
| + if !strings.Contains(out, "archalarm-firing") { |
| + t.Error("a firing alarm must render differently from a healthy one") |
| + } |
| + if !strings.Contains(out, "carlos-eu-west-1-cpu ALARM") { |
| + t.Error("the alarm's name and state must be reachable on the node (hover title)") |
| + } |
| +} |
| + |
| +// TestSVGTextIsFittedToItsBox: nothing may bleed past the rect it belongs |
| +// to. The full string stays reachable as the node's hover title, so |
| +// truncation costs nothing but the overflow. |
| +func TestSVGTextIsFittedToItsBox(t *testing.T) { |
| + in := archFixture() |
| + v := BuildArch(in) |
| + long := "console, kass, keymail, platform-site, woodstar, status, carloku-site" |
| + for i := range v.Nodes { |
| + if v.Nodes[i].Kind == "apps" { |
| + v.Nodes[i].Title = long |
| + v.Nodes[i].Subtitle = "static 3 · resident 4 · hibernating 2 · awake 6 · asleep 3" |
| + } |
| + } |
| + out := string(RenderArchSVG(v)) |
| + if strings.Contains(out, ">"+long+"<") { |
| + t.Error("a title wider than its rect must be truncated, not drawn past the box") |
| + } |
| + if !strings.Contains(out, "…") { |
| + t.Error("truncated text must show an ellipsis") |
| + } |
| + if !strings.Contains(out, long) { |
| + t.Error("the full string must survive as the node's hover title") |
| + } |
| +} |
| + |
| +// TestByoBoxIsNotPriced: the platform does not pay for a box it does not |
| +// own, so a bring-your-own machine gets no monthly figure and no |
| +// "priced by the default spec" chip — a price there would be a claim about |
| +// someone else's bill. |
| +func TestByoBoxIsNotPriced(t *testing.T) { |
| + v := BuildArch(archFixture()) |
| + for _, c := range v.Boxes { |
| + if c.CloudID != "" { |
| + continue |
| + } |
| + if c.USDMonth != 0 { |
| + t.Errorf("BYO box %s priced at %v", c.Label, c.USDMonth) |
| + } |
| + for _, d := range c.Drift { |
| + if strings.Contains(d, "default spec") { |
| + t.Errorf("BYO box %s carries a pricing-drift chip: %q", c.Label, d) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 2a36a9fa8..dd4ca0b4e 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -429,6 +429,11 @@ |
| node's outline sitting on a filled body, invisible for a line that has |
| to be followed across the page. */ |
| .archsvg .archedge { fill: none; stroke: var(--muted); stroke-width: 1.5; } |
| + /* An alarm rides the node it watches (archlayout.go): a healthy one is a |
| + quiet mark, a firing one takes the bad colour so it reads at a glance |
| + rather than only in the words of its hover title. */ |
| + .archsvg .archalarm { fill: var(--good); } |
| + .archsvg .archalarm-firing { fill: var(--bad); } |
| .archbox { |
| border: 1px solid var(--rule); border-radius: 0.6rem; |
| padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 44rem; |