| 1 | // The architecture graph behind /fleet/architecture: one pure function that |
| 2 | // takes a cloud inventory plus everything CARLOS knows about itself and |
| 3 | // produces the page's whole view model — nodes, edges, box cards and |
| 4 | // resource groups. |
| 5 | // |
| 6 | // Three rules run through all of it (2026-08-19-fleet-architecture-diagram- |
| 7 | // design.md §6): |
| 8 | // |
| 9 | // - UNJOINED IS DRAWN, NEVER HIDDEN. A machine no box reports is drift, a |
| 10 | // box with no machine is a customer's own hardware, a DNS record pointing |
| 11 | // outside the estate is an arrow leaving the diagram, and a resource this |
| 12 | // vocabulary has no word for still reaches the resource groups with its |
| 13 | // native type. The resource nobody wrote code for is the one worth |
| 14 | // seeing, so nothing is silently dropped. |
| 15 | // - COST COMES FROM THE RATE CARD AND NOWHERE ELSE, so this page and |
| 16 | // /fleet/economics can never disagree about what a box costs. The |
| 17 | // discovered instance type and disk sit BESIDE the price and are used |
| 18 | // only to flag drift — never to compute a second one. |
| 19 | // - EVERY READ IS INDEPENDENT. A nil inventory, a nil fleet, an empty host |
| 20 | // table and an empty route list each cost their own section and nothing |
| 21 | // else. BuildArch never panics on any combination, including all-nil. |
| 22 | // |
| 23 | // BuildArch is pure — no I/O, no clock, no registry or bucket access. |
| 24 | // Everything it needs arrives in ArchInput, which is what makes it |
| 25 | // table-testable and what lets archlayout.go depend on it being |
| 26 | // deterministic. |
| 27 | package console |
| 28 | |
| 29 | import ( |
| 30 | "fmt" |
| 31 | "html/template" |
| 32 | "sort" |
| 33 | "strconv" |
| 34 | "strings" |
| 35 | "time" |
| 36 | |
| 37 | "github.com/carlosframework/platform/internal/economics" |
| 38 | "github.com/carlosframework/platform/internal/infraview" |
| 39 | "github.com/carlosframework/platform/internal/registry" |
| 40 | "github.com/carlosframework/platform/internal/status" |
| 41 | ) |
| 42 | |
| 43 | // ArchInput is every read the page makes, handed over as data. Each field is |
| 44 | // independently optional: whichever reads succeeded are drawn, and the ones |
| 45 | // that did not simply do not appear. |
| 46 | type ArchInput struct { |
| 47 | Inv *infraview.Inventory |
| 48 | Fleet *status.Fleet |
| 49 | Hosts []HostRow |
| 50 | Routes []registry.Route |
| 51 | Rates economics.RateCard |
| 52 | // Bucket is the deployment bucket the console itself is configured with |
| 53 | // — the ONE bucket relationship the console can state as fact (spec |
| 54 | // §6.2). Empty draws no bucket edge at all: the estate may hold six |
| 55 | // buckets and guessing which one the fleet writes to would put a false |
| 56 | // arrow on a page whose whole value is that its arrows are true. |
| 57 | Bucket string |
| 58 | } |
| 59 | |
| 60 | // KV is one detail line on a node. |
| 61 | type KV struct{ K, V string } |
| 62 | |
| 63 | // Node is one drawn thing. X/Y/W/H are filled by layout (archlayout.go), not |
| 64 | // here: BuildArch owns what the picture MEANS, layout owns where it sits. |
| 65 | type Node struct { |
| 66 | ID, Kind, Title, Subtitle string |
| 67 | Detail []KV |
| 68 | // Alarms are the alarms watching THIS node's subject. They ride the |
| 69 | // node rather than standing as nodes of their own, for two reasons an |
| 70 | // earlier draft learned the hard way: a satellite column placed |
| 71 | // between the IP and the box sits underneath every IP-to-box edge, and |
| 72 | // nodes paint after edges, so the line vanished behind it and the |
| 73 | // picture read as a request flowing THROUGH the alarm; and an alarm is |
| 74 | // not a hop a request takes, it is a property of the thing it watches. |
| 75 | // The Alarms resource table still lists every one of them in full. |
| 76 | Alarms []NodeAlarm |
| 77 | // Extra are further muted lines under the subtitle, and they are why a |
| 78 | // node's height is computed rather than constant. They exist so a fact |
| 79 | // that is a PROPERTY of a node can sit on it instead of standing in the |
| 80 | // flow as a node of its own: the apps a box runs used to be their own |
| 81 | // column, six of nine cards in it reading "no apps", and that column was |
| 82 | // a fifth of the width of a picture too wide for the page. |
| 83 | Extra []string |
| 84 | X, Y, W, H int |
| 85 | } |
| 86 | |
| 87 | // NodeAlarm is one alarm as its subject wears it: enough to say which |
| 88 | // alarm and whether it is firing, and nothing else — the table below the |
| 89 | // diagram carries the metric, the region and the ARN. |
| 90 | type NodeAlarm struct { |
| 91 | Name string |
| 92 | State string |
| 93 | Firing bool |
| 94 | } |
| 95 | |
| 96 | // Edge is one relationship the console can state as fact. Kind is the |
| 97 | // vocabulary of spec §6.1: resolves, routes, runs, stores, watches. |
| 98 | type Edge struct{ From, To, Kind string } |
| 99 | |
| 100 | // BoxCard is one box below the diagram: what CARLOS reports about it, what |
| 101 | // the estate says it is, what the rate card says it costs, and every way |
| 102 | // those three disagree. |
| 103 | type BoxCard struct { |
| 104 | Label, Region, InstanceType, State string |
| 105 | Cores, MemGB, EBSGB float64 |
| 106 | DiscoveredDiskGB float64 |
| 107 | USDMonth float64 |
| 108 | Apps []string |
| 109 | // Static/Resident/Hibernating come from the fleet aggregate and ONLY |
| 110 | // from it, so a box that read never described has no counts at all — |
| 111 | // not three zeroes. CountsKnown is that fact, and it gates every |
| 112 | // rendering of them: a zero here would be a claim about a read that |
| 113 | // never happened, two words from an Awake/Asleep that correctly says |
| 114 | // "unknown". |
| 115 | Static, Resident, Hibernating int |
| 116 | CountsKnown bool |
| 117 | Awake, Asleep *int |
| 118 | Release, Load string |
| 119 | Stale bool |
| 120 | Dark string |
| 121 | Drift []string |
| 122 | CloudID string // "" = no cloud machine behind this box |
| 123 | } |
| 124 | |
| 125 | // ResourceGroup is one table below the diagram. |
| 126 | type ResourceGroup struct { |
| 127 | Title string |
| 128 | Rows []ResourceRow |
| 129 | } |
| 130 | |
| 131 | // ResourceRow is one resource in one of those tables. |
| 132 | type ResourceRow struct{ Name, NativeType, Region, Detail string } |
| 133 | |
| 134 | // archGraph is a set of nodes and the edges between them — the shape every |
| 135 | // pass below builds into, before anything has been dealt into a bank or a |
| 136 | // band. It is unexported because it is a stage, not a thing the page shows. |
| 137 | type archGraph struct { |
| 138 | Nodes []Node |
| 139 | Edges []Edge |
| 140 | } |
| 141 | |
| 142 | // ArchBand is one region's picture: the nodes that reach a machine in that |
| 143 | // region, the edges between them, and its own size. It is a whole diagram |
| 144 | // on its own — laid out, sized and rendered without reference to any other |
| 145 | // band — which is what lets the page stack six of them down the screen |
| 146 | // instead of drawing one picture wider than the column. |
| 147 | // |
| 148 | // Region is the code the cloud uses ("eu-west-1") or "" for the band of |
| 149 | // things that reach no machine at all; Label is how that reads to a person. |
| 150 | type ArchBand struct { |
| 151 | archGraph |
| 152 | Region string |
| 153 | Label string |
| 154 | Width, Height int |
| 155 | } |
| 156 | |
| 157 | // SVG renders this band. A method rather than a field on the page struct |
| 158 | // because a band knows how to draw itself and the number of bands is not |
| 159 | // known until the estate is built — the template ranges over them. |
| 160 | func (b ArchBand) SVG() template.HTML { return RenderArchSVG(b) } |
| 161 | |
| 162 | // ArchEstate is one half of the page: its own bands, and its own tables. |
| 163 | // Nothing in this type knows which half it is — layout and RenderArchSVG |
| 164 | // take one BAND and have no opinion about the estate it came from, which is |
| 165 | // what makes running them per band free and each picture independently |
| 166 | // deterministic. |
| 167 | type ArchEstate struct { |
| 168 | Bands []ArchBand |
| 169 | Groups []ResourceGroup |
| 170 | } |
| 171 | |
| 172 | // Nodes and Edges are the estate whole again, across every band. A node |
| 173 | // drawn in two bands (the deployment bucket, which every region writes to) |
| 174 | // appears once per band here — the callers that count resources dedupe by |
| 175 | // id, exactly as they already do for a node drawn in both banks. |
| 176 | func (e ArchEstate) Nodes() []Node { |
| 177 | var out []Node |
| 178 | for _, b := range e.Bands { |
| 179 | out = append(out, b.Nodes...) |
| 180 | } |
| 181 | return out |
| 182 | } |
| 183 | |
| 184 | func (e ArchEstate) Edges() []Edge { |
| 185 | var out []Edge |
| 186 | for _, b := range e.Bands { |
| 187 | out = append(out, b.Edges...) |
| 188 | } |
| 189 | return out |
| 190 | } |
| 191 | |
| 192 | // ArchView is the whole page: two estates drawn one above the other, and |
| 193 | // the facts that belong to neither. |
| 194 | // |
| 195 | // Rest is deliberately NOT called "legacy" anywhere a person can read it. |
| 196 | // This console serves every CARLOS deployment, and what shares Tito's |
| 197 | // account is a Kubernetes estate while what shares somebody else's will be |
| 198 | // something else entirely. The page says "also in this account", which is |
| 199 | // the only thing that is true in all of them. |
| 200 | type ArchView struct { |
| 201 | CARLOS ArchEstate |
| 202 | Rest ArchEstate |
| 203 | Boxes []BoxCard |
| 204 | TakenAt time.Time |
| 205 | Account string |
| 206 | Partial []string |
| 207 | Missing string // "" | "never" | "ungranted" | "unreadable" |
| 208 | // Firing names every alarm currently in ALARM, with the node it watches, |
| 209 | // so the page can say so above the fold. |
| 210 | // |
| 211 | // The marks themselves ride their nodes, which is right — an alarm is a |
| 212 | // property of the thing it watches — but it made a firing alarm invisible |
| 213 | // on arrival: the marks are off to the right of a drawing wider than the |
| 214 | // window, and the Alarms table is thousands of pixels down (discussion |
| 215 | // #9). A page that knows something is on fire has to say so where the |
| 216 | // reader already is. |
| 217 | // |
| 218 | // It spans BOTH estates: the reader wants to know something is firing, |
| 219 | // not which half of the page to look in. |
| 220 | Firing []string |
| 221 | } |
| 222 | |
| 223 | // firingAlarms collects the ALARM-state marks across the drawn nodes, in the |
| 224 | // nodes' own order so the summary is as deterministic as the diagram. A node |
| 225 | // drawn in more than one band (ArchEstate.Nodes) is counted once: the banner |
| 226 | // names alarms, and an alarm does not fire twice for being drawn twice. |
| 227 | func firingAlarms(nodes []Node) []string { |
| 228 | var out []string |
| 229 | seen := map[string]bool{} |
| 230 | for _, n := range nodes { |
| 231 | if seen[n.ID] { |
| 232 | continue |
| 233 | } |
| 234 | seen[n.ID] = true |
| 235 | for _, a := range n.Alarms { |
| 236 | if a.Firing { |
| 237 | out = append(out, a.Name+" ("+n.Title+")") |
| 238 | } |
| 239 | } |
| 240 | } |
| 241 | return out |
| 242 | } |
| 243 | |
| 244 | // BuildArch joins the estate to the platform's own model of itself. It reads |
| 245 | // as the sequence of joins spec §6.2 names, in the order a request travels: |
| 246 | // boxes first (everything else needs to know which machine belongs to whom), |
| 247 | // then DNS in at the left, then the boxes and their apps, then the |
| 248 | // satellites. |
| 249 | func BuildArch(in ArchInput) ArchView { |
| 250 | var v ArchView |
| 251 | |
| 252 | if in.Inv == nil { |
| 253 | // The caller overwrites this with the reason it actually has |
| 254 | // ("ungranted", "unreadable"); "never" is the honest default for a |
| 255 | // builder handed nothing and told nothing. |
| 256 | v.Missing = "never" |
| 257 | } else { |
| 258 | v.TakenAt = in.Inv.TakenAt |
| 259 | v.Account = in.Inv.Account |
| 260 | v.Partial = in.Inv.Partial |
| 261 | } |
| 262 | |
| 263 | v.Boxes = buildBoxCards(in) |
| 264 | |
| 265 | // machineID -> box label, the join every later step asks about. Built |
| 266 | // once here so "does a box report this machine?" is one lookup and |
| 267 | // cannot drift between the DNS pass and the alarm pass. |
| 268 | claimed := map[string]string{} |
| 269 | for _, b := range v.Boxes { |
| 270 | if b.CloudID != "" { |
| 271 | claimed[b.CloudID] = b.Label |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | // The whole graph is built once, exactly as it always was, and only then |
| 276 | // dealt into two banks. Building it whole is what keeps the joins in one |
| 277 | // place: a record still finds its IP, and an alarm still finds its box, |
| 278 | // without either pass having to know which half of the account it is |
| 279 | // looking at. |
| 280 | var whole archGraph |
| 281 | addDNSNodes(&whole, in.Inv, claimed) |
| 282 | addBoxNodes(&whole, v.Boxes, in) |
| 283 | addMachineNodes(&whole, in.Inv, claimed) |
| 284 | addAlarmBadges(&whole, in.Inv, claimed) |
| 285 | |
| 286 | carlos := carlosResources(in.Inv, claimed, in.Bucket) |
| 287 | bank := splitNodes(whole.Nodes, whole.Edges, carlos) |
| 288 | var forBank [2]archGraph |
| 289 | for _, n := range whole.Nodes { |
| 290 | if bank[n.ID]&estateCARLOS != 0 { |
| 291 | forBank[0].Nodes = append(forBank[0].Nodes, n) |
| 292 | } |
| 293 | if bank[n.ID]&estateRest != 0 { |
| 294 | forBank[1].Nodes = append(forBank[1].Nodes, n) |
| 295 | } |
| 296 | } |
| 297 | // Both banks get every edge and each then prunes what it does not draw. |
| 298 | // An edge whose ends are in different banks therefore survives in |
| 299 | // neither — which is why archestate_test.go asserts no such edge exists, |
| 300 | // rather than this loop quietly deciding it does not matter. |
| 301 | forBank[0].Edges = append([]Edge(nil), whole.Edges...) |
| 302 | forBank[1].Edges = append([]Edge(nil), whole.Edges...) |
| 303 | |
| 304 | v.CARLOS.Groups, v.Rest.Groups = splitResourceGroups(in.Inv, carlos) |
| 305 | |
| 306 | seeds := regionSeeds(in.Inv, v.Boxes) |
| 307 | for i, e := range []*ArchEstate{&v.CARLOS, &v.Rest} { |
| 308 | e.Bands = bandsFrom(forBank[i], seeds) |
| 309 | } |
| 310 | |
| 311 | // After layout, for the same reason the pruning is: layout is what makes |
| 312 | // the node set final, and a summary naming an alarm on a node the diagram |
| 313 | // does not draw would send a reader looking for a mark that is not there. |
| 314 | // |
| 315 | // Both estates, CARLOS first, and each estate's bands in page order |
| 316 | // (ArchEstate.Nodes walks them so), so the order is the order the page |
| 317 | // draws them in — a firing alarm should be findable by reading downwards. |
| 318 | v.Firing = append(firingAlarms(v.CARLOS.Nodes()), firingAlarms(v.Rest.Nodes())...) |
| 319 | return v |
| 320 | } |
| 321 | |
| 322 | // bandsFrom deals one bank's graph into its region bands and lays each one |
| 323 | // out. The ORDER of the three steps is the whole of it: |
| 324 | // |
| 325 | // 1. drop the satellite kinds, because banding must not propagate a region |
| 326 | // along an edge to a node that is not going to be drawn — a zone is |
| 327 | // global and joined to records in every region, and letting it carry |
| 328 | // bands would put every record in every band; |
| 329 | // 2. band what is left, which is what makes an edge unable to span two |
| 330 | // bands; |
| 331 | // 3. lay each band out and only THEN prune its dangling edges, because |
| 332 | // layout is what makes a band's node set final. |
| 333 | func bandsFrom(g archGraph, seeds map[string]string) []ArchBand { |
| 334 | g.Nodes = dropSatellites(g.Nodes) |
| 335 | in := bandsOf(g.Nodes, g.Edges, seeds) |
| 336 | |
| 337 | present := map[string]bool{} |
| 338 | for _, n := range g.Nodes { |
| 339 | if len(in[n.ID]) == 0 { |
| 340 | present[noRegion] = true |
| 341 | continue |
| 342 | } |
| 343 | for r := range in[n.ID] { |
| 344 | present[r] = true |
| 345 | } |
| 346 | } |
| 347 | // An estate with nothing in it still gets one empty band, so the page |
| 348 | // has a picture to draw rather than a gap where one should be — the |
| 349 | // same reason layout clamps an empty graph to a positive viewBox. |
| 350 | if len(present) == 0 { |
| 351 | present[noRegion] = true |
| 352 | } |
| 353 | |
| 354 | var bands []ArchBand |
| 355 | for _, region := range bandOrder(present) { |
| 356 | b := ArchBand{Region: region, Label: bandLabel(region)} |
| 357 | for _, n := range g.Nodes { |
| 358 | set := in[n.ID] |
| 359 | if (len(set) == 0 && region == noRegion) || set[region] { |
| 360 | b.Nodes = append(b.Nodes, n) |
| 361 | } |
| 362 | } |
| 363 | b.Edges = append([]Edge(nil), g.Edges...) |
| 364 | layout(&b) |
| 365 | pruneDanglingEdges(&b) |
| 366 | bands = append(bands, b) |
| 367 | } |
| 368 | // One width for every band in an estate, so the columns line up down the |
| 369 | // page: the box column is in the same place in eu-west-1 as in |
| 370 | // us-east-1, and a reader's eye can travel down it. Layout sizes each |
| 371 | // band to its own deepest column, which left a band of two columns half |
| 372 | // the width of one with four and the stack looking ragged. Node |
| 373 | // positions do not move — X is the column index, not a fraction of the |
| 374 | // width — so this only ever widens the viewBox. |
| 375 | widest := 0 |
| 376 | for _, b := range bands { |
| 377 | if b.Width > widest { |
| 378 | widest = b.Width |
| 379 | } |
| 380 | } |
| 381 | for i := range bands { |
| 382 | bands[i].Width = widest |
| 383 | } |
| 384 | return bands |
| 385 | } |
| 386 | |
| 387 | // dropSatellites removes the kinds the page lists in a table instead of |
| 388 | // drawing. Identities, firewalls, networks, disks and certificates because |
| 389 | // a graph that draws every IAM role is a hairball and the resource groups |
| 390 | // carry them in full; ZONES because a zone is global — Route 53 hands one |
| 391 | // back with no region and records in six of them — so a drawn zone is |
| 392 | // either in no band, cutting every record loose from its name, or in all of |
| 393 | // them, which is six copies of one node whose only edge is to a record |
| 394 | // already labelled with the zone's own name. The DNS table below still |
| 395 | // lists every zone. |
| 396 | // |
| 397 | // Alarms are not in this list because they are no longer nodes at all — |
| 398 | // they ride the node they watch (see archLayer). |
| 399 | func dropSatellites(nodes []Node) []Node { |
| 400 | kept := nodes[:0] |
| 401 | for _, n := range nodes { |
| 402 | if strings.Contains(satelliteKinds, n.Kind) { |
| 403 | continue |
| 404 | } |
| 405 | kept = append(kept, n) |
| 406 | } |
| 407 | return kept |
| 408 | } |
| 409 | |
| 410 | // pruneDanglingEdges drops any edge whose endpoints this view does not |
| 411 | // draw. One rule in one place, applied last — after layout, which is what |
| 412 | // decides which nodes are drawn — so every pass above can join on whatever |
| 413 | // it reads without each of them checking. It matters because a |
| 414 | // PARTIAL inventory is normal (infraview.Inventory.Partial): an EIP whose |
| 415 | // instance read failed, or an alarm naming a machine that has since been |
| 416 | // terminated, would otherwise leave an arrow pointing at nothing — and an |
| 417 | // arrow pointing at nothing is exactly the kind of claim this page exists |
| 418 | // to avoid making. |
| 419 | func pruneDanglingEdges(v *ArchBand) { |
| 420 | drawn := make(map[string]bool, len(v.Nodes)) |
| 421 | for _, n := range v.Nodes { |
| 422 | drawn[n.ID] = true |
| 423 | } |
| 424 | kept := v.Edges[:0] |
| 425 | for _, e := range v.Edges { |
| 426 | if drawn[e.From] && drawn[e.To] { |
| 427 | kept = append(kept, e) |
| 428 | } |
| 429 | } |
| 430 | v.Edges = kept |
| 431 | } |
| 432 | |
| 433 | // ---- the joins --------------------------------------------------------- |
| 434 | |
| 435 | // machineFor finds the cloud machine a box runs on. |
| 436 | // |
| 437 | // The box's OWN report wins: status.BoxMetrics.InstanceID is the id it read |
| 438 | // from the instance metadata service, and nothing else can be as sure. When |
| 439 | // a box reports one, that id is the answer — a hit joins, and a MISS stays a |
| 440 | // miss rather than falling through to a name match, because a box that has |
| 441 | // told us which machine it is must not be quietly attached to a different |
| 442 | // one. (A miss is real: an instance terminated between the box's last |
| 443 | // snapshot and this inventory.) |
| 444 | // |
| 445 | // The Name-tag match is the fallback, and only for a box that reports no id |
| 446 | // at all — an agent that predates the field, which during a roll is every |
| 447 | // box until it restarts. It is a fallback rather than the rule because it |
| 448 | // is a naming convention, and on the flagship that convention never held: |
| 449 | // the instances are tagged carlos-flagship-ie while the boxes call |
| 450 | // themselves annie-welding, so this function matched nothing at all and |
| 451 | // every box on the page read as bring-your-own hardware. |
| 452 | func machineFor(inv *infraview.Inventory, label string, reported string) *infraview.Resource { |
| 453 | if inv == nil { |
| 454 | return nil |
| 455 | } |
| 456 | if reported != "" { |
| 457 | for i := range inv.Resources { |
| 458 | r := &inv.Resources[i] |
| 459 | if r.Kind == infraview.KindMachine && r.ID == reported { |
| 460 | return r |
| 461 | } |
| 462 | } |
| 463 | return nil |
| 464 | } |
| 465 | if label == "" { |
| 466 | return nil |
| 467 | } |
| 468 | for i := range inv.Resources { |
| 469 | r := &inv.Resources[i] |
| 470 | if r.Kind == infraview.KindMachine && strings.EqualFold(r.Name, label) { |
| 471 | return r |
| 472 | } |
| 473 | } |
| 474 | return nil |
| 475 | } |
| 476 | |
| 477 | // diskGBFor sums every disk attached to one machine. A size that will not |
| 478 | // parse is absence, not zero — it simply does not contribute, and the card |
| 479 | // then makes no disk claim rather than a false one. |
| 480 | func diskGBFor(inv *infraview.Inventory, machineID string) float64 { |
| 481 | if inv == nil || machineID == "" { |
| 482 | return 0 |
| 483 | } |
| 484 | var total float64 |
| 485 | for _, d := range inv.ByKind(infraview.KindDisk) { |
| 486 | if d.Attr("attached_to") != machineID { |
| 487 | continue |
| 488 | } |
| 489 | if gb, err := strconv.ParseFloat(d.Attr("size_gb"), 64); err == nil { |
| 490 | total += gb |
| 491 | } |
| 492 | } |
| 493 | return total |
| 494 | } |
| 495 | |
| 496 | // appsFor names the apps a box is running: its own RouteVersions keys are |
| 497 | // its hostnames (the one place a box's real hostnames are carried), looked |
| 498 | // up in the registry for the app they belong to. Distinct and sorted, so two |
| 499 | // routes of one app read as one app and the order never moves. |
| 500 | func appsFor(host HostRow, routes []registry.Route) []string { |
| 501 | if host.Metrics == nil || len(host.Metrics.RouteVersions) == 0 { |
| 502 | return nil |
| 503 | } |
| 504 | byHost := make(map[string]string, len(routes)) |
| 505 | for _, r := range routes { |
| 506 | byHost[r.Host] = r.App |
| 507 | } |
| 508 | seen := map[string]bool{} |
| 509 | var out []string |
| 510 | for h := range host.Metrics.RouteVersions { |
| 511 | app := byHost[h] |
| 512 | if app == "" || seen[app] { |
| 513 | continue |
| 514 | } |
| 515 | seen[app] = true |
| 516 | out = append(out, app) |
| 517 | } |
| 518 | sort.Strings(out) |
| 519 | return out |
| 520 | } |
| 521 | |
| 522 | // boxCost prices one box the way /fleet/economics prices it and no other |
| 523 | // way: the rate card's own spec for that label, falling back to the "" |
| 524 | // default exactly as economics.AllocateBox does. explicit is false when the |
| 525 | // fallback was used, which is itself worth telling the operator — a box |
| 526 | // priced by the default spec is priced by a guess. |
| 527 | func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) { |
| 528 | spec, explicit = rc.Boxes[label] |
| 529 | if !explicit { |
| 530 | spec = rc.Boxes[""] |
| 531 | } |
| 532 | usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD |
| 533 | return usd, spec, explicit |
| 534 | } |
| 535 | |
| 536 | // driftFor names every way the rate card and the estate disagree about this |
| 537 | // box. Two checks only — the two this page can make honestly. Comparing the |
| 538 | // instance TYPE against the card's cores/memory would need a type->shape |
| 539 | // table, which is a second source of truth and out of scope (spec §10). |
| 540 | func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string { |
| 541 | var out []string |
| 542 | if !explicit { |
| 543 | out = append(out, "priced by the default spec — no rate-card entry for this box") |
| 544 | } |
| 545 | if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 { |
| 546 | if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 { |
| 547 | out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB", |
| 548 | spec.EBSGB, card.DiscoveredDiskGB)) |
| 549 | } |
| 550 | } |
| 551 | return out |
| 552 | } |
| 553 | |
| 554 | // ---- box cards --------------------------------------------------------- |
| 555 | |
| 556 | // buildBoxCards is the union of the two reads that know about boxes: the |
| 557 | // published fleet (regions, load, route counts, staleness) and the private |
| 558 | // host table (release, hostnames, dark time). Either one alone still |
| 559 | // produces a full set of cards, because either one alone is a legitimate |
| 560 | // state of the page — the fleet read and the host read fail separately. |
| 561 | func buildBoxCards(in ArchInput) []BoxCard { |
| 562 | fleetBoxes := map[string]status.FleetBox{} |
| 563 | var labels []string |
| 564 | seen := map[string]bool{} |
| 565 | add := func(label string) { |
| 566 | if label == "" || seen[label] { |
| 567 | return |
| 568 | } |
| 569 | seen[label] = true |
| 570 | labels = append(labels, label) |
| 571 | } |
| 572 | if in.Fleet != nil { |
| 573 | for _, b := range in.Fleet.Boxes { |
| 574 | fleetBoxes[b.Label] = b |
| 575 | add(b.Label) |
| 576 | } |
| 577 | } |
| 578 | hosts := map[string]HostRow{} |
| 579 | for _, h := range in.Hosts { |
| 580 | hosts[h.Label] = h |
| 581 | add(h.Label) |
| 582 | } |
| 583 | sort.Strings(labels) |
| 584 | |
| 585 | cards := make([]BoxCard, 0, len(labels)) |
| 586 | for _, label := range labels { |
| 587 | // described is whether the FLEET read produced a row for this box. |
| 588 | // A box the host table alone knows about — and every box when the |
| 589 | // fleet read failed — has no counts, and fb's zero value must not be |
| 590 | // mistaken for a fleet that counted zero of everything. |
| 591 | fb, described := fleetBoxes[label] |
| 592 | host := hosts[label] |
| 593 | |
| 594 | card := BoxCard{ |
| 595 | Label: label, |
| 596 | Region: firstNonEmpty(fb.Region, host.Region), |
| 597 | Static: fb.Routes.Static, |
| 598 | Resident: fb.Routes.Resident, |
| 599 | Hibernating: fb.Routes.Hibernating, |
| 600 | CountsKnown: described, |
| 601 | Awake: fb.Routes.Awake, |
| 602 | Asleep: fb.Routes.Asleep, |
| 603 | Stale: fb.Stale || host.Stale, |
| 604 | Dark: host.Dark, |
| 605 | Apps: appsFor(host, in.Routes), |
| 606 | } |
| 607 | if fb.Load != nil { |
| 608 | card.Load = *fb.Load |
| 609 | } |
| 610 | // Metrics is nil for a stale box by design (see HostRow) — an |
| 611 | // unreachable host's last happy numbers must never reach the page, |
| 612 | // and reading through the pointer is where that promise is kept. |
| 613 | // reportedID is the machine this box says it is. It comes from the |
| 614 | // same Metrics block as Release, so a STALE box reports neither — |
| 615 | // which is correct: a dark box's claim about which machine it was is |
| 616 | // exactly as old as its other readings, and the page falls back to |
| 617 | // the name match rather than trusting history. |
| 618 | var reportedID string |
| 619 | if host.Metrics != nil { |
| 620 | card.Release = host.Metrics.Release |
| 621 | reportedID = host.Metrics.InstanceID |
| 622 | } |
| 623 | if m := machineFor(in.Inv, label, reportedID); m != nil { |
| 624 | card.CloudID = m.ID |
| 625 | card.InstanceType = m.Attr("type") |
| 626 | card.State = m.Attr("state") |
| 627 | card.Region = firstNonEmpty(card.Region, m.Region) |
| 628 | card.DiscoveredDiskGB = diskGBFor(in.Inv, m.ID) |
| 629 | } |
| 630 | |
| 631 | // A box with no cloud machine behind it is somebody else's |
| 632 | // hardware — a customer's fleet box, the Pi on a desk. The rate |
| 633 | // card prices what THIS platform pays for, so putting a monthly |
| 634 | // figure on one of those is a claim about a bill we do not pay, |
| 635 | // and "priced by the default spec" is a warning about a pricing |
| 636 | // that should not be happening at all. Both are withheld; the |
| 637 | // card still says plainly that there is no cloud machine. |
| 638 | usd, spec, explicit := boxCost(in.Rates, label) |
| 639 | if card.CloudID != "" { |
| 640 | card.USDMonth = usd |
| 641 | card.Cores, card.MemGB, card.EBSGB = spec.Cores, spec.MemGB, spec.EBSGB |
| 642 | card.Drift = driftFor(card, spec, explicit) |
| 643 | } |
| 644 | cards = append(cards, card) |
| 645 | } |
| 646 | return cards |
| 647 | } |
| 648 | |
| 649 | // ---- nodes and edges --------------------------------------------------- |
| 650 | |
| 651 | // addDNSNodes draws the left of the picture: zones, their records, and where |
| 652 | // each record actually points. A record whose value is an address the estate |
| 653 | // does not own gets an external node — an arrow leaving the diagram, per |
| 654 | // spec §6.3 — rather than being dropped, because "this name points somewhere |
| 655 | // we do not run" is exactly the thing an operator opens this page to see. |
| 656 | func addDNSNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) { |
| 657 | if inv == nil { |
| 658 | return |
| 659 | } |
| 660 | zones := inv.ByKind(infraview.KindDNSZone) |
| 661 | for _, z := range zones { |
| 662 | sub := "" |
| 663 | if n := z.Attr("record_count"); n != "" { |
| 664 | sub = n + " records" |
| 665 | } |
| 666 | v.Nodes = append(v.Nodes, Node{ |
| 667 | ID: "zone:" + z.ID, Kind: "zone", Title: nameOf(z), Subtitle: sub, |
| 668 | }) |
| 669 | } |
| 670 | for _, ip := range inv.ByKind(infraview.KindIP) { |
| 671 | id := "ip:" + ip.ID |
| 672 | v.Nodes = append(v.Nodes, Node{ |
| 673 | ID: id, Kind: "ip", Title: firstNonEmpty(ip.Attr("address"), nameOf(ip)), |
| 674 | Subtitle: ipSubtitle(ip, claimed), |
| 675 | }) |
| 676 | if to := targetOf(ip.Attr("attached_to"), claimed); to != "" { |
| 677 | v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"}) |
| 678 | } |
| 679 | } |
| 680 | addRecordNodes(v, inv, zones, claimed) |
| 681 | } |
| 682 | |
| 683 | // recordGroup collects every DNS record that shares a zone and a resolved |
| 684 | // destination. The flagship carries one A record per app host, all pointing |
| 685 | // at the same Elastic IP — without grouping that layer is dozens of nodes |
| 686 | // wide and unreadable. Grouping happens only in the diagram: every record |
| 687 | // still appears individually, in full, in the DNS resource group below |
| 688 | // (resourceGroups walks the inventory directly and never sees a group). |
| 689 | type recordGroup struct { |
| 690 | ids []string // record ids, sorted; ids[0] names the drawn node |
| 691 | names []string // record display names, sorted, for the example list |
| 692 | zone string // the raw "zone" attr this group's records share |
| 693 | typ string // record type, meaningful only when len(names) == 1 |
| 694 | value string // record value, meaningful only when len(names) == 1 |
| 695 | target string // edge destination: "", "ip:…", "box:…"/"machine:…", "external:…" |
| 696 | } |
| 697 | |
| 698 | // addRecordNodes groups the estate's DNS records by (zone, resolved |
| 699 | // destination) and draws one node per group, sorted by each group's lowest |
| 700 | // record id — never by map iteration, which Go randomises on every run and |
| 701 | // this diagram must not. |
| 702 | func addRecordNodes(v *archGraph, inv *infraview.Inventory, zones []infraview.Resource, claimed map[string]string) { |
| 703 | groups := map[string]*recordGroup{} |
| 704 | for _, rec := range inv.ByKind(infraview.KindDNSRecord) { |
| 705 | zoneName := rec.Attr("zone") |
| 706 | value := rec.Attr("value") |
| 707 | target := recordTarget(inv, value, claimed) |
| 708 | key := zoneName + "\x00" + target |
| 709 | g := groups[key] |
| 710 | if g == nil { |
| 711 | g = &recordGroup{zone: zoneName, target: target} |
| 712 | groups[key] = g |
| 713 | } |
| 714 | g.ids = append(g.ids, rec.ID) |
| 715 | g.names = append(g.names, nameOf(rec)) |
| 716 | g.typ, g.value = rec.Attr("type"), value |
| 717 | } |
| 718 | keys := make([]string, 0, len(groups)) |
| 719 | for k, g := range groups { |
| 720 | sort.Strings(g.ids) |
| 721 | sort.Strings(g.names) |
| 722 | keys = append(keys, k) |
| 723 | } |
| 724 | sort.Slice(keys, func(a, b int) bool { |
| 725 | return groups[keys[a]].ids[0] < groups[keys[b]].ids[0] |
| 726 | }) |
| 727 | |
| 728 | external := map[string]bool{} |
| 729 | for _, k := range keys { |
| 730 | g := groups[k] |
| 731 | id := "record:" + g.ids[0] |
| 732 | title, sub := recordNodeText(g) |
| 733 | v.Nodes = append(v.Nodes, Node{ID: id, Kind: "record", Title: title, Subtitle: sub}) |
| 734 | for _, z := range zones { |
| 735 | if z.Name != "" && g.zone == z.Name { |
| 736 | v.Edges = append(v.Edges, Edge{From: "zone:" + z.ID, To: id, Kind: "resolves"}) |
| 737 | } |
| 738 | } |
| 739 | switch { |
| 740 | case g.target == "": |
| 741 | // An absent value is absence. Nothing is claimed about it. |
| 742 | case strings.HasPrefix(g.target, "external:"): |
| 743 | val := strings.TrimPrefix(g.target, "external:") |
| 744 | if !external[val] { |
| 745 | external[val] = true |
| 746 | v.Nodes = append(v.Nodes, Node{ |
| 747 | ID: g.target, Kind: "external", Title: val, |
| 748 | Subtitle: "outside this estate", |
| 749 | }) |
| 750 | } |
| 751 | v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"}) |
| 752 | default: |
| 753 | v.Edges = append(v.Edges, Edge{From: id, To: g.target, Kind: "resolves"}) |
| 754 | } |
| 755 | } |
| 756 | } |
| 757 | |
| 758 | // recordTarget is where a DNS record's value actually points: an allocated |
| 759 | // IP, a machine holding that address directly, or outside the estate |
| 760 | // entirely. Pure — it only reads the inventory — so the grouping pass above |
| 761 | // can call it once per record before deciding which records share a |
| 762 | // destination. Find refuses to match on an empty value, so an absent value |
| 763 | // falls straight through to the "" case. |
| 764 | func recordTarget(inv *infraview.Inventory, value string, claimed map[string]string) string { |
| 765 | if value == "" { |
| 766 | return "" |
| 767 | } |
| 768 | if ip := inv.Find(infraview.KindIP, "address", value); ip != nil { |
| 769 | return "ip:" + ip.ID |
| 770 | } |
| 771 | if m := inv.Find(infraview.KindMachine, "public_ip", value); m != nil { |
| 772 | // An address the estate owns but holds directly on a machine rather |
| 773 | // than through an allocated IP. |
| 774 | return targetOf(m.ID, claimed) |
| 775 | } |
| 776 | return "external:" + value |
| 777 | } |
| 778 | |
| 779 | // recordNodeText is the node's label. A single record keeps exactly the |
| 780 | // shape it had before grouping existed: its own name, and its type and |
| 781 | // value as the subtitle. A group of more than one names the count and up to |
| 782 | // three example names, sorted, with the remainder folded into a "+N". |
| 783 | func recordNodeText(g *recordGroup) (title, subtitle string) { |
| 784 | if len(g.names) == 1 { |
| 785 | return g.names[0], strings.TrimSpace(g.typ + " " + g.value) |
| 786 | } |
| 787 | examples := g.names |
| 788 | if len(examples) > 3 { |
| 789 | examples = examples[:3] |
| 790 | } |
| 791 | sub := strings.Join(examples, ", ") |
| 792 | if rest := len(g.names) - len(examples); rest > 0 { |
| 793 | sub += fmt.Sprintf(", +%d", rest) |
| 794 | } |
| 795 | return fmt.Sprintf("%d records", len(g.names)), sub |
| 796 | } |
| 797 | |
| 798 | // addBoxNodes draws every box CARLOS reports — with or without a machine |
| 799 | // behind it — carrying its apps and instance counts on the card itself, and |
| 800 | // the deployment-bucket edge when the caller named a bucket. |
| 801 | // |
| 802 | // The counts are one line per box, never one per instance: a fleet of a |
| 803 | // hundred tenants must still produce a diagram a person can read (spec |
| 804 | // §6.4). |
| 805 | func addBoxNodes(v *archGraph, boxes []BoxCard, in ArchInput) { |
| 806 | for _, b := range boxes { |
| 807 | boxID := "box:" + b.Label |
| 808 | // The apps and the counts ride the box (Node.Extra) rather than |
| 809 | // standing as an "apps" node beside it. They were never a hop a |
| 810 | // request takes — they are what this box IS — and as a column they |
| 811 | // cost the picture 280 units of width to tell six boxes out of nine |
| 812 | // that they run nothing. |
| 813 | v.Nodes = append(v.Nodes, Node{ |
| 814 | ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b), |
| 815 | Extra: []string{appsTitle(b), countsSubtitle(b)}, |
| 816 | }) |
| 817 | if in.Bucket != "" { |
| 818 | v.Edges = append(v.Edges, Edge{From: boxID, To: "bucket:" + in.Bucket, Kind: "stores"}) |
| 819 | } |
| 820 | } |
| 821 | if in.Bucket == "" { |
| 822 | return |
| 823 | } |
| 824 | // The bucket node exists whether or not the estate read found it: the |
| 825 | // console knows this bucket first-hand, and an un-granted S3 read must |
| 826 | // not erase a relationship the console is certain of. |
| 827 | id := "bucket:" + in.Bucket |
| 828 | sub := "deployment bucket" |
| 829 | if in.Inv != nil { |
| 830 | for _, b := range in.Inv.ByKind(infraview.KindBucket) { |
| 831 | if nameOf(b) == in.Bucket && b.Region != "" { |
| 832 | sub = "deployment bucket · " + b.Region |
| 833 | } |
| 834 | } |
| 835 | } |
| 836 | v.Nodes = append(v.Nodes, Node{ID: id, Kind: "bucket", Title: in.Bucket, Subtitle: sub}) |
| 837 | } |
| 838 | |
| 839 | // addMachineNodes draws the machines NO box reports. That is the whole |
| 840 | // point: an instance nobody is running CARLOS on, or a box that has gone |
| 841 | // dark, is drift — and drift is only visible if it is drawn. A machine a box |
| 842 | // does report is already on the page as that box, and drawing it twice would |
| 843 | // say the estate holds two things where it holds one. |
| 844 | func addMachineNodes(v *archGraph, inv *infraview.Inventory, claimed map[string]string) { |
| 845 | if inv == nil { |
| 846 | return |
| 847 | } |
| 848 | for _, m := range inv.ByKind(infraview.KindMachine) { |
| 849 | if claimed[m.ID] != "" { |
| 850 | continue |
| 851 | } |
| 852 | sub := "no box reporting" |
| 853 | if t := m.Attr("type"); t != "" { |
| 854 | sub = "no box reporting · " + t |
| 855 | } |
| 856 | v.Nodes = append(v.Nodes, Node{ |
| 857 | ID: "machine:" + m.ID, Kind: "machine", |
| 858 | Title: firstNonEmpty(m.Name, m.ID), Subtitle: sub, |
| 859 | }) |
| 860 | } |
| 861 | } |
| 862 | |
| 863 | // addAlarmBadges hangs each alarm on whatever it watches — the box when a |
| 864 | // box reports that machine, the bare machine otherwise. An alarm naming |
| 865 | // nothing this page draws is still listed in the resource groups, and one |
| 866 | // naming something the page DOES draw becomes a mark on that node rather |
| 867 | // than a node in the flow (see Node.Alarms for why). |
| 868 | func addAlarmBadges(v *archGraph, inv *infraview.Inventory, claimed map[string]string) { |
| 869 | if inv == nil { |
| 870 | return |
| 871 | } |
| 872 | at := map[string]int{} |
| 873 | for i := range v.Nodes { |
| 874 | at[v.Nodes[i].ID] = i |
| 875 | } |
| 876 | for _, a := range inv.ByKind(infraview.KindAlarm) { |
| 877 | to := targetOf(a.Attr("target"), claimed) |
| 878 | if to == "" { |
| 879 | continue |
| 880 | } |
| 881 | i, drawn := at[to] |
| 882 | if !drawn { |
| 883 | continue |
| 884 | } |
| 885 | state := a.Attr("state") |
| 886 | v.Nodes[i].Alarms = append(v.Nodes[i].Alarms, NodeAlarm{ |
| 887 | Name: a.Name, |
| 888 | State: state, |
| 889 | Firing: state == "ALARM", |
| 890 | }) |
| 891 | } |
| 892 | } |
| 893 | |
| 894 | // ---- resource groups --------------------------------------------------- |
| 895 | |
| 896 | // archGroupOrder is the order the tables appear in, and archGroupOf maps a |
| 897 | // kind onto one of them. A kind neither map knows lands in Other rather than |
| 898 | // being dropped: infraview carries an unrecognised kind through verbatim |
| 899 | // precisely so the page can show it. |
| 900 | var archGroupOrder = []string{"Compute", "Network", "Storage", "DNS", "Identity", "Alarms", "Certificates", "Other"} |
| 901 | |
| 902 | var archGroupOf = map[string]string{ |
| 903 | infraview.KindMachine: "Compute", |
| 904 | infraview.KindDisk: "Compute", |
| 905 | infraview.KindIP: "Network", |
| 906 | infraview.KindFirewall: "Network", |
| 907 | infraview.KindNetwork: "Network", |
| 908 | infraview.KindBucket: "Storage", |
| 909 | infraview.KindDNSZone: "DNS", |
| 910 | infraview.KindDNSRecord: "DNS", |
| 911 | infraview.KindIdentity: "Identity", |
| 912 | infraview.KindAlarm: "Alarms", |
| 913 | infraview.KindCertificate: "Certificates", |
| 914 | infraview.KindOther: "Other", |
| 915 | } |
| 916 | |
| 917 | // splitResourceGroups deals the inventory's tables into the two banks. The |
| 918 | // exactly-once promise below is now a promise across BOTH banks together: |
| 919 | // every resource is in one of them, never in neither and never in both, and |
| 920 | // archestate_test.go counts it. |
| 921 | func splitResourceGroups(inv *infraview.Inventory, carlos map[string]bool) (inEstate, rest []ResourceGroup) { |
| 922 | if inv == nil { |
| 923 | return nil, nil |
| 924 | } |
| 925 | var mine, theirs []infraview.Resource |
| 926 | for _, r := range inv.Resources { |
| 927 | if carlos[r.ID] { |
| 928 | mine = append(mine, r) |
| 929 | } else { |
| 930 | theirs = append(theirs, r) |
| 931 | } |
| 932 | } |
| 933 | return resourceGroups(mine), resourceGroups(theirs) |
| 934 | } |
| 935 | |
| 936 | // resourceGroups turns a set of resources into the tables below one |
| 937 | // diagram. EVERY resource appears exactly once, in inventory order, and an |
| 938 | // empty group is omitted rather than rendered as a confident "none". |
| 939 | func resourceGroups(resources []infraview.Resource) []ResourceGroup { |
| 940 | rows := map[string][]ResourceRow{} |
| 941 | for _, r := range resources { |
| 942 | g, ok := archGroupOf[r.Kind] |
| 943 | if !ok { |
| 944 | g = "Other" |
| 945 | } |
| 946 | rows[g] = append(rows[g], ResourceRow{ |
| 947 | Name: nameOf(r), |
| 948 | NativeType: firstNonEmpty(r.NativeType, r.Kind), |
| 949 | Region: r.Region, |
| 950 | Detail: detailOf(r), |
| 951 | }) |
| 952 | } |
| 953 | var out []ResourceGroup |
| 954 | for _, title := range archGroupOrder { |
| 955 | if len(rows[title]) == 0 { |
| 956 | continue |
| 957 | } |
| 958 | out = append(out, ResourceGroup{Title: title, Rows: rows[title]}) |
| 959 | } |
| 960 | return out |
| 961 | } |
| 962 | |
| 963 | // detailOf renders a resource's own attributes, sorted by key, so an |
| 964 | // attribute nobody wrote code for still reaches the page. The id leads when |
| 965 | // it says something the Name column does not — for an `other` resource that |
| 966 | // is its ARN, which is the whole of what spec §6.3 promises about them. |
| 967 | func detailOf(r infraview.Resource) string { |
| 968 | keys := make([]string, 0, len(r.Attrs)) |
| 969 | for k := range r.Attrs { |
| 970 | keys = append(keys, k) |
| 971 | } |
| 972 | sort.Strings(keys) |
| 973 | parts := make([]string, 0, len(keys)+1) |
| 974 | if r.ID != "" && r.ID != r.Name { |
| 975 | parts = append(parts, "id="+r.ID) |
| 976 | } |
| 977 | for _, k := range keys { |
| 978 | parts = append(parts, k+"="+r.Attrs[k]) |
| 979 | } |
| 980 | return strings.Join(parts, ", ") |
| 981 | } |
| 982 | |
| 983 | // ---- small shared helpers ---------------------------------------------- |
| 984 | |
| 985 | // targetOf resolves a machine id to the node that stands for it: the box |
| 986 | // when one reports it, the machine node otherwise. "" for an empty id — an |
| 987 | // absent attribute is absence, and joining on it would attach every |
| 988 | // unattached alarm to every machine with no id. |
| 989 | func targetOf(machineID string, claimed map[string]string) string { |
| 990 | if machineID == "" { |
| 991 | return "" |
| 992 | } |
| 993 | if label := claimed[machineID]; label != "" { |
| 994 | return "box:" + label |
| 995 | } |
| 996 | return "machine:" + machineID |
| 997 | } |
| 998 | |
| 999 | func ipSubtitle(ip infraview.Resource, claimed map[string]string) string { |
| 1000 | to := ip.Attr("attached_to") |
| 1001 | if to == "" { |
| 1002 | return "attached to nothing" |
| 1003 | } |
| 1004 | if label := claimed[to]; label != "" { |
| 1005 | return label |
| 1006 | } |
| 1007 | return to |
| 1008 | } |
| 1009 | |
| 1010 | func boxSubtitle(b BoxCard) string { |
| 1011 | if b.CloudID == "" { |
| 1012 | return "bring-your-own box" |
| 1013 | } |
| 1014 | return strings.TrimSpace(b.InstanceType + " " + b.State) |
| 1015 | } |
| 1016 | |
| 1017 | func appsTitle(b BoxCard) string { |
| 1018 | if len(b.Apps) == 0 { |
| 1019 | return "no apps" |
| 1020 | } |
| 1021 | return strings.Join(b.Apps, ", ") |
| 1022 | } |
| 1023 | |
| 1024 | // countsSubtitle renders the box's instance counts. Two absences, both of |
| 1025 | // which must read as absence: a box the fleet read never described has NO |
| 1026 | // counts (three zeroes would state a read that never happened), and Awake |
| 1027 | // and Asleep are *int whose null means the socket table was unreadable. |
| 1028 | // "unknown", never 0, because 0 is a claim. |
| 1029 | // |
| 1030 | // The unknown form is deliberately short: this string is a subtitle inside a |
| 1031 | // 190-unit node rect, and the long form already overruns it. |
| 1032 | func countsSubtitle(b BoxCard) string { |
| 1033 | if !b.CountsKnown { |
| 1034 | return "counts unknown" |
| 1035 | } |
| 1036 | return fmt.Sprintf("static %d · resident %d · hibernating %d · awake %s · asleep %s", |
| 1037 | b.Static, b.Resident, b.Hibernating, countOrUnknown(b.Awake), countOrUnknown(b.Asleep)) |
| 1038 | } |
| 1039 | |
| 1040 | func countOrUnknown(n *int) string { |
| 1041 | if n == nil { |
| 1042 | return "unknown" |
| 1043 | } |
| 1044 | return strconv.Itoa(*n) |
| 1045 | } |
| 1046 | |
| 1047 | // nameOf is a resource's display name, falling back to its id — every |
| 1048 | // resource has an id, not every provider gives everything a name. |
| 1049 | func nameOf(r infraview.Resource) string { return firstNonEmpty(r.Name, r.ID) } |
| 1050 | |
| 1051 | func firstNonEmpty(vals ...string) string { |
| 1052 | for _, v := range vals { |
| 1053 | if v != "" { |
| 1054 | return v |
| 1055 | } |
| 1056 | } |
| 1057 | return "" |
| 1058 | } |
| 1059 | |