console: the Fleet tab — shared-fleet card, fleets index, nav (D1/D2)
GET /a/{sqid}/fleet: the account-level answer to "where does this
account's work run?", member-readable, and a different page from both
/fleet (the operator's global status page) and /a/{sqid}/fleets/{fleet}
(one fleet in detail).
Two halves. The shared platform fleet is a PRESENTATION of the public
status/fleet.json aggregate (D2) — box count and distinct regions, never
the raw status/boxes/* snapshots, whose per-box operational detail this
page is not gated to show. The account's own fleets come from the
registry, filtered by owner (fleet names are one flat cross-account
namespace), each row counted by loadFleetPage itself so an index row and
the detail page it links to can never disagree.
Every read soft-fails to an empty section plus a flash, the
overview.go/loadFleetPage posture: three independent reads, none of them
a reason to 500. One honesty carry beyond the plan — when the route read
fails, the card renders NEITHER verdict rather than falling through its
zero value into "none of your apps run on the shared fleet", a positive
claim about routes it never managed to look at.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+602
−1
internal/console/accountfleet.go+201 −0internal/console/accountfleet_test.go+344 −0internal/console/server.go+11 −1internal/console/templates/account_fleet.html+45 −0internal/console/templates/layout.html+1 −0
diff --git a/internal/console/accountfleet.go b/internal/console/accountfleet.go| new file mode 100644 |
| index 000000000..94d726086 |
| --- /dev/null |
| +++ b/internal/console/accountfleet.go |
| @@ -0,0 +1,201 @@ |
| +package console |
| + |
| +// The account-level Fleet tab: GET /a/{sqid}/fleet, the page that answers |
| +// "where does this account's work actually run?". |
| +// |
| +// It is a DIFFERENT page from the two that already carry the word fleet: |
| +// handleFleet (/fleet) is the operator's global platform status page, and |
| +// handleFleetPage (/a/{sqid}/fleets/{fleet}) is one of this account's own |
| +// fleets in detail. This one sits between them — member-readable, account |
| +// scoped, and the only place the shared platform fleet and the account's own |
| +// boxes are shown as two halves of one question. |
| +// |
| +// Two rules shape everything below: |
| +// |
| +// D2 — the shared fleet is a PRESENTATION, not a registry object. There is |
| +// no "shared fleet" row anywhere; the card is assembled from |
| +// status/fleet.json, the same PUBLIC aggregate the status page renders. The |
| +// raw status/boxes/* snapshots are deliberately NOT read here: they carry |
| +// per-box operational detail (release ids, load, memory) that the operator |
| +// page is gated to show and a tenant's page is not. |
| +// |
| +// Soft-fail everywhere — the overview.go/loadFleetPage posture. This page |
| +// makes three independent reads (the aggregate, the account's routes, its |
| +// fleets); any one of them having a bad moment costs that section and a |
| +// flash, never the page. |
| + |
| +import ( |
| + "context" |
| + "log" |
| + "net/http" |
| + "sort" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// sharedFleetCard is the platform's own fleet as this account sees it: |
| +// how many boxes are reporting, where they are, and whether any of THIS |
| +// account's apps are running on them. |
| +// |
| +// Boxes/Regions come from the public aggregate and are the same for every |
| +// viewer; Active/PlatformApps are this account's own placement split and are |
| +// not. Nothing here is per-box: a tenant learns that the shared fleet exists |
| +// and roughly where it is, never which machine is which. |
| +type sharedFleetCard struct { |
| + Title string |
| + Active bool // at least one of this account's apps runs here |
| + Regions []string // distinct, sorted; a box with no region is simply absent |
| + Boxes int |
| + PlatformApps int // this account's distinct apps NOT placed on a fleet |
| + // PlacementKnown is false when the read that decides Active/PlatformApps |
| + // failed. Both fields then hold their zero values, which read as the |
| + // POSITIVE claim "none of your apps run on the shared fleet" — a claim |
| + // this account's routes were never actually consulted for. The card |
| + // renders neither verdict when this is false: "we could not check" and |
| + // "we checked, and no" are different sentences, and only one of them is |
| + // true after a registry outage. |
| + PlacementKnown bool |
| +} |
| + |
| +// accountFleetCard is one of the account's own fleets as the index row it |
| +// links through to the detail page with. Counts come from loadFleetPage — |
| +// the same function the detail page itself uses — so a row and the page it |
| +// clicks through to can never disagree about how many boxes a fleet has. |
| +type accountFleetCard struct { |
| + Name string |
| + Boxes int |
| + Reachable int |
| + Apps int |
| +} |
| + |
| +// assembleAccountFleet builds the tab's two halves: the shared platform |
| +// fleet from status/fleet.json, and the account's own fleets from the |
| +// registry. warn carries the FIRST read that failed, for a flash banner — |
| +// every failure degrades its own section to empty and nothing else. |
| +func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*sharedFleetCard, []accountFleetCard, string) { |
| + card := &sharedFleetCard{Title: "Shared fleet"} |
| + var warn string |
| + |
| + // A console with no deployment bucket configured has no aggregate to |
| + // read at all — that is the empty card, not an error. |
| + if s.cfg.Releases != nil { |
| + f, err := FleetView(ctx, s.cfg.Releases) |
| + switch { |
| + case err != nil: |
| + // A read that FAILED is not the same claim as "no box is |
| + // reporting" — the card stays empty either way, but only this |
| + // branch admits the difference, rather than telling a tenant the |
| + // platform has no boxes because S3 was slow. (FleetView already |
| + // folds a not-yet-published object into (nil, nil), so reaching |
| + // here means a real failure.) |
| + log.Printf("console: account fleet %s: fleet view: %v", acct.Sqid, err) |
| + warn = "Could not read the shared fleet's status." |
| + case f != nil: |
| + card.Boxes = len(f.Boxes) |
| + seen := map[string]bool{} |
| + for _, b := range f.Boxes { |
| + if b.Region != "" && !seen[b.Region] { |
| + seen[b.Region] = true |
| + card.Regions = append(card.Regions, b.Region) |
| + } |
| + } |
| + sort.Strings(card.Regions) |
| + } |
| + } |
| + |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: account fleet %s: open registry: %v", acct.Sqid, err) |
| + if warn == "" { |
| + warn = "Could not reach the fleet registry." |
| + } |
| + return card, nil, warn |
| + } |
| + |
| + // The placement split. A route whose Region parses as a fleet placement |
| + // runs on that fleet; everything else — "" and any future residency code |
| + // FleetPlacement declines — runs on the shared fleet, which is exactly |
| + // the fallback the platform itself applies. Counted by distinct APP, not |
| + // by route, because "3 of your apps run here" is the sentence a tenant |
| + // can act on; a host count is noise on this page. |
| + // |
| + // rt.Account is compared strictly, the same test loadFleetPage and |
| + // overview.go make: an unstamped row is not this account's to claim on a |
| + // page that goes on to say "of YOUR apps". |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: account fleet %s: list routes: %v", acct.Sqid, err) |
| + if warn == "" { |
| + warn = "Could not read where your apps are running." |
| + } |
| + } else { |
| + platformApps := map[string]bool{} |
| + for _, rt := range routes { |
| + if rt.Account != acct.Sqid { |
| + continue |
| + } |
| + if _, placed := registry.FleetPlacement(rt.Region); !placed { |
| + platformApps[rt.App] = true |
| + } |
| + } |
| + card.PlatformApps = len(platformApps) |
| + card.Active = card.PlatformApps > 0 |
| + card.PlacementKnown = true |
| + } |
| + |
| + fleets, err := reg.ListFleets() |
| + if err != nil { |
| + log.Printf("console: account fleet %s: list fleets: %v", acct.Sqid, err) |
| + if warn == "" { |
| + warn = "Could not list your fleets." |
| + } |
| + return card, nil, warn |
| + } |
| + var cards []accountFleetCard |
| + for i := range fleets { |
| + // Fleet names are one flat, cross-account namespace (fleets.go's |
| + // package doc comment), so this filter is the ONLY thing standing |
| + // between this index and another account's fleet names. &fleets[i] |
| + // rather than a ranged copy's address: loadFleetPage takes a pointer, |
| + // and one that outlived a loop variable would be a bug waiting for |
| + // the day this function keeps it. |
| + f := &fleets[i] |
| + if f.Account != acct.Sqid { |
| + continue |
| + } |
| + boxes, apps, fleetWarn := s.loadFleetPage(ctx, reg, acct, f) |
| + if fleetWarn != "" && warn == "" { |
| + // loadFleetPage's own wording says "this fleet", which has no |
| + // referent on an index of several; say what it costs HERE. |
| + warn = "Some fleet details could not be read — the counts below may be incomplete." |
| + } |
| + c := accountFleetCard{Name: f.Name, Boxes: len(boxes), Apps: len(apps)} |
| + for _, b := range boxes { |
| + if b.Reachable { |
| + c.Reachable++ |
| + } |
| + } |
| + cards = append(cards, c) |
| + } |
| + return card, cards, warn |
| +} |
| + |
| +// handleAccountFleetTab serves GET /a/{sqid}/fleet. Membership in the |
| +// account is the whole gate: the page is a read, so a member sees exactly |
| +// what an owner does (there is no mutation on it to withhold). |
| +func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = "Fleet" |
| + p.ActiveNav = "fleet" |
| + shared, own, warn := s.assembleAccountFleet(r.Context(), acct) |
| + p.SharedFleet = shared |
| + p.AccountFleets = own |
| + if warn != "" && p.FlashErr == "" { |
| + p.FlashErr = warn |
| + } |
| + s.render(w, http.StatusOK, "account_fleet", p) |
| +} |
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go| new file mode 100644 |
| index 000000000..50b787b91 |
| --- /dev/null |
| +++ b/internal/console/accountfleet_test.go |
| @@ -0,0 +1,344 @@ |
| +package console |
| + |
| +// The account-level Fleet tab (accountfleet.go): GET /a/{sqid}/fleet, a |
| +// MEMBER-readable page that answers "where does this account's work run?" in |
| +// two halves — the shared platform fleet, presented purely from the public |
| +// status/fleet.json aggregate, and the account's own fleets from the |
| +// registry. The properties that matter, in the order they'd hurt if lost: |
| +// the page never 500s because one of its three independent reads had a bad |
| +// moment (the loadFleetPage/overview.go posture), it never shows another |
| +// account's fleets, and the shared card's "Active" claim tracks placement — |
| +// an account whose every route sits on its own fleet must not be told its |
| +// apps run on the shared one. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/status" |
| +) |
| + |
| +// seedSharedBoxes publishes a status/fleet.json carrying one box per region |
| +// name given, through the same BuildFleet/PublishFleet path production uses |
| +// (seedFleetJSON's shape, with the regions the tab renders as chips). |
| +func seedSharedBoxes(t *testing.T, fx *consoleFixture, regions ...string) { |
| + t.Helper() |
| + ctx := context.Background() |
| + for i, region := range regions { |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: time.Now(), |
| + Label: []string{"ada-stargazing", "grace-moonlit", "alan-seaside"}[i], |
| + Region: region, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + reg, err := registry.Open(fx.regPath) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + f, err := status.BuildFleet(ctx, fx.rel, routes, time.Now(), 5*time.Minute) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.PublishFleet(ctx, fx.rel, f); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// fixtureAccount is the account value the assemble-level tests pass in — the |
| +// same {ID, Sqid} pair the gated handler would hand it. |
| +func fixtureAccount(fx *consoleFixture) *Account { |
| + return &Account{ID: OpsAccountID, Sqid: fx.sqid, Name: "ops"} |
| +} |
| + |
| +// TestAccountFleetTabMemberReadable: the tab is a READ, so a plain member |
| +// sees it exactly as the owner does; a non-member gets the account gate's |
| +// byte-identical 404, the same answer an unrouted URL gives. |
| +func TestAccountFleetTabMemberReadable(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-acctfleet-gate") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + owner := newBrowser(t) |
| + signUp(t, fx, owner, "code-acctfleet-owner") |
| + st, _, body := get(t, owner, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("owner GET /fleet = %d, want 200:\n%s", st, body) |
| + } |
| + for _, want := range []string{"Shared fleet", "Your fleets", "acme", "Bring your own fleet"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("owner fleet tab missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + fx.setUser("sam@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-acctfleet-member") |
| + st, _, memberBody := get(t, member, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("member GET /fleet = %d, want 200:\n%s", st, memberBody) |
| + } |
| + for _, want := range []string{"Shared fleet", "acme"} { |
| + if !strings.Contains(memberBody, want) { |
| + t.Fatalf("member fleet tab missing %q in:\n%s", want, memberBody) |
| + } |
| + } |
| + |
| + fx.setUser("bob@keymail.dev") |
| + stranger := newBrowser(t) |
| + signUp(t, fx, stranger, "code-acctfleet-bob") |
| + nonMemberStatus, _, nonMemberBody := get(t, stranger, fx.u("/fleet")) |
| + ghostStatus, _, ghostBody := get(t, stranger, fx.ts.URL+"/definitely-not-a-page") |
| + if nonMemberStatus != http.StatusNotFound || nonMemberStatus != ghostStatus { |
| + t.Fatalf("non-member fleet tab = %d, unrouted page = %d; want both 404", nonMemberStatus, ghostStatus) |
| + } |
| + if nonMemberBody != ghostBody { |
| + t.Fatalf("non-member fleet tab body differs from an unrouted page's:\n%q\n%q", nonMemberBody, ghostBody) |
| + } |
| +} |
| + |
| +// TestAccountFleetTabNavLink: the tab is reachable — every account page |
| +// carries the Fleet nav entry, and it marks itself current on this one. |
| +func TestAccountFleetTabNavLink(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + // Nav LINKS carry the preferred segment — the account's name, "ops" for |
| + // the fixture's built-in account — while forms keep posting to the sqid |
| + // path (accountPage: Nav vs Base). |
| + st, _, apps := get(t, c, fx.u("/")) |
| + if st != http.StatusOK { |
| + t.Fatalf("account home = %d:\n%s", st, apps) |
| + } |
| + if !strings.Contains(apps, `href="/a/ops/fleet"`) { |
| + t.Fatalf("account home nav has no Fleet link:\n%s", apps) |
| + } |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| + } |
| + if !strings.Contains(body, `href="/a/ops/fleet" aria-current="page"`) { |
| + t.Fatalf("Fleet tab does not mark its own nav entry current:\n%s", body) |
| + } |
| +} |
| + |
| +// TestAccountFleetSharedCardFromFleetJSON: the shared card is a PRESENTATION |
| +// of the public aggregate — box count and the distinct regions, deduped and |
| +// sorted, straight off status/fleet.json. |
| +func TestAccountFleetSharedCardFromFleetJSON(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedSharedBoxes(t, fx, "UK · London", "EU · Ireland") |
| + |
| + card, _, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx)) |
| + if warn != "" { |
| + t.Fatalf("warn = %q, want none on a healthy read", warn) |
| + } |
| + if card.Boxes != 2 { |
| + t.Fatalf("card.Boxes = %d, want 2", card.Boxes) |
| + } |
| + if len(card.Regions) != 2 || card.Regions[0] != "EU · Ireland" || card.Regions[1] != "UK · London" { |
| + t.Fatalf("card.Regions = %v, want both regions, sorted", card.Regions) |
| + } |
| + |
| + // And they reach the page. |
| + c := signInPaul(t, fx) |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| + } |
| + for _, want := range []string{"EU · Ireland", "UK · London", "2 box"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("fleet tab missing %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| +// TestAccountFleetActiveFlipsWithPlacement: "Active" is a claim about THIS |
| +// account's routes — true while any of its apps still runs on the shared |
| +// fleet, false once every one of them is placed on a fleet of its own. An |
| +// account told "your apps run here" when they do not is the failure this |
| +// test exists to prevent. |
| +func TestAccountFleetActiveFlipsWithPlacement(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-acctfleet-active") |
| + reg := openTestReg(t, fx) |
| + if err := reg.Upsert(registry.Route{ |
| + Host: "kass.example.dev", Kind: "instance", App: "kass", Account: fx.sqid, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + acct := fixtureAccount(fx) |
| + card, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct) |
| + if !card.Active || card.PlatformApps != 1 { |
| + t.Fatalf("unplaced route: Active=%v PlatformApps=%d, want true/1", card.Active, card.PlatformApps) |
| + } |
| + |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + if st, body := postJSON(t, placeURL(fx, "kass"), map[string]string{"target": "fleet/acme"}, token); st != http.StatusOK { |
| + t.Fatalf("place = %d %v, want 200", st, body) |
| + } |
| + |
| + card, own, _ := fx.srv.assembleAccountFleet(context.Background(), acct) |
| + if card.Active || card.PlatformApps != 0 { |
| + t.Fatalf("every route placed: Active=%v PlatformApps=%d, want false/0", card.Active, card.PlatformApps) |
| + } |
| + if len(own) != 1 || own[0].Apps != 1 { |
| + t.Fatalf("own fleets = %+v, want acme carrying the one placed app", own) |
| + } |
| +} |
| + |
| +// TestAccountFleetListsOnlyOwnFleets: fleet names are one flat cross-account |
| +// namespace (fleets.go's package doc), so the index MUST filter by owner — |
| +// a fleet belonging to another account must not appear here any more than it |
| +// resolves on the detail page. |
| +func TestAccountFleetListsOnlyOwnFleets(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-acctfleet-own") |
| + reg := openTestReg(t, fx) |
| + if _, err := reg.CreateFleet(registry.Fleet{Name: "stranger", Account: "zzz", Bucket: "not-yours"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, name := range []string{"acme", "beta"} { |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": name, "bucket": name + "-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet %s = %d %v", name, st, body) |
| + } |
| + } |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/acme/boxes", |
| + map[string]string{"label": "pi-1"}, token); st != http.StatusOK { |
| + t.Fatalf("add-box = %d %v", st, body) |
| + } |
| + writeFleetHeartbeat(t, fx, "acme", status.Box{GeneratedAt: time.Now(), Label: "pi-1"}) |
| + |
| + _, own, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx)) |
| + if warn != "" { |
| + t.Fatalf("warn = %q, want none", warn) |
| + } |
| + if len(own) != 2 || own[0].Name != "acme" || own[1].Name != "beta" { |
| + t.Fatalf("own fleets = %+v, want exactly acme and beta", own) |
| + } |
| + if own[0].Boxes != 1 || own[0].Reachable != 1 { |
| + t.Fatalf("acme = %+v, want 1 box, 1 reachable", own[0]) |
| + } |
| + if own[1].Boxes != 0 || own[1].Reachable != 0 { |
| + t.Fatalf("beta = %+v, want no boxes", own[1]) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet = %d:\n%s", st, body) |
| + } |
| + if strings.Contains(body, "stranger") { |
| + t.Fatalf("another account's fleet leaked onto the tab:\n%s", body) |
| + } |
| + for _, want := range []string{`href="/a/ops/fleets/acme"`, `href="/a/ops/fleets/beta"`} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("fleet tab missing link %q in:\n%s", want, body) |
| + } |
| + } |
| +} |
| + |
| +// TestAccountFleetDegradesWithoutSnapshot: a deployment whose status-lease |
| +// holder has never published status/fleet.json still serves the tab — the |
| +// shared card renders its empty state, and the account's own fleets (a |
| +// completely independent read) are unaffected. |
| +func TestAccountFleetDegradesWithoutSnapshot(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-acctfleet-nosnapshot") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet with no published snapshot = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "No shared boxes are reporting right now.") { |
| + t.Fatalf("missing the shared card's empty state:\n%s", body) |
| + } |
| + if !strings.Contains(body, "acme") { |
| + t.Fatalf("the account's own fleets (an independent read) went missing too:\n%s", body) |
| + } |
| +} |
| + |
| +// TestAccountFleetUnreadableRegistryClaimsNothing: when the read that |
| +// decides "do your apps run on the shared fleet?" fails, the card must not |
| +// fall through to its zero value and announce "none of your apps run |
| +// here" — a positive claim about routes it never managed to look at. "Could |
| +// not check" and "checked, and no" are different sentences. |
| +func TestAccountFleetUnreadableRegistryClaimsNothing(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + seedSharedBoxes(t, fx, "EU · Ireland") |
| + c := signInPaul(t, fx) |
| + // A path whose parent directory does not exist: registry.Open fails, and |
| + // a failed open is never cached (openRegistry, view.go). |
| + fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "gone", "registry.db") |
| + |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet with an unreadable registry = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| + } |
| + if strings.Contains(body, "None of your apps run on the shared fleet") || |
| + strings.Contains(body, "Not used") { |
| + t.Fatalf("an unread registry rendered as a positive \"not used\" verdict:\n%s", body) |
| + } |
| + if !strings.Contains(body, "could not be read just now") { |
| + t.Fatalf("missing the honest unknown-placement state:\n%s", body) |
| + } |
| + // The half that DID read is still shown. |
| + if !strings.Contains(body, "EU · Ireland") { |
| + t.Fatalf("the shared card's own (successful) read went missing:\n%s", body) |
| + } |
| +} |
| + |
| +// TestAccountFleetDegradesOnStoreFailure: a bucket having a moment must cost |
| +// the shared card, never the page — the fleets index is registry-side and |
| +// keeps rendering, with a flash that says which half is unknown. |
| +func TestAccountFleetDegradesOnStoreFailure(t *testing.T) { |
| + fx, token := fleetFixture(t, "code-acctfleet-storefail") |
| + if st, body := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", |
| + map[string]string{"name": "acme", "bucket": "acme-data"}, token); st != http.StatusOK { |
| + t.Fatalf("create fleet = %d %v", st, body) |
| + } |
| + seedSharedBoxes(t, fx, "EU · Ireland") |
| + |
| + c := signInPaul(t, fx) |
| + // Only the aggregate itself is unreadable: the per-fleet heartbeat reads |
| + // still work, so a degraded shared card is the ONLY thing this costs. |
| + fx.srv.cfg.Releases = errOnKeyStore{Store: fx.rel, |
| + key: "status/fleet.json", err: errors.New("bucket having a moment")} |
| + |
| + st, _, body := get(t, c, fx.u("/fleet")) |
| + if st != http.StatusOK { |
| + t.Fatalf("GET /fleet with an unreadable store = %d, want 200 (degrade, don't 500):\n%s", st, body) |
| + } |
| + if !strings.Contains(body, "acme") { |
| + t.Fatalf("the registry-side fleets index went missing on a BUCKET failure:\n%s", body) |
| + } |
| + if !strings.Contains(body, "Could not read the shared fleet") { |
| + t.Fatalf("a failed shared-fleet read rendered as if nothing were wrong:\n%s", body) |
| + } |
| + if strings.Contains(body, "EU · Ireland") { |
| + t.Fatalf("a failed read still showed the last-known regions:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 4f3462d0e..387981fb0 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -315,6 +315,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // Account-scoped and explicit: /a/<sqid>/apps/<app> rather than |
| // account-by-session, so a link a teammate opens lands where you meant. |
| s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome) |
| + s.mux.HandleFunc("GET /a/{sqid}/fleet", s.handleAccountFleetTab) |
| s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam) |
| s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings) |
| // Go 1.22's mux prefers this literal over apps/{app} below — exactly why |
| @@ -446,7 +447,7 @@ type page struct { |
| Error string |
| Welcome bool // one-shot "account created" banner |
| - ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings" |
| + ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings" |
| ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings" |
| AppName string // app pages: the {app} segment, for the tab bar and eyebrow |
| @@ -540,6 +541,15 @@ type page struct { |
| FleetDetail *registry.Fleet |
| FleetBoxes []fleetPageBox |
| FleetApps []fleetPagePlacedApp |
| + |
| + // /a/{sqid}/fleet (accountfleet.go): the account-level Fleet TAB, which |
| + // is neither of the two pages above — the shared platform fleet as this |
| + // account sees it (built from the PUBLIC status/fleet.json aggregate, |
| + // never the raw per-box snapshots) and an index of the account's own |
| + // fleets. Set only by handleAccountFleetTab; every other page leaves |
| + // SharedFleet nil and renders no such section. |
| + SharedFleet *sharedFleetCard |
| + AccountFleets []accountFleetCard |
| // RotatedLabel/RotatedToken are set only on the response to a rotate |
| // POST, to show the fresh bearer token exactly once — never stored, |
| // never re-derivable from a later view of this same page. |
diff --git a/internal/console/templates/account_fleet.html b/internal/console/templates/account_fleet.html| new file mode 100644 |
| index 000000000..9b13729d5 |
| --- /dev/null |
| +++ b/internal/console/templates/account_fleet.html |
| @@ -0,0 +1,45 @@ |
| +{{define "account_fleet"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">Fleet · {{.Account.Name}}</p> |
| + <h1>Fleet</h1> |
| + <p class="lede">Where this account's apps run — the shared fleet, and boxes you bring.</p> |
| +</div> |
| + |
| +<h2>{{.SharedFleet.Title}} |
| + {{if .SharedFleet.PlacementKnown}}{{if .SharedFleet.Active}}<span class="chip chip-env">Active</span> |
| + {{else}}<span class="chip chip-env">Not used</span>{{end}}{{end}}</h2> |
| +{{if .SharedFleet.Boxes}} |
| +<p>{{.SharedFleet.Boxes}} box{{if ne .SharedFleet.Boxes 1}}es{{end}}{{if .SharedFleet.Regions}} in |
| + {{range .SharedFleet.Regions}}<span class="chip chip-env">{{.}}</span> {{end}}{{end}}</p> |
| +{{else}}<p class="small">No shared boxes are reporting right now.</p>{{end}} |
| +{{if .SharedFleet.PlacementKnown}} |
| +<p class="small">{{if .SharedFleet.Active}}{{.SharedFleet.PlatformApps}} of your apps |
| + {{if eq .SharedFleet.PlatformApps 1}}runs{{else}}run{{end}} here. Apps run on the shared fleet |
| + unless you place them on a fleet of your own.{{else}}None of your apps run on the shared |
| + fleet.{{end}}</p> |
| +{{else}}<p class="small">Whether your apps run here could not be read just now.</p>{{end}} |
| + |
| +<h2>Your fleets</h2> |
| +{{if .AccountFleets}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Fleet</th><th>Boxes</th><th>Reachable</th><th>Placed apps</th></tr> |
| + {{range .AccountFleets}} |
| + <tr> |
| + <td><a href="{{$.Nav}}/fleets/{{.Name}}">{{.Name}}</a></td> |
| + <td>{{.Boxes}}</td> |
| + <td>{{.Reachable}}</td> |
| + <td>{{.Apps}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No fleets of your own yet — the shared fleet runs everything.</p>{{end}} |
| + |
| +<h2>Bring your own fleet</h2> |
| +<p>Run your apps on hardware you own — an office box, a Raspberry Pi, your own cloud account. |
| +Your boxes dial out to the platform; nothing connects in.</p> |
| +<p><a class="btn" href="{{.Nav}}/fleet/byo">How to add and harden a box</a></p> |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 55235fdab..36ceb9555 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -314,6 +314,7 @@ |
| {{if .Address}}<nav> |
| {{if .Account}} |
| <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a> |
| + <a href="{{.Nav}}/fleet"{{if eq .ActiveNav "fleet"}} aria-current="page"{{end}}>Fleet</a> |
| <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a> |
| <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a> |
| {{end}} |