carlos / platform Public

console: the operator role — box-granted, UI-projected

Adds a fleet-operator role gated the same way app ownership is: a
box-side-only bit (console grant/revoke -operator <address>) that a
signed-in address either has or doesn't. GET /fleet decodes
status/fleet.json (via the console's existing release.Store handle,
into internal/status's own Fleet type) into a server-rendered private
mirror of the public status page, with identical column order so the
two read as one product. Non-operators — signed out, signed in but
ungranted, or a store error mid-check — all get the exact same
http.NotFound an unregistered route gets, so the role's existence
stays unprobeable. A missing fleet.json renders the page's empty
state, never a 500, and the view stays readable in keyless
(read-only) deployments since it's a read.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 3685590e09ca56e092025b2003b000e54355d1de parent 2817c76
8 files changed, +431 −10
  • cmd/console/main.go +26 −8
  • internal/console/server.go +77 −0
  • internal/console/server_test.go +110 −0
  • internal/console/store.go +32 −2
  • internal/console/store_test.go +54 −0
  • internal/console/templates/fleet.html +88 −0
  • internal/console/templates/layout.html +18 −0
  • internal/console/view.go +26 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 0f6252585..76583271b 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -10,6 +10,8 @@
// console serve run the HTTP server
// console grant <address> <app> make address an owner of app
// console revoke <address> <app> remove that ownership
+// console grant -operator <address> make address a fleet operator
+// console revoke -operator <address> remove that operator role
// console owners list every owner and their apps
//
// serve reads: CONSOLE_ADDR (default 127.0.0.1:8090), CONSOLE_DB (default
@@ -24,6 +26,7 @@ package main
import (
"context"
"crypto/ed25519"
+ "flag"
"fmt"
"log"
"net/http"
@@ -44,15 +47,30 @@ func main() {
case "serve":
serve()
case "grant", "revoke":
- if len(os.Args) != 4 {
- log.Fatalf("usage: console %s <address> <app>", os.Args[1])
- }
+ verb := os.Args[1]
+ fs := flag.NewFlagSet(verb, flag.ExitOnError)
+ operator := fs.Bool("operator", false, "grant/revoke the fleet-operator role instead of an app")
+ fs.Parse(os.Args[2:])
+ args := fs.Args()
st := openConsoleDB()
var err error
- if os.Args[1] == "grant" {
- err = st.Grant(os.Args[2], os.Args[3])
- } else {
- err = st.Revoke(os.Args[2], os.Args[3])
+ switch {
+ case *operator:
+ // Box-side only, exactly like an app grant: no app argument, and
+ // it works whether or not the address has ever signed in.
+ if len(args) != 1 {
+ log.Fatalf("usage: console %s -operator <address>", verb)
+ }
+ err = st.SetOperator(args[0], verb == "grant")
+ default:
+ if len(args) != 2 {
+ log.Fatalf("usage: console %s <address> <app>", verb)
+ }
+ if verb == "grant" {
+ err = st.Grant(args[0], args[1])
+ } else {
+ err = st.Revoke(args[0], args[1])
+ }
}
if err != nil {
log.Fatal(err)
@@ -77,7 +95,7 @@ func main() {
}
func usage() {
- log.Fatal("usage: console serve | grant <address> <app> | revoke <address> <app> | owners")
+ log.Fatal("usage: console serve | grant [-operator] <address> [<app>] | revoke [-operator] <address> [<app>] | owners")
}
func serve() {
diff --git a/internal/console/server.go b/internal/console/server.go
index 1bf0eefbf..2f1392633 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -23,6 +23,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
)
//go:embed templates/*.html
@@ -30,6 +31,44 @@ var tmplFS embed.FS
var templates = template.Must(template.New("").Funcs(template.FuncMap{
"fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") },
+ // The fleet template's em-dash helpers: fleet.json's null-propagation
+ // rule (internal/status/fleet.go) means these *string/*int/*bool fields
+ // are unknown, not zero, and unknown must render as a titled em-dash —
+ // never a blank cell or a guessed value — same rule index.html's JS
+ // enforces client-side (see asText/asBool there).
+ "strOrDash": func(v *string) string {
+ if v == nil {
+ return "—"
+ }
+ return *v
+ },
+ "intOrDash": func(v *int) string {
+ if v == nil {
+ return "—"
+ }
+ return fmt.Sprint(*v)
+ },
+ "boolWord": func(v *bool, whenTrue, whenFalse string) string {
+ if v == nil {
+ return "—"
+ }
+ if *v {
+ return whenTrue
+ }
+ return whenFalse
+ },
+ "emptyOrDash": func(s string) string {
+ if s == "" {
+ return "—"
+ }
+ return s
+ },
+ // mapGet reads one named field out of Fleet.Instances — a map so a
+ // template range would iterate it key-sorted, alphabetically, which
+ // scrambles the static/resident/hibernating/awake/asleep order the
+ // public page and the design spec both fix; indexing by name keeps that
+ // order explicit in the template instead.
+ "mapGet": func(m map[string]*int, k string) *int { return m[k] },
}).ParseFS(tmplFS, "templates/*.html"))
const (
@@ -92,6 +131,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /auth/callback", s.handleCallback)
s.mux.HandleFunc("GET /auth/confirm", s.handleConfirm)
s.mux.HandleFunc("POST /auth/signout", s.handleSignout)
+ s.mux.HandleFunc("GET /fleet", s.handleFleet)
s.mux.HandleFunc("GET /apps", s.handleApps)
s.mux.HandleFunc("POST /apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /apps/{app}", s.handleApp)
@@ -131,6 +171,8 @@ type page struct {
Instances *InstanceReport // /apps/{app}: the app's self-report, nil when unpolled
Events []Event // /apps/{app}: recent console actions, newest first
CanPromote bool // a signing key is configured on this host
+
+ Fleet *status.Fleet // /fleet: nil means no snapshot published yet
}
// appRow is one line of the "your apps" table.
@@ -402,6 +444,41 @@ func (s *Server) handleSignout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+// ---- fleet (operator-only) ---------------------------------------------------
+
+// handleFleet serves the private fleet view: everything status/fleet.json
+// carries, server-rendered, for signed-in operators only. Every non-operator
+// path — signed out, signed in but not an operator, or a store error reading
+// the operator bit — gets exactly http.NotFound, the identical response an
+// unregistered URL gets from this mux. Role existence is not probeable: a
+// styled "not found" page (like ownedApp's) would itself reveal that /fleet
+// is a real, gated route, so this deliberately skips renderMessage.
+func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) {
+ addr, raw, ok := s.session(r)
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ isOp, err := s.store.IsOperator(addr)
+ if err != nil {
+ log.Printf("console: IsOperator %s: %v", addr, err)
+ http.NotFound(w, r)
+ return
+ }
+ if !isOp {
+ http.NotFound(w, r)
+ return
+ }
+ f, err := FleetView(r.Context(), s.cfg.Releases)
+ if err != nil {
+ log.Printf("console: fleet view: %v", err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read fleet status. Please try again.")
+ return
+ }
+ p := page{Title: "Fleet status", Address: addr, CSRF: raw, Fleet: f}
+ s.render(w, http.StatusOK, "fleet", p)
+}
+
// ---- app pages --------------------------------------------------------------
func (s *Server) handleApps(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index e101f790d..4bd310957 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -18,6 +18,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
)
// consoleFixture is a whole console: fixture platform (registry + bucket
@@ -826,3 +827,112 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
t.Fatalf("keyless console touched canary/x (%s)", p.Version)
}
}
+
+// seedFleetJSON writes a small status/fleet.json fixture through the real
+// production path (one box snapshot, then BuildFleet + PublishFleet reading
+// the fixture registry's routes) rather than hand-rolling JSON — so the test
+// exercises the same shape /fleet will see in production.
+func seedFleetJSON(t *testing.T, fx *consoleFixture) *status.Fleet {
+ t.Helper()
+ ctx := context.Background()
+ if err := status.WriteBox(ctx, fx.rel, status.Box{
+ GeneratedAt: time.Now(),
+ Label: "ada-stargazing",
+ Region: "eu-west-1",
+ Routes: status.RouteCounts{Static: 2, Resident: 1},
+ }); 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)
+ }
+ return f
+}
+
+// TestFleetOperatorOnly is Task 9's core gate: signed out, signed in but not
+// an operator, and a revoked operator all get exactly the same 404 an
+// actually-missing route gets — role existence must not be probeable.
+// Granting flips the same session straight into the rendered fleet view.
+func TestFleetOperatorOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ seedFleetJSON(t, fx)
+
+ out := newBrowser(t)
+ statusGhost, _, bodyGhost := get(t, out, fx.ts.URL+"/this-route-does-not-exist")
+ if statusGhost != http.StatusNotFound {
+ t.Fatalf("sanity: a missing route = %d, want 404", statusGhost)
+ }
+ if status, _, body := get(t, out, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("signed-out /fleet = %d %q, want the same 404 as a missing route (%d %q)", status, body, statusGhost, bodyGhost)
+ }
+
+ // Signed in and owns kass, but is not an operator: still the same 404.
+ owner := signInPaul(t, fx)
+ if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("non-operator /fleet = %d %q, want the same 404 as a missing route", status, body)
+ }
+
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, owner, fx.ts.URL+"/fleet")
+ if status != 200 || !strings.Contains(body, "Fleet status") {
+ t.Errorf("operator fleet view: %d %s", status, body)
+ }
+ for _, want := range []string{"ada-stargazing", "eu-west-1", "Boxes", "Apps", "Services"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/fleet missing %q in:\n%s", want, body)
+ }
+ }
+
+ // Revoked: back to the exact same 404.
+ if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil {
+ t.Fatal(err)
+ }
+ if status, _, body := get(t, owner, fx.ts.URL+"/fleet"); status != http.StatusNotFound || body != bodyGhost {
+ t.Fatalf("revoked operator /fleet = %d %q, want the same 404", status, body)
+ }
+}
+
+// TestFleetEmptyState: a deployment where the status-lease holder has never
+// published status/fleet.json must render the page's empty state, never a
+// 500 — the object simply not existing yet is not an error.
+func TestFleetEmptyState(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, c, fx.ts.URL+"/fleet")
+ if status != 200 || !strings.Contains(body, "Fleet status") || !strings.Contains(body, "No snapshots yet") {
+ t.Fatalf("empty fleet state: %d %s", status, body)
+ }
+}
+
+// TestFleetKeylessReadable: /fleet is a read, so a deployment with no
+// CARLOS_RELEASE_KEY configured (promotions disabled, console read-only)
+// must still serve it to an operator.
+func TestFleetKeylessReadable(t *testing.T) {
+ fx := newConsoleFixtureKeyed(t, false)
+ seedFleetJSON(t, fx)
+ c := signInPaul(t, fx)
+ if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ status, _, body := get(t, c, fx.ts.URL+"/fleet")
+ if status != 200 || !strings.Contains(body, "Fleet status") {
+ t.Fatalf("keyless /fleet = %d %s, want 200 with Fleet status", status, body)
+ }
+}
diff --git a/internal/console/store.go b/internal/console/store.go
index 47ab808a7..ea6d73ea3 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -132,8 +132,10 @@ func (s *Store) migrate() error {
}
}
// Additive column changes land here, forgiving "duplicate column" so
- // re-running is always safe. (None yet in slice 1.)
- for _, stmt := range []string{} {
+ // re-running is always safe.
+ for _, stmt := range []string{
+ `ALTER TABLE users ADD COLUMN operator INTEGER NOT NULL DEFAULT 0;`,
+ } {
if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") {
return err
}
@@ -235,6 +237,34 @@ ON CONFLICT(address) DO NOTHING`, address, now.Unix())
return n > 0, err
}
+// SetOperator flips address's fleet-operator bit — box-side only, exactly
+// like an app grant: it works whether or not address has ever signed in,
+// upserting a users row if none exists yet (leaving created_at at "now" for
+// a brand-new row, untouched for an existing one).
+func (s *Store) SetOperator(address string, on bool) error {
+ v := 0
+ if on {
+ v = 1
+ }
+ _, err := s.db.Exec(`INSERT INTO users (address, created_at, operator) VALUES (?, ?, ?)
+ON CONFLICT(address) DO UPDATE SET operator = excluded.operator`, address, time.Now().Unix(), v)
+ return err
+}
+
+// IsOperator reports whether address holds the fleet-operator bit. An
+// address with no users row (never signed in) is not an operator.
+func (s *Store) IsOperator(address string) (bool, error) {
+ var v int
+ err := s.db.QueryRow(`SELECT operator FROM users WHERE address = ?`, address).Scan(&v)
+ if err == sql.ErrNoRows {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ return v != 0, nil
+}
+
// ---- grants (the console's Keymail access tokens) ---------------------------
// SetGrantToken stores the Keymail access token the user's OAuth approval
diff --git a/internal/console/store_test.go b/internal/console/store_test.go
index 0e2b4b142..162fb2603 100644
--- a/internal/console/store_test.go
+++ b/internal/console/store_test.go
@@ -265,6 +265,60 @@ func TestAuditEvents(t *testing.T) {
}
}
+func TestOperatorRoundTrip(t *testing.T) {
+ s := openTestStore(t)
+ if op, err := s.IsOperator("paul@keymail.dev"); err != nil || op {
+ t.Fatalf("IsOperator before any grant = %v %v, want false nil", op, err)
+ }
+ // Box-side only: SetOperator works even though this address has never
+ // signed in (no users row from EnsureUser), exactly like an app grant.
+ if err := s.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ if op, err := s.IsOperator("paul@keymail.dev"); err != nil || !op {
+ t.Fatalf("IsOperator after grant = %v %v, want true nil", op, err)
+ }
+ if op, err := s.IsOperator("bob@keymail.dev"); err != nil || op {
+ t.Fatalf("IsOperator(bob) = %v %v, want false nil", op, err)
+ }
+ // A real sign-up (EnsureUser) after the operator bit was set must not
+ // clobber it back to 0.
+ if _, err := s.EnsureUser("paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if op, err := s.IsOperator("paul@keymail.dev"); err != nil || !op {
+ t.Fatalf("IsOperator after EnsureUser = %v %v, want true nil (grant survives sign-up)", op, err)
+ }
+ if err := s.SetOperator("paul@keymail.dev", false); err != nil {
+ t.Fatal(err)
+ }
+ if op, err := s.IsOperator("paul@keymail.dev"); err != nil || op {
+ t.Fatalf("IsOperator after revoke = %v %v, want false nil", op, err)
+ }
+}
+
+func TestOperatorMigrationIdempotent(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "console.db")
+ s, err := Open(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.SetOperator("paul@keymail.dev", true); err != nil {
+ t.Fatal(err)
+ }
+ s.Close()
+ // Re-running migrate (every Open) must not fail on the operator column
+ // already existing, and the bit must survive the reopen.
+ s2, err := Open(path)
+ if err != nil {
+ t.Fatalf("reopen: %v", err)
+ }
+ defer s2.Close()
+ if op, err := s2.IsOperator("paul@keymail.dev"); err != nil || !op {
+ t.Fatalf("IsOperator after reopen = %v %v, want true nil", op, err)
+ }
+}
+
func TestCreateAppClaimOnce(t *testing.T) {
s := openTestStore(t)
now := time.Now()
diff --git a/internal/console/templates/fleet.html b/internal/console/templates/fleet.html
new file mode 100644
index 000000000..1348914ed
--- /dev/null
+++ b/internal/console/templates/fleet.html
@@ -0,0 +1,88 @@
+{{define "fleet"}}{{template "top" .}}
+<h1>Fleet status</h1>
+{{if .Fleet}}
+<p class="small">Generated {{fmtTime .Fleet.GeneratedAt}}</p>
+
+<section class="tiles" aria-label="Fleet summary">
+ <div class="tile">
+ <h2>Instances</h2>
+ <p class="tile-value">{{intOrDash (mapGet .Fleet.Instances "total")}}</p>
+ <p class="tile-detail">
+ static {{intOrDash (mapGet .Fleet.Instances "static")}} ·
+ resident {{intOrDash (mapGet .Fleet.Instances "resident")}} ·
+ hibernating {{intOrDash (mapGet .Fleet.Instances "hibernating")}} ·
+ awake {{intOrDash (mapGet .Fleet.Instances "awake")}} ·
+ asleep {{intOrDash (mapGet .Fleet.Instances "asleep")}}
+ </p>
+ </div>
+ <div class="tile">
+ <h2>Capacity</h2>
+ <p class="tile-value">{{strOrDash .Fleet.Capacity.Level}}</p>
+ </div>
+ <div class="tile">
+ <h2>Replication</h2>
+ <p class="tile-value">{{boolWord .Fleet.Replication.Healthy "healthy" "unhealthy"}}</p>
+ </div>
+</section>
+
+<h2>Boxes</h2>
+{{if .Fleet.Boxes}}
+<div class="tablewrap">
+<table>
+ <tr>
+ <th>Label</th><th>Region</th><th>Load</th>
+ <th>Static</th><th>Resident</th><th>Hibernating</th><th>Awake</th><th>Asleep</th>
+ <th></th>
+ </tr>
+ {{range .Fleet.Boxes}}
+ <tr>
+ <td>{{.Label}}</td>
+ <td>{{emptyOrDash .Region}}</td>
+ <td>{{strOrDash .Load}}</td>
+ <td>{{.Routes.Static}}</td>
+ <td>{{.Routes.Resident}}</td>
+ <td>{{.Routes.Hibernating}}</td>
+ <td>{{intOrDash .Routes.Awake}}</td>
+ <td>{{intOrDash .Routes.Asleep}}</td>
+ <td>{{if .Stale}}<span class="chip chip-stale">stale</span>{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No box snapshots yet.</p>{{end}}
+
+<h2>Apps</h2>
+{{if .Fleet.Apps}}
+<div class="tablewrap">
+<table>
+ <tr><th>Name</th><th>Instances</th><th>Healthy</th><th>Channel</th><th>Version</th></tr>
+ {{range .Fleet.Apps}}
+ <tr>
+ <td>{{.Name}}</td>
+ <td>{{.Instances}}</td>
+ <td>{{if .Healthy}}healthy{{else}}unhealthy{{end}}</td>
+ <td>{{.Channel}}</td>
+ <td>{{.Version}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">No opted-in apps yet.</p>{{end}}
+{{if .Fleet.AppsUnlisted}}
+<p class="small">{{.Fleet.AppsUnlisted}} more app{{if ne .Fleet.AppsUnlisted 1}}s{{end}} running, not publicly named.</p>
+{{end}}
+
+<h2>Services</h2>
+{{if .Fleet.Services}}
+<div class="chips">
+ {{range .Fleet.Services}}
+ <span class="chip {{if .Up}}chip-up{{else}}chip-down{{end}}">{{.Name}} {{if .Up}}up{{else}}down{{end}}</span>
+ {{end}}
+</div>
+{{else}}<p class="small">No services reporting.</p>{{end}}
+
+<p class="small">Aggregate counts and status words only — never hostnames, machine identifiers, or raw utilization figures; anything this page doesn't know shows as an em-dash, never guessed.</p>
+{{else}}
+<p>No snapshots yet — the fleet status writer hasn't published <code>status/fleet.json</code> on this deployment.</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 1990cb678..e468520d2 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -64,6 +64,24 @@
}
.tablewrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; }
+ .tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; }
+ .tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; }
+ .tile h2 {
+ margin: 0 0 0.35rem; font-size: 0.78rem; text-transform: uppercase;
+ letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
+ }
+ .tile-value { font-size: 1.6rem; font-weight: 600; margin: 0; }
+ .tile-detail { color: var(--muted); font-size: 0.8rem; margin: 0.25rem 0 0; }
+ /* .chip-stale is bordered-only, no fill — a "trust this less" flag, not
+ an outage marker, matching internal/status/assets/status.css. */
+ .chip {
+ display: inline-block; font-size: 0.78rem; font-weight: 600;
+ padding: 0.15rem 0.55rem; border-radius: 999px; margin: 0.15rem 0.3rem 0.15rem 0;
+ }
+ .chip-stale { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+ .chip-up { background: var(--accent-soft); color: #2f7a4f; }
+ .chip-down { background: var(--accent-soft); color: var(--bad); }
+ .chips { display: flex; flex-wrap: wrap; margin: 0 0 1rem; }
th {
font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.05em;
diff --git a/internal/console/view.go b/internal/console/view.go
index e06c89609..fa1ab7b73 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -7,6 +7,9 @@ package console
import (
"context"
"crypto/ed25519"
+ "encoding/json"
+ "errors"
+ "os"
"sort"
"strings"
"sync"
@@ -14,6 +17,7 @@ import (
"github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/status"
)
// AppView is everything the console shows about one app.
@@ -198,6 +202,28 @@ func assembleChannels(ctx context.Context, s release.Store, pub ed25519.PublicKe
return out
}
+// FleetView reads status/fleet.json off the console's release.Store handle
+// and decodes it into status.Fleet — the exact shape internal/status's
+// public page renders, so the operator view and the public page never
+// disagree about what a field means. A snapshot that hasn't been published
+// yet (no status-lease holder has run, or a brand-new deployment) is not an
+// error: it returns a nil Fleet so the page can render its "no snapshots
+// yet" empty state instead of a 500.
+func FleetView(ctx context.Context, s release.Store) (*status.Fleet, error) {
+ body, _, err := s.Get(ctx, "status/fleet.json")
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ return nil, err
+ }
+ var f status.Fleet
+ if err := json.Unmarshal(body, &f); err != nil {
+ return nil, err
+ }
+ return &f, nil
+}
+
// versionsCap bounds the app page's release list.
const versionsCap = 20