carlos / platform Public

arch page: alarms ride the node they watch, text fits its box, BYO boxes carry no price

Three defects found by rendering the page and reading the emitted SVG,
none of which a test we had could see.

Alarms were given the column between the IPs and the boxes — the only gap
in the flow, and the one every IP-to-box edge crosses. Nodes paint after
edges, so each alarm covered the line beneath it and the diagram read as a
request reaching its box THROUGH an alarm. The modelling was wrong more
than the placement: an alarm is not a hop, it is a property of the thing it
watches. Each alarm is now a mark on its subject's node, coloured when
firing (which also answers 'a firing alarm looks like a healthy one'), with
its name and state in the node's hover title. TestNoNodeSitsOnAnEdgePath
enforces the general invariant that was missing.

Node text had no truncation at all, so a five-app list ran ~300 units
across a 190-unit rect and bled over its neighbour. Text is now fitted to
the box with an estimated glyph budget, and the full string survives as the
node's hover title.

A bring-your-own box showed $17/mo from the default rate-card spec. The
rate card prices what this platform pays for; a Pi on someone's desk is not
that, so the figure was a claim about someone else's bill. Such a box is
now unpriced, and keeps only the plain statement that it has no cloud
machine.

Spec §6.2 and §6.4 amended to describe what ships.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev a04e8eb2ea844081f1c9afb3115fa37d0aa9a1c1 parent 4235e9f
7 files changed, +378 −90
  • docs/superpowers/specs/2026-08-19-fleet-architecture-diagram-design.md +27 −13
  • internal/console/archgraph.go +50 −13
  • internal/console/archgraph_test.go +28 −30
  • internal/console/archlayout.go +102 −15
  • internal/console/archlayout_test.go +17 −19
  • internal/console/archlegibility_test.go +149 −0
  • internal/console/templates/layout.html +5 −0

CI passed — run details

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;