arch page: counts unknown, alarms drawn, cores on the card
Three fixes from the whole-branch review of /fleet/architecture. 1. A count is a claim. BoxCard.Static/Resident/Hibernating come from the fleet aggregate and only from it, so a box that read never described — one present in the private host table alone, or every box when the fleet read failed — was rendering "static 0 · resident 0 · hibernating 0" in the diagram AND on the card, two words from an Awake/Asleep that correctly said "unknown". buildBoxCards now tracks whether the fleet produced a row for each label (BoxCard.CountsKnown) and both renderings say "counts unknown" instead. The short form also mitigates the known overflow of the 190-unit node rect. 2. The alarm join was built and then thrown away: layout dropped every alarm node unconditionally, RenderArchSVG silently skipped the orphaned edges, and the test asserting the edge passed on a view that never reached the page. Alarms are now DRAWN, as satellites in the column immediately left of the box or machine they watch — few per fleet, no extra width (that column was reserved and empty), the `watches` edge reads left-to-right like every other edge, and the barycentre sweep orders each alarm by the row of what it watches. Identity, firewall, network, disk and certificate stay table-only. pruneDanglingEdges now runs AFTER layout, since layout is what makes the node set final, so the shipped ArchView satisfies the invariant TestArchDrawsNoEdgeToAnUndrawnNode states rather than relying on the renderer's skip. The sweep ignores edges to nodes it does not draw, so a partial estate cannot place a node by a row that is not on the page. RenderArchSVG's okF/okT skip stays as the second net. 3. BoxCard.Cores was computed, carried and never rendered; spec §7 says the card shows cores/memory. Rendered beside memory, as its own .fact span like every other independently-absent value on that line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
6 files changed,
+301
−24
internal/console/archgraph.go+39 −14internal/console/archgraph_test.go+93 −1internal/console/archlayout.go+33 −8internal/console/archlayout_test.go+90 −0internal/console/archpage_test.go+39 −0internal/console/templates/architecture.html+7 −1
diff --git a/internal/console/archgraph.go b/internal/console/archgraph.go| index 51c4959f8..64fdd186e 100644 |
| --- a/internal/console/archgraph.go |
| +++ b/internal/console/archgraph.go |
| @@ -80,13 +80,20 @@ type BoxCard struct { |
| DiscoveredDiskGB float64 |
| USDMonth float64 |
| Apps []string |
| - Static, Resident, Hibernating int |
| - Awake, Asleep *int |
| - Release, Load string |
| - Stale bool |
| - Dark string |
| - Drift []string |
| - CloudID string // "" = no cloud machine behind this box |
| + // Static/Resident/Hibernating come from the fleet aggregate and ONLY |
| + // from it, so a box that read never described has no counts at all — |
| + // not three zeroes. CountsKnown is that fact, and it gates every |
| + // rendering of them: a zero here would be a claim about a read that |
| + // never happened, two words from an Awake/Asleep that correctly says |
| + // "unknown". |
| + Static, Resident, Hibernating int |
| + CountsKnown bool |
| + Awake, Asleep *int |
| + Release, Load string |
| + Stale bool |
| + Dark string |
| + Drift []string |
| + CloudID string // "" = no cloud machine behind this box |
| } |
| // ResourceGroup is one table below the diagram. |
| @@ -147,14 +154,19 @@ func BuildArch(in ArchInput) ArchView { |
| addMachineNodes(&v, in.Inv, claimed) |
| addAlarmNodes(&v, in.Inv, claimed) |
| v.Groups = resourceGroups(in.Inv) |
| - pruneDanglingEdges(&v) |
| + // 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 |
| + // edge is only knowably dangling once layout has run. Pruning earlier |
| + // left the shipped ArchView carrying edges to nodes it does not draw. |
| layout(&v) |
| + pruneDanglingEdges(&v) |
| return v |
| } |
| // pruneDanglingEdges drops any edge whose endpoints this view does not |
| -// draw. One rule in one place, applied last, so every pass above can join on |
| -// whatever it reads without each of them checking. It matters because a |
| +// draw. One rule in one place, applied last — after layout, which is what |
| +// decides which nodes are drawn — so every pass above can join on whatever |
| +// it reads without each of them checking. It matters because a |
| // PARTIAL inventory is normal (infraview.Inventory.Partial): an EIP whose |
| // instance read failed, or an alarm naming a machine that has since been |
| // terminated, would otherwise leave an arrow pointing at nothing — and an |
| @@ -304,7 +316,11 @@ func buildBoxCards(in ArchInput) []BoxCard { |
| cards := make([]BoxCard, 0, len(labels)) |
| for _, label := range labels { |
| - fb := fleetBoxes[label] |
| + // described is whether the FLEET read produced a row for this box. |
| + // A box the host table alone knows about — and every box when the |
| + // fleet read failed — has no counts, and fb's zero value must not be |
| + // mistaken for a fleet that counted zero of everything. |
| + fb, described := fleetBoxes[label] |
| host := hosts[label] |
| card := BoxCard{ |
| @@ -313,6 +329,7 @@ func buildBoxCards(in ArchInput) []BoxCard { |
| Static: fb.Routes.Static, |
| Resident: fb.Routes.Resident, |
| Hibernating: fb.Routes.Hibernating, |
| + CountsKnown: described, |
| Awake: fb.Routes.Awake, |
| Asleep: fb.Routes.Asleep, |
| Stale: fb.Stale || host.Stale, |
| @@ -692,10 +709,18 @@ func appsTitle(b BoxCard) string { |
| return strings.Join(b.Apps, ", ") |
| } |
| -// countsSubtitle renders the box's instance counts. Awake and Asleep are |
| -// *int and null means the socket table was unreadable — "unknown", never 0, |
| -// because 0 is a claim. |
| +// countsSubtitle renders the box's instance counts. Two absences, both of |
| +// which must read as absence: a box the fleet read never described has NO |
| +// counts (three zeroes would state a read that never happened), and Awake |
| +// and Asleep are *int whose null means the socket table was unreadable. |
| +// "unknown", never 0, because 0 is a claim. |
| +// |
| +// The unknown form is deliberately short: this string is a subtitle inside a |
| +// 190-unit node rect, and the long form already overruns it. |
| func countsSubtitle(b BoxCard) string { |
| + if !b.CountsKnown { |
| + return "counts unknown" |
| + } |
| return fmt.Sprintf("static %d · resident %d · hibernating %d · awake %s · asleep %s", |
| b.Static, b.Resident, b.Hibernating, countOrUnknown(b.Awake), countOrUnknown(b.Asleep)) |
| } |
diff --git a/internal/console/archgraph_test.go b/internal/console/archgraph_test.go| index 272ee4b89..f127d9015 100644 |
| --- a/internal/console/archgraph_test.go |
| +++ b/internal/console/archgraph_test.go |
| @@ -2,6 +2,7 @@ package console |
| import ( |
| "fmt" |
| + "strings" |
| "testing" |
| "github.com/carlosframework/platform/internal/economics" |
| @@ -380,16 +381,35 @@ 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) { |
| 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"}, |
| + 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)) |
| + } |
| } |
| // A zone can carry dozens of A records aimed at one address — one per app |
| @@ -456,3 +476,75 @@ func TestArchGroupsRecordsBySameZoneAndTarget(t *testing.T) { |
| t.Errorf("DNS resource group rows = %d, want 22 (every record listed individually)", dnsRows) |
| } |
| } |
| + |
| +// A count is a claim. The fleet aggregate is the ONLY read that carries |
| +// static/resident/hibernating, so a box that read never described — a box |
| +// present only in the private host table, or every box when the fleet read |
| +// failed outright — must say its counts are unknown rather than print three |
| +// confident zeroes beside an "unknown" awake/asleep two words away. |
| +func TestArchCountsAreUnknownWhenTheFleetNeverDescribedTheBox(t *testing.T) { |
| + in := archFixture() |
| + in.Hosts = append(in.Hosts, HostRow{Label: "extra-box", Region: "us-east-1"}) |
| + v := BuildArch(in) |
| + |
| + byLabel := map[string]BoxCard{} |
| + for _, b := range v.Boxes { |
| + byLabel[b.Label] = b |
| + } |
| + described, ok := byLabel["carlos-eu-west-1"] |
| + if !ok { |
| + t.Fatal("no card for the fleet-described box") |
| + } |
| + if !described.CountsKnown { |
| + t.Error("a box the fleet described must carry its counts") |
| + } |
| + if got := countsSubtitle(described); !strings.Contains(got, "static 0 · resident 0 · hibernating 0") { |
| + t.Errorf("countsSubtitle = %q, want the fleet's own counts", got) |
| + } |
| + |
| + undescribed, ok := byLabel["extra-box"] |
| + if !ok { |
| + t.Fatal("no card for the host-only box") |
| + } |
| + if undescribed.CountsKnown { |
| + t.Error("the fleet never described this box, so its counts are not known") |
| + } |
| + if got := countsSubtitle(undescribed); got != "counts unknown" { |
| + t.Errorf("countsSubtitle = %q, want %q — a read that never happened is not a zero", got, "counts unknown") |
| + } |
| + if n := nodeByID(v, "apps:extra-box"); n == nil { |
| + t.Fatal("no apps node for the host-only box") |
| + } else if strings.Contains(n.Subtitle, "static") { |
| + t.Errorf("the diagram states counts for a box the fleet never described: %q", n.Subtitle) |
| + } |
| +} |
| + |
| +// The same rule with the fleet read gone entirely: every card says unknown, |
| +// and no zero reaches the diagram. |
| +func TestArchCountsAreUnknownWithNoFleetRead(t *testing.T) { |
| + in := archFixture() |
| + in.Fleet = nil |
| + v := BuildArch(in) |
| + if len(v.Boxes) == 0 { |
| + t.Fatal("the host table alone must still produce cards") |
| + } |
| + for _, b := range v.Boxes { |
| + if b.CountsKnown { |
| + t.Errorf("%s: counts claimed with no fleet read at all", b.Label) |
| + } |
| + } |
| + for _, n := range v.Nodes { |
| + if n.Kind == "apps" && strings.Contains(n.Subtitle, "static") { |
| + t.Errorf("%s subtitle states counts nobody read: %q", n.ID, n.Subtitle) |
| + } |
| + } |
| +} |
| + |
| +func nodeByID(v ArchView, id string) *Node { |
| + for i := range v.Nodes { |
| + if v.Nodes[i].ID == id { |
| + return &v.Nodes[i] |
| + } |
| + } |
| + return nil |
| +} |
diff --git a/internal/console/archlayout.go b/internal/console/archlayout.go| index e12c1b503..860e441f4 100644 |
| --- a/internal/console/archlayout.go |
| +++ b/internal/console/archlayout.go |
| @@ -20,8 +20,17 @@ import ( |
| // "external" shares layer 3 with "router": nothing emits a router node yet, |
| // 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. |
| var archLayer = map[string]int{ |
| - "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3, |
| + "zone": 0, "record": 1, "ip": 2, "router": 3, "external": 3, "alarm": 3, |
| "box": 4, "machine": 4, "apps": 5, "bucket": 6, |
| } |
| @@ -31,13 +40,17 @@ const ( |
| gapX = 90 |
| gapY = 26 |
| padding = 24 |
| - satelliteKinds = "alarm identity firewall network disk certificate other" |
| + satelliteKinds = "identity firewall network disk certificate other" |
| ) |
| func layout(v *ArchView) { |
| - // Satellites (alarms, identities, firewalls…) are listed in the cards |
| - // 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. |
| + // 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. |
| kept := v.Nodes[:0] |
| for _, n := range v.Nodes { |
| if strings.Contains(satelliteKinds, n.Kind) { |
| @@ -68,13 +81,21 @@ func layout(v *ArchView) { |
| 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 { |
| + if forward && e.To == id && drawn[e.From] { |
| out = append(out, e.From) |
| } |
| - if !forward && e.From == id { |
| + if !forward && e.From == id && drawn[e.To] { |
| out = append(out, e.To) |
| } |
| } |
| @@ -181,7 +202,11 @@ func RenderArchSVG(v ArchView) template.HTML { |
| from, okF := pos[e.From] |
| to, okT := pos[e.To] |
| if !okF || !okT { |
| - continue // a satellite edge whose node the layout dropped |
| + // 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 |
diff --git a/internal/console/archlayout_test.go b/internal/console/archlayout_test.go| index bc9c5d895..1e2bde30d 100644 |
| --- a/internal/console/archlayout_test.go |
| +++ b/internal/console/archlayout_test.go |
| @@ -113,3 +113,93 @@ func TestSVGCarriesIntrinsicSize(t *testing.T) { |
| t.Error("two renders of the same input differ") |
| } |
| } |
| + |
| +// archAlarmFixture is the flagship's real shape for this: one auto-recover |
| +// alarm per box, plus one alarm naming a machine the estate read did not |
| +// return. |
| +func archAlarmFixture() ArchInput { |
| + in := archFixture() |
| + in.Inv.Resources = append(in.Inv.Resources, |
| + infraview.Resource{Kind: infraview.KindAlarm, ID: "a-1", Name: "auto-recover-eu-west-1", |
| + Attrs: map[string]string{"target": "i-123", "state": "OK", "metric": "StatusCheckFailed"}}, |
| + infraview.Resource{Kind: infraview.KindAlarm, ID: "a-2", Name: "auto-recover-mystery", |
| + Attrs: map[string]string{"target": "i-999", "state": "ALARM", "metric": "StatusCheckFailed"}}, |
| + ) |
| + 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) { |
| + 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 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]) |
| + } |
| + } |
| + for i := range v.Nodes { |
| + for j := i + 1; j < len(v.Nodes); j++ { |
| + a, b := v.Nodes[i], v.Nodes[j] |
| + if a.X < b.X+b.W && b.X < a.X+a.W && a.Y < b.Y+b.H && b.Y < a.Y+a.H { |
| + t.Fatalf("%s overlaps %s", a.ID, b.ID) |
| + } |
| + } |
| + } |
| + if RenderArchSVG(v) != RenderArchSVG(BuildArch(archAlarmFixture())) { |
| + t.Error("two builds of an alarm-bearing estate produced different SVG") |
| + } |
| +} |
| + |
| +// The ordering contract between layout and pruneDanglingEdges. LAYOUT is |
| +// what decides which nodes are drawn — it drops the satellite kinds the page |
| +// lists in tables instead of drawing — so the prune has to run after it, or |
| +// the shipped ArchView carries edges to nodes nobody draws and the invariant |
| +// rests entirely on RenderArchSVG's skip. Two halves: the two functions |
| +// compose in that order, and a built view is already clean when it ships. |
| +func TestPruneRunsAfterLayoutDropsSatellites(t *testing.T) { |
| + v := ArchView{ |
| + Nodes: []Node{ |
| + {ID: "box:a", Kind: "box", Title: "a"}, |
| + {ID: "identity:role", Kind: "identity", Title: "carlos-box-role"}, |
| + }, |
| + Edges: []Edge{{From: "box:a", To: "identity:role", Kind: "runs"}}, |
| + } |
| + layout(&v) |
| + pruneDanglingEdges(&v) |
| + if hasNode(v, "identity:role") { |
| + t.Fatal("an identity is table-only; layout must drop it") |
| + } |
| + if len(v.Edges) != 0 { |
| + t.Errorf("edges = %+v, want none — the only edge pointed at a node layout dropped", v.Edges) |
| + } |
| + |
| + // And on a real build carrying both drawn alarms and a partial estate: |
| + // pruning the shipped view again must change nothing, because BuildArch |
| + // already pruned AFTER laying out. |
| + in := archAlarmFixture() |
| + in.Inv.Resources = append(in.Inv.Resources, infraview.Resource{ |
| + Kind: infraview.KindAlarm, ID: "a-gone", |
| + Attrs: map[string]string{"target": "i-terminated", "state": "OK"}, |
| + }) |
| + built := BuildArch(in) |
| + before := len(built.Edges) |
| + pruneDanglingEdges(&built) |
| + if len(built.Edges) != before { |
| + t.Errorf("a shipped view still had %d dangling edges in it", before-len(built.Edges)) |
| + } |
| +} |
diff --git a/internal/console/archpage_test.go b/internal/console/archpage_test.go| index c11420353..00a4e0d0e 100644 |
| --- a/internal/console/archpage_test.go |
| +++ b/internal/console/archpage_test.go |
| @@ -378,6 +378,7 @@ func TestArchitectureBoxCardShowsEstateAndDrift(t *testing.T) { |
| "eu-west-1", // region |
| "t4g.medium", // discovered instance type |
| "running", // discovered state |
| + "2 cores", // rate-card cores (spec §7: cores/memory) |
| "4 GB", // rate-card memory |
| "40 GB disk", // discovered disk |
| "/mo", // rate-card price |
| @@ -399,3 +400,41 @@ func TestArchitectureBoxCardShowsEstateAndDrift(t *testing.T) { |
| t.Errorf("an explicitly priced box reported default-spec drift:\n%s", body) |
| } |
| } |
| + |
| +// The counts come from the published fleet aggregate and nowhere else. A box |
| +// whose own snapshot is on the bucket but which the last fleet merge did not |
| +// describe has NO counts — and "static 0 · resident 0 · hibernating 0" would |
| +// be three confident claims about a read that never happened, printed beside |
| +// an awake/asleep that correctly says it does not know. |
| +func TestArchitectureBoxWithoutAFleetRowSaysCountsUnknown(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Written, but never merged into status/fleet.json: the box exists in the |
| + // host table alone. |
| + if err := status.WriteBox(context.Background(), fx.rel, status.Box{ |
| + GeneratedAt: time.Now(), |
| + Label: "grace-unmerged", |
| + Region: "eu-west-1", |
| + Routes: status.RouteCounts{Static: 2, Resident: 1}, |
| + Metrics: &status.BoxMetrics{Release: "v9.9.9-test"}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200", st) |
| + } |
| + if !strings.Contains(body, "grace-unmerged") { |
| + t.Fatalf("the host-table box has no card:\n%s", body) |
| + } |
| + if !strings.Contains(body, "counts unknown") { |
| + t.Errorf("a box the fleet never described must say its counts are unknown:\n%s", body) |
| + } |
| + if strings.Contains(body, "static 0") || strings.Contains(body, "hibernating 0") { |
| + t.Errorf("the page states a count for a read that never happened:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html| index ab983677b..c7b0fa52f 100644 |
| --- a/internal/console/templates/architecture.html |
| +++ b/internal/console/templates/architecture.html |
| @@ -40,15 +40,21 @@ |
| <p class="small"> |
| {{if .InstanceType}}<span class="fact">{{.InstanceType}}</span>{{end}} |
| {{if .State}}<span class="fact">{{.State}}</span>{{end}} |
| + {{if .Cores}}<span class="fact">{{printf "%.0f" .Cores}} cores</span>{{end}} |
| {{if .MemGB}}<span class="fact">{{printf "%.0f" .MemGB}} GB</span>{{end}} |
| {{if .DiscoveredDiskGB}}<span class="fact">{{printf "%.0f" .DiscoveredDiskGB}} GB disk</span>{{end}} |
| {{if .USDMonth}}<span class="fact">${{printf "%.0f" .USDMonth}}/mo</span>{{end}} |
| {{if not .CloudID}}<span class="fact">no cloud machine (bring-your-own box)</span>{{end}} |
| </p> |
| <p class="small">Apps: {{if .Apps}}{{range .Apps}}<code>{{.}}</code> {{end}}{{else}}none reported{{end}}</p> |
| + {{/* The counts come from the fleet aggregate and nowhere else, so a box |
| + that read never described has NO counts — printing three zeroes here |
| + would state a read that never happened, beside an awake/asleep that |
| + correctly says it does not know. */}} |
| <p class="small"> |
| - static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} · |
| + {{if .CountsKnown}}static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} · |
| awake {{intOrDash .Awake}} · asleep {{intOrDash .Asleep}} |
| + {{else}}counts unknown{{end}} |
| {{with .Release}}· <code>{{.}}</code>{{end}} |
| </p> |
| {{if .Stale}}<p><span class="chip chip-stale">dark{{if .Dark}} {{.Dark}}{{end}}</span></p>{{end}} |