| 1 | package console |
| 2 | |
| 3 | // The account-level Fleet tab: GET /a/{sqid}/fleet, the page that answers |
| 4 | // "where does this account's work actually run?". |
| 5 | // |
| 6 | // It is a DIFFERENT page from the two that already carry the word fleet: |
| 7 | // handleFleet (/fleet) is the operator's global platform status page, and |
| 8 | // handleFleetPage (/a/{sqid}/fleets/{fleet}) is one of this account's own |
| 9 | // fleets in detail. This one sits between them — member-readable, account |
| 10 | // scoped, and the only place the shared platform fleet and the account's own |
| 11 | // boxes are shown as two halves of one question. |
| 12 | // |
| 13 | // Two rules shape everything below: |
| 14 | // |
| 15 | // D2 — the shared fleet is a PRESENTATION, not a registry object. There is |
| 16 | // no "shared fleet" row anywhere; the card is assembled from |
| 17 | // status/fleet.json, the same PUBLIC aggregate the status page renders. The |
| 18 | // raw status/boxes/* snapshots are deliberately NOT read here: they carry |
| 19 | // per-box operational detail (release ids, load, memory) that the operator |
| 20 | // page is gated to show and a tenant's page is not. |
| 21 | // |
| 22 | // Soft-fail everywhere — the overview.go/loadFleetPage posture. This page |
| 23 | // makes three independent reads (the aggregate, the account's routes, its |
| 24 | // fleets); any one of them having a bad moment costs that section and a |
| 25 | // flash, never the page. |
| 26 | |
| 27 | import ( |
| 28 | "context" |
| 29 | "log" |
| 30 | "net/http" |
| 31 | "sort" |
| 32 | |
| 33 | "github.com/carlosframework/platform/internal/registry" |
| 34 | ) |
| 35 | |
| 36 | // sharedFleetCard is the platform's own fleet as this account sees it: |
| 37 | // how many boxes are reporting, where they are, and whether any of THIS |
| 38 | // account's apps are running on them. |
| 39 | // |
| 40 | // Boxes/Regions come from the public aggregate and are the same for every |
| 41 | // viewer; Active/PlatformApps are this account's own placement split and are |
| 42 | // not. Nothing here is per-box: a tenant learns that the shared fleet exists |
| 43 | // and roughly where it is, never which machine is which. |
| 44 | type sharedFleetCard struct { |
| 45 | Title string |
| 46 | Active bool // at least one of this account's apps runs here |
| 47 | Regions []string // distinct, sorted; a box with no region is simply absent |
| 48 | Boxes int |
| 49 | PlatformApps int // this account's distinct apps NOT placed on a fleet |
| 50 | // PlacementKnown is false when the read that decides Active/PlatformApps |
| 51 | // failed. Both fields then hold their zero values, which read as the |
| 52 | // POSITIVE claim "none of your apps run on the shared fleet" — a claim |
| 53 | // this account's routes were never actually consulted for. The card |
| 54 | // renders neither verdict when this is false: "we could not check" and |
| 55 | // "we checked, and no" are different sentences, and only one of them is |
| 56 | // true after a registry outage. |
| 57 | PlacementKnown bool |
| 58 | } |
| 59 | |
| 60 | // accountFleetCard is one of the account's own fleets as the index row it |
| 61 | // links through to the detail page with. Counts come from loadFleetPage — |
| 62 | // the same function the detail page itself uses — so a row and the page it |
| 63 | // clicks through to can never disagree about how many boxes a fleet has. |
| 64 | type accountFleetCard struct { |
| 65 | Name string |
| 66 | Boxes int |
| 67 | Reachable int |
| 68 | Apps int |
| 69 | } |
| 70 | |
| 71 | // assembleAccountFleet builds the tab's two halves: the shared platform |
| 72 | // fleet from status/fleet.json, and the account's own fleets from the |
| 73 | // registry. warn carries the FIRST read that failed, for a flash banner — |
| 74 | // every failure degrades its own section to empty and nothing else. |
| 75 | // |
| 76 | // fleetsKnown is the fleets index's twin of sharedFleetCard.PlacementKnown, |
| 77 | // and exists for the identical reason: a nil slice is the SAME value for |
| 78 | // "this account has no fleets" and "the read that would have listed them |
| 79 | // failed", and the first of those is a positive claim ("No fleets of your |
| 80 | // own yet") that a failed registry read has not earned. False means the |
| 81 | // section renders "unknown", never "none". |
| 82 | func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (card *sharedFleetCard, fleets []accountFleetCard, fleetsKnown bool, warn string) { |
| 83 | card = &sharedFleetCard{Title: "Shared fleet"} |
| 84 | |
| 85 | // A console with no deployment bucket configured has no aggregate to |
| 86 | // read at all — that is the empty card, not an error. |
| 87 | if s.cfg.Releases != nil { |
| 88 | f, err := FleetView(ctx, s.cfg.Releases) |
| 89 | switch { |
| 90 | case err != nil: |
| 91 | // A read that FAILED is not the same claim as "no box is |
| 92 | // reporting" — the card stays empty either way, but only this |
| 93 | // branch admits the difference, rather than telling a tenant the |
| 94 | // platform has no boxes because S3 was slow. (FleetView already |
| 95 | // folds a not-yet-published object into (nil, nil), so reaching |
| 96 | // here means a real failure.) |
| 97 | log.Printf("console: account fleet %s: fleet view: %v", acct.Sqid, err) |
| 98 | warn = "Could not read the shared fleet's status." |
| 99 | case f != nil: |
| 100 | card.Boxes = len(f.Boxes) |
| 101 | seen := map[string]bool{} |
| 102 | for _, b := range f.Boxes { |
| 103 | if b.Region != "" && !seen[b.Region] { |
| 104 | seen[b.Region] = true |
| 105 | card.Regions = append(card.Regions, b.Region) |
| 106 | } |
| 107 | } |
| 108 | sort.Strings(card.Regions) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | reg, err := openRegistry(s.cfg.Registry) |
| 113 | if err != nil { |
| 114 | log.Printf("console: account fleet %s: open registry: %v", acct.Sqid, err) |
| 115 | if warn == "" { |
| 116 | warn = "Could not reach the fleet registry." |
| 117 | } |
| 118 | return card, nil, false, warn |
| 119 | } |
| 120 | |
| 121 | // The placement split. A route whose Region parses as a fleet placement |
| 122 | // runs on that fleet; everything else — "" and any future residency code |
| 123 | // FleetPlacement declines — runs on the shared fleet, which is exactly |
| 124 | // the fallback the platform itself applies. Counted by distinct APP, not |
| 125 | // by route, because "3 of your apps run here" is the sentence a tenant |
| 126 | // can act on; a host count is noise on this page. |
| 127 | // |
| 128 | // rt.Account is compared strictly, the same test loadFleetPage and |
| 129 | // overview.go make: an unstamped row is not this account's to claim on a |
| 130 | // page that goes on to say "of YOUR apps". |
| 131 | routes, err := reg.List() |
| 132 | if err != nil { |
| 133 | log.Printf("console: account fleet %s: list routes: %v", acct.Sqid, err) |
| 134 | if warn == "" { |
| 135 | warn = "Could not read where your apps are running." |
| 136 | } |
| 137 | } else { |
| 138 | platformApps := map[string]bool{} |
| 139 | for _, rt := range routes { |
| 140 | if rt.Account != acct.Sqid { |
| 141 | continue |
| 142 | } |
| 143 | if _, placed := registry.FleetPlacement(rt.Region); !placed { |
| 144 | platformApps[rt.App] = true |
| 145 | } |
| 146 | } |
| 147 | card.PlatformApps = len(platformApps) |
| 148 | card.Active = card.PlatformApps > 0 |
| 149 | card.PlacementKnown = true |
| 150 | } |
| 151 | |
| 152 | rows, err := reg.ListFleets() |
| 153 | if err != nil { |
| 154 | log.Printf("console: account fleet %s: list fleets: %v", acct.Sqid, err) |
| 155 | if warn == "" { |
| 156 | warn = "Could not list your fleets." |
| 157 | } |
| 158 | return card, nil, false, warn |
| 159 | } |
| 160 | for i := range rows { |
| 161 | // Fleet names are one flat, cross-account namespace (fleets.go's |
| 162 | // package doc comment), so this filter is the ONLY thing standing |
| 163 | // between this index and another account's fleet names. &fleets[i] |
| 164 | // rather than a ranged copy's address: loadFleetPage takes a pointer, |
| 165 | // and one that outlived a loop variable would be a bug waiting for |
| 166 | // the day this function keeps it. |
| 167 | f := &rows[i] |
| 168 | if f.Account != acct.Sqid { |
| 169 | continue |
| 170 | } |
| 171 | boxes, apps, fleetWarn := s.loadFleetPage(ctx, reg, acct, f) |
| 172 | if fleetWarn != "" && warn == "" { |
| 173 | // loadFleetPage's own wording says "this fleet", which has no |
| 174 | // referent on an index of several; say what it costs HERE. |
| 175 | warn = "Some fleet details could not be read — the counts below may be incomplete." |
| 176 | } |
| 177 | c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)} |
| 178 | for _, b := range boxes { |
| 179 | if b.Reachable { |
| 180 | c.Reachable++ |
| 181 | } |
| 182 | } |
| 183 | fleets = append(fleets, c) |
| 184 | } |
| 185 | return card, fleets, true, warn |
| 186 | } |
| 187 | |
| 188 | // handleAccountFleetTab serves GET /a/{sqid}/fleet. Membership in the |
| 189 | // account is the whole gate: the page is a read, so a member sees exactly |
| 190 | // what an owner does (there is no mutation on it to withhold). |
| 191 | func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) { |
| 192 | addr, raw, acct, role, ok := s.account(w, r) |
| 193 | if !ok { |
| 194 | return |
| 195 | } |
| 196 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 197 | p.Title = "Fleet" |
| 198 | p.ActiveNav = "fleet" |
| 199 | shared, own, ownKnown, warn := s.assembleAccountFleet(r.Context(), acct) |
| 200 | p.SharedFleet = shared |
| 201 | p.AccountFleets = own |
| 202 | p.FleetsKnown = ownKnown |
| 203 | if warn != "" && p.FlashErr == "" { |
| 204 | p.FlashErr = warn |
| 205 | } |
| 206 | s.render(w, http.StatusOK, "account_fleet", p) |
| 207 | } |
| 208 | |
| 209 | // handleFleetBYO serves GET /a/{sqid}/fleet/byo: the "how to add and harden |
| 210 | // a box" page the Fleet tab's button links to. It is static, per-deployment |
| 211 | // content — the account only personalizes the eyebrow and header, same as |
| 212 | // every other account page — so the handler is handleAccountFleetTab minus |
| 213 | // the shared-fleet/own-fleets assembly: gate, page scaffold, render. |
| 214 | func (s *Server) handleFleetBYO(w http.ResponseWriter, r *http.Request) { |
| 215 | addr, raw, acct, role, ok := s.account(w, r) |
| 216 | if !ok { |
| 217 | return |
| 218 | } |
| 219 | p := s.accountPage(w, r, addr, raw, acct, role) |
| 220 | p.Title = "Bring your own fleet" |
| 221 | p.ActiveNav = "fleet" |
| 222 | s.render(w, http.StatusOK, "fleet_byo", p) |
| 223 | } |
| 224 | |