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>
8 files changed,
+431
−10
cmd/console/main.go+26 −8internal/console/server.go+77 −0internal/console/server_test.go+110 −0internal/console/store.go+32 −2internal/console/store_test.go+54 −0internal/console/templates/fleet.html+88 −0internal/console/templates/layout.html+18 −0internal/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 |