carlos / platform Public

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>
Paul Campbell f663ead4a6dc4b1bd5f4062125c3dd73bb0d7ec4 parent 40442b0
5 files changed, +602 −1
  • internal/console/accountfleet.go +201 −0
  • internal/console/accountfleet_test.go +344 −0
  • internal/console/server.go +11 −1
  • internal/console/templates/account_fleet.html +45 −0
  • internal/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}}