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>
4 files changed,
+65
−23
internal/console/accountfleet.go+18 −12internal/console/accountfleet_test.go+40 −10internal/console/server.go+5 −0internal/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. |