| new file mode 100644 |
| index 000000000..4db0a14e5 |
| --- /dev/null |
| +++ b/internal/console/archgraph.go |
| @@ -0,0 +1,620 @@ |
| +// The architecture graph behind /fleet/architecture: one pure function that |
| +// takes a cloud inventory plus everything CARLOS knows about itself and |
| +// produces the page's whole view model — nodes, edges, box cards and |
| +// resource groups. |
| +// |
| +// Three rules run through all of it (2026-08-19-fleet-architecture-diagram- |
| +// design.md §6): |
| +// |
| +// - UNJOINED IS DRAWN, NEVER HIDDEN. A machine no box reports is drift, a |
| +// box with no machine is a customer's own hardware, a DNS record pointing |
| +// outside the estate is an arrow leaving the diagram, and a resource this |
| +// vocabulary has no word for still reaches the resource groups with its |
| +// native type. The resource nobody wrote code for is the one worth |
| +// seeing, so nothing is silently dropped. |
| +// - COST COMES FROM THE RATE CARD AND NOWHERE ELSE, so this page and |
| +// /fleet/economics can never disagree about what a box costs. The |
| +// discovered instance type and disk sit BESIDE the price and are used |
| +// only to flag drift — never to compute a second one. |
| +// - EVERY READ IS INDEPENDENT. A nil inventory, a nil fleet, an empty host |
| +// table and an empty route list each cost their own section and nothing |
| +// else. BuildArch never panics on any combination, including all-nil. |
| +// |
| +// BuildArch is pure — no I/O, no clock, no registry or bucket access. |
| +// Everything it needs arrives in ArchInput, which is what makes it |
| +// table-testable and what lets archlayout.go depend on it being |
| +// deterministic. |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "sort" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/economics" |
| + "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// ArchInput is every read the page makes, handed over as data. Each field is |
| +// independently optional: whichever reads succeeded are drawn, and the ones |
| +// that did not simply do not appear. |
| +type ArchInput struct { |
| + Inv *infraview.Inventory |
| + Fleet *status.Fleet |
| + Hosts []HostRow |
| + Routes []registry.Route |
| + Rates economics.RateCard |
| + // Bucket is the deployment bucket the console itself is configured with |
| + // — the ONE bucket relationship the console can state as fact (spec |
| + // §6.2). Empty draws no bucket edge at all: the estate may hold six |
| + // buckets and guessing which one the fleet writes to would put a false |
| + // arrow on a page whose whole value is that its arrows are true. |
| + Bucket string |
| +} |
| + |
| +// KV is one detail line on a node. |
| +type KV struct{ K, V string } |
| + |
| +// Node is one drawn thing. X/Y/W/H are filled by layout (archlayout.go), not |
| +// here: BuildArch owns what the picture MEANS, layout owns where it sits. |
| +type Node struct { |
| + ID, Kind, Title, Subtitle string |
| + Detail []KV |
| + X, Y, W, H int |
| +} |
| + |
| +// Edge is one relationship the console can state as fact. Kind is the |
| +// vocabulary of spec §6.1: resolves, routes, runs, stores, watches. |
| +type Edge struct{ From, To, Kind string } |
| + |
| +// BoxCard is one box below the diagram: what CARLOS reports about it, what |
| +// the estate says it is, what the rate card says it costs, and every way |
| +// those three disagree. |
| +type BoxCard struct { |
| + Label, Region, InstanceType, State string |
| + Cores, MemGB, EBSGB float64 |
| + 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 |
| +} |
| + |
| +// ResourceGroup is one table below the diagram. |
| +type ResourceGroup struct { |
| + Title string |
| + Rows []ResourceRow |
| +} |
| + |
| +// ResourceRow is one resource in one of those tables. |
| +type ResourceRow struct{ Name, NativeType, Region, Detail string } |
| + |
| +// ArchView is the whole page. |
| +type ArchView struct { |
| + Nodes []Node |
| + Edges []Edge |
| + Width, Height int |
| + Boxes []BoxCard |
| + Groups []ResourceGroup |
| + TakenAt time.Time |
| + Account string |
| + Partial []string |
| + Missing string // "" | "never" | "ungranted" | "unreadable" |
| +} |
| + |
| +// BuildArch joins the estate to the platform's own model of itself. It reads |
| +// as the sequence of joins spec §6.2 names, in the order a request travels: |
| +// boxes first (everything else needs to know which machine belongs to whom), |
| +// then DNS in at the left, then the boxes and their apps, then the |
| +// satellites. |
| +func BuildArch(in ArchInput) ArchView { |
| + var v ArchView |
| + |
| + if in.Inv == nil { |
| + // The caller overwrites this with the reason it actually has |
| + // ("ungranted", "unreadable"); "never" is the honest default for a |
| + // builder handed nothing and told nothing. |
| + v.Missing = "never" |
| + } else { |
| + v.TakenAt = in.Inv.TakenAt |
| + v.Account = in.Inv.Account |
| + v.Partial = in.Inv.Partial |
| + } |
| + |
| + v.Boxes = buildBoxCards(in) |
| + |
| + // machineID -> box label, the join every later step asks about. Built |
| + // once here so "does a box report this machine?" is one lookup and |
| + // cannot drift between the DNS pass and the alarm pass. |
| + claimed := map[string]string{} |
| + for _, b := range v.Boxes { |
| + if b.CloudID != "" { |
| + claimed[b.CloudID] = b.Label |
| + } |
| + } |
| + |
| + addDNSNodes(&v, in.Inv, claimed) |
| + addBoxNodes(&v, in, claimed) |
| + addMachineNodes(&v, in.Inv, claimed) |
| + addAlarmNodes(&v, in.Inv, claimed) |
| + v.Groups = resourceGroups(in.Inv) |
| + return v |
| +} |
| + |
| +// ---- the joins --------------------------------------------------------- |
| + |
| +// machineFor matches a machine's Name tag against a box label, case- |
| +// insensitively and exactly — the Name tag is set to the host's name and the |
| +// box label IS the box's hostname, so this join needs no convention beyond |
| +// the one the module already follows. Returns nil when nothing matches, |
| +// which is the BYO-box case and not an error. |
| +func machineFor(inv *infraview.Inventory, label string) *infraview.Resource { |
| + if inv == nil || label == "" { |
| + return nil |
| + } |
| + for i := range inv.Resources { |
| + r := &inv.Resources[i] |
| + if r.Kind == infraview.KindMachine && strings.EqualFold(r.Name, label) { |
| + return r |
| + } |
| + } |
| + return nil |
| +} |
| + |
| +// diskGBFor sums every disk attached to one machine. A size that will not |
| +// parse is absence, not zero — it simply does not contribute, and the card |
| +// then makes no disk claim rather than a false one. |
| +func diskGBFor(inv *infraview.Inventory, machineID string) float64 { |
| + if inv == nil || machineID == "" { |
| + return 0 |
| + } |
| + var total float64 |
| + for _, d := range inv.ByKind(infraview.KindDisk) { |
| + if d.Attr("attached_to") != machineID { |
| + continue |
| + } |
| + if gb, err := strconv.ParseFloat(d.Attr("size_gb"), 64); err == nil { |
| + total += gb |
| + } |
| + } |
| + return total |
| +} |
| + |
| +// appsFor names the apps a box is running: its own RouteVersions keys are |
| +// its hostnames (the one place a box's real hostnames are carried), looked |
| +// up in the registry for the app they belong to. Distinct and sorted, so two |
| +// routes of one app read as one app and the order never moves. |
| +func appsFor(host HostRow, routes []registry.Route) []string { |
| + if host.Metrics == nil || len(host.Metrics.RouteVersions) == 0 { |
| + return nil |
| + } |
| + byHost := make(map[string]string, len(routes)) |
| + for _, r := range routes { |
| + byHost[r.Host] = r.App |
| + } |
| + seen := map[string]bool{} |
| + var out []string |
| + for h := range host.Metrics.RouteVersions { |
| + app := byHost[h] |
| + if app == "" || seen[app] { |
| + continue |
| + } |
| + seen[app] = true |
| + out = append(out, app) |
| + } |
| + sort.Strings(out) |
| + return out |
| +} |
| + |
| +// boxCost prices one box the way /fleet/economics prices it and no other |
| +// way: the rate card's own spec for that label, falling back to the "" |
| +// default exactly as economics.AllocateBox does. explicit is false when the |
| +// fallback was used, which is itself worth telling the operator — a box |
| +// priced by the default spec is priced by a guess. |
| +func boxCost(rc economics.RateCard, label string) (usd float64, spec economics.BoxSpec, explicit bool) { |
| + spec, explicit = rc.Boxes[label] |
| + if !explicit { |
| + spec = rc.Boxes[""] |
| + } |
| + usd = spec.HourUSD*economics.HoursPerMonth + spec.EBSGB*rc.EBSGBMonthUSD |
| + return usd, spec, explicit |
| +} |
| + |
| +// driftFor names every way the rate card and the estate disagree about this |
| +// box. Two checks only — the two this page can make honestly. Comparing the |
| +// instance TYPE against the card's cores/memory would need a type->shape |
| +// table, which is a second source of truth and out of scope (spec §10). |
| +func driftFor(card BoxCard, spec economics.BoxSpec, explicit bool) []string { |
| + var out []string |
| + if !explicit { |
| + out = append(out, "priced by the default spec — no rate-card entry for this box") |
| + } |
| + if card.DiscoveredDiskGB > 0 && spec.EBSGB > 0 { |
| + if d := card.DiscoveredDiskGB - spec.EBSGB; d >= 1 || d <= -1 { |
| + out = append(out, fmt.Sprintf("rate card says %.0f GB of EBS, the estate has %.0f GB", |
| + spec.EBSGB, card.DiscoveredDiskGB)) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// ---- box cards --------------------------------------------------------- |
| + |
| +// buildBoxCards is the union of the two reads that know about boxes: the |
| +// published fleet (regions, load, route counts, staleness) and the private |
| +// host table (release, hostnames, dark time). Either one alone still |
| +// produces a full set of cards, because either one alone is a legitimate |
| +// state of the page — the fleet read and the host read fail separately. |
| +func buildBoxCards(in ArchInput) []BoxCard { |
| + fleetBoxes := map[string]status.FleetBox{} |
| + var labels []string |
| + seen := map[string]bool{} |
| + add := func(label string) { |
| + if label == "" || seen[label] { |
| + return |
| + } |
| + seen[label] = true |
| + labels = append(labels, label) |
| + } |
| + if in.Fleet != nil { |
| + for _, b := range in.Fleet.Boxes { |
| + fleetBoxes[b.Label] = b |
| + add(b.Label) |
| + } |
| + } |
| + hosts := map[string]HostRow{} |
| + for _, h := range in.Hosts { |
| + hosts[h.Label] = h |
| + add(h.Label) |
| + } |
| + sort.Strings(labels) |
| + |
| + cards := make([]BoxCard, 0, len(labels)) |
| + for _, label := range labels { |
| + fb := fleetBoxes[label] |
| + host := hosts[label] |
| + |
| + card := BoxCard{ |
| + Label: label, |
| + Region: firstNonEmpty(fb.Region, host.Region), |
| + Static: fb.Routes.Static, |
| + Resident: fb.Routes.Resident, |
| + Hibernating: fb.Routes.Hibernating, |
| + Awake: fb.Routes.Awake, |
| + Asleep: fb.Routes.Asleep, |
| + Stale: fb.Stale || host.Stale, |
| + Dark: host.Dark, |
| + Apps: appsFor(host, in.Routes), |
| + } |
| + if fb.Load != nil { |
| + card.Load = *fb.Load |
| + } |
| + // Metrics is nil for a stale box by design (see HostRow) — an |
| + // unreachable host's last happy numbers must never reach the page, |
| + // and reading through the pointer is where that promise is kept. |
| + if host.Metrics != nil { |
| + card.Release = host.Metrics.Release |
| + } |
| + if m := machineFor(in.Inv, label); m != nil { |
| + card.CloudID = m.ID |
| + card.InstanceType = m.Attr("type") |
| + card.State = m.Attr("state") |
| + card.Region = firstNonEmpty(card.Region, m.Region) |
| + card.DiscoveredDiskGB = diskGBFor(in.Inv, m.ID) |
| + } |
| + |
| + 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) |
| + cards = append(cards, card) |
| + } |
| + return cards |
| +} |
| + |
| +// ---- nodes and edges --------------------------------------------------- |
| + |
| +// addDNSNodes draws the left of the picture: zones, their records, and where |
| +// each record actually points. A record whose value is an address the estate |
| +// does not own gets an external node — an arrow leaving the diagram, per |
| +// spec §6.3 — rather than being dropped, because "this name points somewhere |
| +// we do not run" is exactly the thing an operator opens this page to see. |
| +func addDNSNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) { |
| + if inv == nil { |
| + return |
| + } |
| + zones := inv.ByKind(infraview.KindDNSZone) |
| + for _, z := range zones { |
| + sub := "" |
| + if n := z.Attr("record_count"); n != "" { |
| + sub = n + " records" |
| + } |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: "zone:" + z.ID, Kind: "zone", Title: nameOf(z), Subtitle: sub, |
| + }) |
| + } |
| + for _, ip := range inv.ByKind(infraview.KindIP) { |
| + id := "ip:" + ip.ID |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: id, Kind: "ip", Title: firstNonEmpty(ip.Attr("address"), nameOf(ip)), |
| + Subtitle: ipSubtitle(ip, claimed), |
| + }) |
| + if to := targetOf(ip.Attr("attached_to"), claimed); to != "" { |
| + v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "routes"}) |
| + } |
| + } |
| + external := map[string]bool{} |
| + for _, rec := range inv.ByKind(infraview.KindDNSRecord) { |
| + id := "record:" + rec.ID |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: id, Kind: "record", Title: nameOf(rec), |
| + Subtitle: strings.TrimSpace(rec.Attr("type") + " " + rec.Attr("value")), |
| + }) |
| + for _, z := range zones { |
| + if z.Name != "" && rec.Attr("zone") == z.Name { |
| + v.Edges = append(v.Edges, Edge{From: "zone:" + z.ID, To: id, Kind: "resolves"}) |
| + } |
| + } |
| + // Find refuses to match on an empty value, so an absent record value |
| + // falls straight through both joins — and then out of the estate |
| + // check below, which only fires on a value there is one of. |
| + value := rec.Attr("value") |
| + ip := inv.Find(infraview.KindIP, "address", value) |
| + machine := inv.Find(infraview.KindMachine, "public_ip", value) |
| + switch { |
| + case value == "": |
| + // An absent value is absence. Nothing is claimed about it. |
| + case ip != nil: |
| + v.Edges = append(v.Edges, Edge{From: id, To: "ip:" + ip.ID, Kind: "resolves"}) |
| + case machine != nil: |
| + // An address the estate owns but holds directly on a machine |
| + // rather than through an allocated IP. |
| + v.Edges = append(v.Edges, Edge{From: id, To: targetOf(machine.ID, claimed), Kind: "resolves"}) |
| + default: |
| + ext := "external:" + value |
| + if !external[value] { |
| + external[value] = true |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: ext, Kind: "external", Title: value, |
| + Subtitle: "outside this estate", |
| + }) |
| + } |
| + v.Edges = append(v.Edges, Edge{From: id, To: ext, Kind: "resolves"}) |
| + } |
| + } |
| +} |
| + |
| +// addBoxNodes draws every box CARLOS reports — with or without a machine |
| +// behind it — plus the one apps node per box that carries its instance |
| +// counts, and the deployment-bucket edge when the caller named a bucket. |
| +// |
| +// One apps node per box, never one per instance: a fleet of a hundred |
| +// tenants must still produce a diagram a person can read (spec §6.4). |
| +func addBoxNodes(v *ArchView, in ArchInput, claimed map[string]string) { |
| + for _, b := range v.Boxes { |
| + boxID := "box:" + b.Label |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: boxID, Kind: "box", Title: b.Label, Subtitle: boxSubtitle(b), |
| + }) |
| + appsID := "apps:" + b.Label |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: appsID, Kind: "apps", Title: appsTitle(b), Subtitle: countsSubtitle(b), |
| + }) |
| + v.Edges = append(v.Edges, Edge{From: boxID, To: appsID, Kind: "runs"}) |
| + if in.Bucket != "" { |
| + v.Edges = append(v.Edges, Edge{From: boxID, To: "bucket:" + in.Bucket, Kind: "stores"}) |
| + } |
| + } |
| + if in.Bucket == "" { |
| + return |
| + } |
| + // The bucket node exists whether or not the estate read found it: the |
| + // console knows this bucket first-hand, and an un-granted S3 read must |
| + // not erase a relationship the console is certain of. |
| + id := "bucket:" + in.Bucket |
| + sub := "deployment bucket" |
| + if in.Inv != nil { |
| + for _, b := range in.Inv.ByKind(infraview.KindBucket) { |
| + if nameOf(b) == in.Bucket && b.Region != "" { |
| + sub = "deployment bucket · " + b.Region |
| + } |
| + } |
| + } |
| + v.Nodes = append(v.Nodes, Node{ID: id, Kind: "bucket", Title: in.Bucket, Subtitle: sub}) |
| +} |
| + |
| +// addMachineNodes draws the machines NO box reports. That is the whole |
| +// point: an instance nobody is running CARLOS on, or a box that has gone |
| +// dark, is drift — and drift is only visible if it is drawn. A machine a box |
| +// does report is already on the page as that box, and drawing it twice would |
| +// say the estate holds two things where it holds one. |
| +func addMachineNodes(v *ArchView, inv *infraview.Inventory, claimed map[string]string) { |
| + if inv == nil { |
| + return |
| + } |
| + for _, m := range inv.ByKind(infraview.KindMachine) { |
| + if claimed[m.ID] != "" { |
| + continue |
| + } |
| + sub := "no box reporting" |
| + if t := m.Attr("type"); t != "" { |
| + sub = "no box reporting · " + t |
| + } |
| + v.Nodes = append(v.Nodes, Node{ |
| + ID: "machine:" + m.ID, Kind: "machine", |
| + Title: firstNonEmpty(m.Name, m.ID), Subtitle: sub, |
| + }) |
| + } |
| +} |
| + |
| +// addAlarmNodes hangs each alarm off 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) { |
| + if inv == nil { |
| + return |
| + } |
| + 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")), |
| + }) |
| + v.Edges = append(v.Edges, Edge{From: id, To: to, Kind: "watches"}) |
| + } |
| +} |
| + |
| +// ---- resource groups --------------------------------------------------- |
| + |
| +// archGroupOrder is the order the tables appear in, and archGroupOf maps a |
| +// kind onto one of them. A kind neither map knows lands in Other rather than |
| +// being dropped: infraview carries an unrecognised kind through verbatim |
| +// precisely so the page can show it. |
| +var archGroupOrder = []string{"Compute", "Network", "Storage", "DNS", "Identity", "Alarms", "Certificates", "Other"} |
| + |
| +var archGroupOf = map[string]string{ |
| + infraview.KindMachine: "Compute", |
| + infraview.KindDisk: "Compute", |
| + infraview.KindIP: "Network", |
| + infraview.KindFirewall: "Network", |
| + infraview.KindNetwork: "Network", |
| + infraview.KindBucket: "Storage", |
| + infraview.KindDNSZone: "DNS", |
| + infraview.KindDNSRecord: "DNS", |
| + infraview.KindIdentity: "Identity", |
| + infraview.KindAlarm: "Alarms", |
| + infraview.KindCertificate: "Certificates", |
| + infraview.KindOther: "Other", |
| +} |
| + |
| +// resourceGroups turns the whole inventory into the tables below the |
| +// diagram. EVERY resource appears exactly once, in inventory order, and an |
| +// empty group is omitted rather than rendered as a confident "none". |
| +func resourceGroups(inv *infraview.Inventory) []ResourceGroup { |
| + if inv == nil { |
| + return nil |
| + } |
| + rows := map[string][]ResourceRow{} |
| + for _, r := range inv.Resources { |
| + g, ok := archGroupOf[r.Kind] |
| + if !ok { |
| + g = "Other" |
| + } |
| + rows[g] = append(rows[g], ResourceRow{ |
| + Name: nameOf(r), |
| + NativeType: firstNonEmpty(r.NativeType, r.Kind), |
| + Region: r.Region, |
| + Detail: detailOf(r), |
| + }) |
| + } |
| + var out []ResourceGroup |
| + for _, title := range archGroupOrder { |
| + if len(rows[title]) == 0 { |
| + continue |
| + } |
| + out = append(out, ResourceGroup{Title: title, Rows: rows[title]}) |
| + } |
| + return out |
| +} |
| + |
| +// detailOf renders a resource's own attributes, sorted by key, so an |
| +// attribute nobody wrote code for still reaches the page. The id leads when |
| +// it says something the Name column does not — for an `other` resource that |
| +// is its ARN, which is the whole of what spec §6.3 promises about them. |
| +func detailOf(r infraview.Resource) string { |
| + keys := make([]string, 0, len(r.Attrs)) |
| + for k := range r.Attrs { |
| + keys = append(keys, k) |
| + } |
| + sort.Strings(keys) |
| + parts := make([]string, 0, len(keys)+1) |
| + if r.ID != "" && r.ID != r.Name { |
| + parts = append(parts, "id="+r.ID) |
| + } |
| + for _, k := range keys { |
| + parts = append(parts, k+"="+r.Attrs[k]) |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
| + |
| +// ---- small shared helpers ---------------------------------------------- |
| + |
| +// targetOf resolves a machine id to the node that stands for it: the box |
| +// when one reports it, the machine node otherwise. "" for an empty id — an |
| +// absent attribute is absence, and joining on it would attach every |
| +// unattached alarm to every machine with no id. |
| +func targetOf(machineID string, claimed map[string]string) string { |
| + if machineID == "" { |
| + return "" |
| + } |
| + if label := claimed[machineID]; label != "" { |
| + return "box:" + label |
| + } |
| + return "machine:" + machineID |
| +} |
| + |
| +func ipSubtitle(ip infraview.Resource, claimed map[string]string) string { |
| + to := ip.Attr("attached_to") |
| + if to == "" { |
| + return "attached to nothing" |
| + } |
| + if label := claimed[to]; label != "" { |
| + return label |
| + } |
| + return to |
| +} |
| + |
| +func boxSubtitle(b BoxCard) string { |
| + if b.CloudID == "" { |
| + return "bring-your-own box" |
| + } |
| + return strings.TrimSpace(b.InstanceType + " " + b.State) |
| +} |
| + |
| +func appsTitle(b BoxCard) string { |
| + if len(b.Apps) == 0 { |
| + return "no apps" |
| + } |
| + 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. |
| +func countsSubtitle(b BoxCard) string { |
| + 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)) |
| +} |
| + |
| +func countOrUnknown(n *int) string { |
| + if n == nil { |
| + return "unknown" |
| + } |
| + return strconv.Itoa(*n) |
| +} |
| + |
| +// nameOf is a resource's display name, falling back to its id — every |
| +// resource has an id, not every provider gives everything a name. |
| +func nameOf(r infraview.Resource) string { return firstNonEmpty(r.Name, r.ID) } |
| + |
| +func firstNonEmpty(vals ...string) string { |
| + for _, v := range vals { |
| + if v != "" { |
| + return v |
| + } |
| + } |
| + return "" |
| +} |