carlos / platform Public

console: the Fleet tab — an empty fleets index is a claim, not a default

Review finding on the fleets-index empty state: both registry-failure
paths leave AccountFleets nil, so "No fleets of your own yet — the
shared fleet runs everything" rendered from a read that failed — a
positive claim about fleets AND placement, sitting right beside the
shared card's honest degraded copy. The same defect PlacementKnown was
added to remove, one section lower.

Fixed both ways round: the placement clause is gone (it has no business
in a fleets empty state at all), and assembleAccountFleet now returns
fleetsKnown, so the section renders rows, "No fleets of your own yet.",
or "Your fleets could not be read just now." — three states, because
nil was two of them at once.

TestAccountFleetUnreadableRegistryClaimsNothing now holds BOTH sections
to that standard (verified failing against the old wording), and
TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned pins the positive
half: a healthy registry with no fleets still says so plainly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 89c89d328c03f49b713d65690c88f0f687e7aa90 parent f663ead
4 files changed, +65 −23
  • internal/console/accountfleet.go +18 −12
  • internal/console/accountfleet_test.go +40 −10
  • internal/console/server.go +5 −0
  • internal/console/templates/account_fleet.html +2 −1
diff --git a/internal/console/accountfleet.go b/internal/console/accountfleet.go
index 94d726086..b4f6fd98c 100644
--- a/internal/console/accountfleet.go
+++ b/internal/console/accountfleet.go
@@ -72,9 +72,15 @@ type accountFleetCard struct {
// 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
+//
+// fleetsKnown is the fleets index's twin of sharedFleetCard.PlacementKnown,
+// and exists for the identical reason: a nil slice is the SAME value for
+// "this account has no fleets" and "the read that would have listed them
+// failed", and the first of those is a positive claim ("No fleets of your
+// own yet") that a failed registry read has not earned. False means the
+// section renders "unknown", never "none".
+func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (card *sharedFleetCard, fleets []accountFleetCard, fleetsKnown bool, warn string) {
+ card = &sharedFleetCard{Title: "Shared fleet"}
// A console with no deployment bucket configured has no aggregate to
// read at all — that is the empty card, not an error.
@@ -109,7 +115,7 @@ func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*shar
if warn == "" {
warn = "Could not reach the fleet registry."
}
- return card, nil, warn
+ return card, nil, false, warn
}
// The placement split. A route whose Region parses as a fleet placement
@@ -143,23 +149,22 @@ func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*shar
card.PlacementKnown = true
}
- fleets, err := reg.ListFleets()
+ rows, 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
+ return card, nil, false, warn
}
- var cards []accountFleetCard
- for i := range fleets {
+ for i := range rows {
// 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]
+ f := &rows[i]
if f.Account != acct.Sqid {
continue
}
@@ -175,9 +180,9 @@ func (s *Server) assembleAccountFleet(ctx context.Context, acct *Account) (*shar
c.Reachable++
}
}
- cards = append(cards, c)
+ fleets = append(fleets, c)
}
- return card, cards, warn
+ return card, fleets, true, warn
}
// handleAccountFleetTab serves GET /a/{sqid}/fleet. Membership in the
@@ -191,9 +196,10 @@ func (s *Server) handleAccountFleetTab(w http.ResponseWriter, r *http.Request) {
p := s.accountPage(w, r, addr, raw, acct, role)
p.Title = "Fleet"
p.ActiveNav = "fleet"
- shared, own, warn := s.assembleAccountFleet(r.Context(), acct)
+ shared, own, ownKnown, warn := s.assembleAccountFleet(r.Context(), acct)
p.SharedFleet = shared
p.AccountFleets = own
+ p.FleetsKnown = ownKnown
if warn != "" && p.FlashErr == "" {
p.FlashErr = warn
}
diff --git a/internal/console/accountfleet_test.go b/internal/console/accountfleet_test.go
index 50b787b91..3975af76a 100644
--- a/internal/console/accountfleet_test.go
+++ b/internal/console/accountfleet_test.go
@@ -145,7 +145,7 @@ func TestAccountFleetSharedCardFromFleetJSON(t *testing.T) {
fx := newConsoleFixture(t)
seedSharedBoxes(t, fx, "UK · London", "EU · Ireland")
- card, _, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
+ card, _, _, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
if warn != "" {
t.Fatalf("warn = %q, want none on a healthy read", warn)
}
@@ -184,7 +184,7 @@ func TestAccountFleetActiveFlipsWithPlacement(t *testing.T) {
}
acct := fixtureAccount(fx)
- card, _, _ := fx.srv.assembleAccountFleet(context.Background(), acct)
+ 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)
}
@@ -197,7 +197,7 @@ func TestAccountFleetActiveFlipsWithPlacement(t *testing.T) {
t.Fatalf("place = %d %v, want 200", st, body)
}
- card, own, _ := fx.srv.assembleAccountFleet(context.Background(), acct)
+ 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)
}
@@ -228,10 +228,13 @@ func TestAccountFleetListsOnlyOwnFleets(t *testing.T) {
}
writeFleetHeartbeat(t, fx, "acme", status.Box{GeneratedAt: time.Now(), Label: "pi-1"})
- _, own, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
+ _, own, ownKnown, warn := fx.srv.assembleAccountFleet(context.Background(), fixtureAccount(fx))
if warn != "" {
t.Fatalf("warn = %q, want none", warn)
}
+ if !ownKnown {
+ t.Fatalf("fleetsKnown = false on a healthy registry read")
+ }
if len(own) != 2 || own[0].Name != "acme" || own[1].Name != "beta" {
t.Fatalf("own fleets = %+v, want exactly acme and beta", own)
}
@@ -281,11 +284,12 @@ func TestAccountFleetDegradesWithoutSnapshot(t *testing.T) {
}
}
-// 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.
+// TestAccountFleetUnreadableRegistryClaimsNothing: one failed registry read
+// takes out BOTH registry-fed sections, and neither may fall through to its
+// zero value and make a positive claim from it — not the card ("none of your
+// apps run here", about routes it never looked at) and not the index ("no
+// fleets of your own yet", about a list it never got). "Could not check" and
+// "checked, and no" are different sentences in both places.
func TestAccountFleetUnreadableRegistryClaimsNothing(t *testing.T) {
fx := newConsoleFixture(t)
seedSharedBoxes(t, fx, "EU · Ireland")
@@ -302,15 +306,41 @@ func TestAccountFleetUnreadableRegistryClaimsNothing(t *testing.T) {
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") {
+ if !strings.Contains(body, "Whether your apps run here could not be read just now.") {
t.Fatalf("missing the honest unknown-placement state:\n%s", body)
}
+ if strings.Contains(body, "No fleets of your own yet") {
+ t.Fatalf("an unread registry rendered as a positive \"you have no fleets\" verdict:\n%s", body)
+ }
+ if !strings.Contains(body, "Your fleets could not be read just now.") {
+ t.Fatalf("missing the honest unknown-fleets 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)
}
}
+// TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned is the positive half of
+// the test above: a HEALTHY registry that genuinely holds no fleets for this
+// account does get the flat "none" wording — the honest-unknown copy must not
+// have swallowed the ordinary empty state along with the lie.
+func TestAccountFleetEmptyStatesAreClaimsOnlyWhenEarned(t *testing.T) {
+ fx := newConsoleFixture(t)
+ 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, "No fleets of your own yet.") {
+ t.Fatalf("an account with no fleets and a healthy registry lost its empty state:\n%s", body)
+ }
+ if strings.Contains(body, "could not be read just now") {
+ t.Fatalf("a healthy read rendered as unknown:\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.
diff --git a/internal/console/server.go b/internal/console/server.go
index 387981fb0..8835cce5a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -548,8 +548,13 @@ type page struct {
// 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.
+ // FleetsKnown says the AccountFleets read SUCCEEDED, so an empty slice
+ // may be rendered as "no fleets yet" rather than "unknown" — the twin of
+ // sharedFleetCard.PlacementKnown, and load-bearing for the same reason:
+ // nil is otherwise both answers at once.
SharedFleet *sharedFleetCard
AccountFleets []accountFleetCard
+ FleetsKnown bool
// 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
index 9b13729d5..833f4acd2 100644
--- a/internal/console/templates/account_fleet.html
+++ b/internal/console/templates/account_fleet.html
@@ -36,7 +36,8 @@
{{end}}
</table>
</div>
-{{else}}<p class="small">No fleets of your own yet — the shared fleet runs everything.</p>{{end}}
+{{else if .FleetsKnown}}<p class="small">No fleets of your own yet.</p>
+{{else}}<p class="small">Your fleets could not be read just now.</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.