console: /fleet/architecture page, gate and refresh door
The operator's map of the deployment, behind the gate handleFleet has
always used: session -> IsOperator -> bare http.NotFound on every failure
path, so a probe of this route is byte-identical to a probe of a missing
one. That gate is now one function, operatorOnly, shared by /fleet,
/fleet/metrics, /fleet/economics and both new handlers — a promise about
byte-identity only survives being written once.
Every read soft-fails on its own: the inventory, the fleet aggregate, the
per-box snapshots, the registry and the rate card are five independent
reads, and one having a bad moment costs its own section plus a named
line under "Reads that failed", never the page. A missing inventory names
WHICH of the three states applies (never taken / no read grant /
unreadable) rather than claiming an empty estate nobody looked at.
The refresh door is POST-only (method-checked in the handler, not by the
mux pattern, so a GET is a 404 rather than a 405 + Allow), operator-gated,
CSRF-checked, and single-flighted through infraRefreshing. It deliberately
does not clear the auth latch: a deployment without the IAM grant gets the
same honest answer however many times the button is pressed.
Also here, both small and both load-bearing:
- Config.Bucket carries CARLOS_DEPLOYMENT_BUCKET's name, set in
cmd/console beside the store it opens. ArchInput.Bucket had nothing
setting it, so the spec's box -> bucket edge was shipping dark; a
handler reaching for os.Getenv would have hidden a deployment fact
Config exists to carry.
- StartInfraPass now leaves as soon as infraDisabled is set, not an hour
later. The pass that FLIPS the latch returns its own wrapped error, so
only the NEXT call hit the top-of-function guard and the goroutine sat
parked for up to an hour with provably nothing left to do. Still one
log line.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+545
−40
cmd/console/main.go+9 −4internal/console/archpage.go+126 −0internal/console/archpage_test.go+240 −0internal/console/infrapass.go+9 −0internal/console/server.go+52 −36internal/console/templates/architecture.html+85 −0internal/console/templates/fleet.html+1 −0internal/console/templates/layout.html+23 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 499b023ba..4e4a5e31e 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -260,10 +260,15 @@ func serve() { |
| mailDep := mailDeployment() |
| mailProvisioner, dnsWriter := mailBackends(mailDep, mail.NewAWSProvisioner, dnsrec.NewRoute53Writer) |
| srv, err := console.NewServer(console.Config{ |
| - Origin: origin, |
| - Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| - Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| - Releases: openStore(), |
| + Origin: origin, |
| + Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"), |
| + Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"), |
| + Releases: openStore(), |
| + // The deployment bucket's NAME, from the same variable openStore() |
| + // opens it with — /fleet/architecture draws the box -> bucket edge |
| + // from it. Empty on a CARLOS_DEPLOYMENT_DIR deployment, which |
| + // correctly draws no bucket node. |
| + Bucket: os.Getenv("CARLOS_DEPLOYMENT_BUCKET"), |
| ReleasePub: pub, |
| Key: key, |
| EdgeIPs: edgeIPs(), |
diff --git a/internal/console/archpage.go b/internal/console/archpage.go| new file mode 100644 |
| index 000000000..b561d06b6 |
| --- /dev/null |
| +++ b/internal/console/archpage.go |
| @@ -0,0 +1,126 @@ |
| +// GET /fleet/architecture — the operator's map of the deployment: how a |
| +// request reaches a process, and everything the account is carrying that |
| +// makes that possible. |
| +// |
| +// The gate is operatorOnly's (server.go): session -> IsOperator -> bare |
| +// http.NotFound on EVERY failure including the store error. A styled refusal |
| +// would confirm the route exists, and this page names hostnames, instance |
| +// ids and the shape of the estate. |
| +// |
| +// Every read soft-fails on its own. The inventory, the fleet aggregate, the |
| +// per-box snapshots, the registry and the rate card are five independent |
| +// reads; any one of them having a bad moment costs its own section and a |
| +// line in the footer, never the page. |
| +package console |
| + |
| +import ( |
| + "crypto/subtle" |
| + "log" |
| + "net/http" |
| + "time" |
| +) |
| + |
| +func (s *Server) handleArchitecture(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| + return |
| + } |
| + in := ArchInput{Bucket: s.cfg.Bucket} |
| + inv, missing := s.loadInventory(r.Context()) |
| + in.Inv = inv |
| + |
| + // A read that failed is SAID, not only logged. An empty Boxes section |
| + // with nothing beside it reads as "this deployment has no boxes", which |
| + // is the one claim a page whose bucket read just failed has no business |
| + // making. |
| + var failed []string |
| + |
| + // Reads two, three and five all go through the deployment bucket; a |
| + // console configured without one (CARLOS_DEPLOYMENT_DIR-less, legal per |
| + // Config.Releases) has no fleet, no host table and no rate card to read, |
| + // and asking anyway would panic rather than degrade. |
| + if s.cfg.Releases != nil { |
| + if f, err := FleetView(r.Context(), s.cfg.Releases); err != nil { |
| + log.Printf("console: architecture: fleet view: %v", err) |
| + failed = append(failed, "the published fleet status could not be read") |
| + } else { |
| + in.Fleet = f |
| + } |
| + if hosts, err := HostRows(r.Context(), s.cfg.Releases, time.Now()); err != nil { |
| + log.Printf("console: architecture: hosts: %v", err) |
| + failed = append(failed, "the per-box snapshots could not be read") |
| + } else { |
| + in.Hosts = hosts |
| + } |
| + // The rate card comes from the SAME loader /fleet/economics uses |
| + // (economicsview.go), so the two pages cannot disagree about what a |
| + // box costs. Its warning is already surfaced on that page; here a bad |
| + // scenario simply means the zero rate card, and every box then reports |
| + // drift — which is the honest answer, not a silent $0. |
| + sc, _ := s.loadActiveScenario(r.Context()) |
| + in.Rates = sc.RateCard |
| + } |
| + if reg, err := openRegistry(s.cfg.Registry); err != nil { |
| + log.Printf("console: architecture: registry: %v", err) |
| + failed = append(failed, "the route registry could not be opened — apps are not named on the boxes below") |
| + } else if routes, err := reg.List(); err != nil { |
| + log.Printf("console: architecture: routes: %v", err) |
| + failed = append(failed, "the routes could not be listed — apps are not named on the boxes below") |
| + } else { |
| + in.Routes = routes |
| + } |
| + |
| + v := BuildArch(in) |
| + // BuildArch defaults a nil inventory to "never"; loadInventory knows |
| + // WHICH of the three states it actually is, and that is the one the page |
| + // must name. |
| + if missing != "" { |
| + v.Missing = missing |
| + } |
| + // Appended to a COPY: v.Partial aliases the inventory's own slice, and |
| + // this page's read failures are not the inventory's. |
| + if len(failed) > 0 { |
| + v.Partial = append(append([]string{}, v.Partial...), failed...) |
| + } |
| + p := page{Title: "Architecture", Address: addr, CSRF: raw, Arch: &v, ArchSVG: RenderArchSVG(v)} |
| + p.Flash, p.FlashErr = s.takeFlash(w, r) |
| + s.render(w, http.StatusOK, "architecture", p) |
| +} |
| + |
| +// handleArchitectureRefresh runs one inventory now. Single-flighted |
| +// (infraRefreshing): a leaned-on button must not stack passes against a |
| +// rate-limited API. It deliberately does NOT clear the auth latch — a |
| +// deployment without the IAM grant gets the same honest answer however many |
| +// times the button is pressed. |
| +// |
| +// Method-checked here rather than by a "POST /path" mux pattern, for the |
| +// reason server.go's economics doors give: a 405 + Allow answer to a GET |
| +// would leak that this route exists to someone who merely guessed the URL. |
| +func (s *Server) handleArchitectureRefresh(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + _, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if !s.infraRefreshing.CompareAndSwap(false, true) { |
| + s.setFlash(w, "err", "An inventory is already running.") |
| + http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| + return |
| + } |
| + defer s.infraRefreshing.Store(false) |
| + |
| + if err := s.infraPass(r.Context()); err != nil { |
| + log.Printf("console: architecture refresh: %v", err) |
| + s.setFlash(w, "err", "The inventory could not be refreshed.") |
| + } else { |
| + s.setFlash(w, "ok", "Inventory refreshed.") |
| + } |
| + http.Redirect(w, r, "/fleet/architecture", http.StatusSeeOther) |
| +} |
diff --git a/internal/console/archpage_test.go b/internal/console/archpage_test.go| new file mode 100644 |
| index 000000000..f6cd4f217 |
| --- /dev/null |
| +++ b/internal/console/archpage_test.go |
| @@ -0,0 +1,240 @@ |
| +package console |
| + |
| +// The /fleet/architecture page (archpage.go): its gate, its honest empty |
| +// state, and its refresh door. What these tests own is the DOOR's shape — |
| +// who may see the page at all, what it says when it has never had an |
| +// inventory, and that the refresh button cannot be driven from another |
| +// site. The graph itself is proven in archgraph_test.go and the drawing in |
| +// archlayout_test.go. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/infraview" |
| +) |
| + |
| +// TestFleetOperatorOnly's shape against /fleet/architecture: signed out, |
| +// signed in but not an operator, and a revoked operator all get the exact |
| +// 404 an unrouted path gets — byte-identical, so the route's existence is |
| +// not probeable. |
| +func TestArchitectureOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + out := newBrowser(t) |
| + ghostStatus, _, ghost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + if ghostStatus != http.StatusNotFound { |
| + t.Fatalf("sanity: missing route = %d", ghostStatus) |
| + } |
| + if st, _, body := get(t, out, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("signed-out = %d %q, want the same 404 as a missing route", st, body) |
| + } |
| + owner := signInPaul(t, fx) |
| + if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("non-operator = %d %q, want the same 404", st, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 || !strings.Contains(body, "archsvg") { |
| + t.Fatalf("operator view = %d, want 200 with the diagram:\n%s", st, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", false); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, body := get(t, owner, fx.ts.URL+"/fleet/architecture"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("revoked = %d %q, want the same 404", st, body) |
| + } |
| +} |
| + |
| +// The refresh door's gate is the page's, and a wrong-method request is |
| +// byte-identical to a missing route: a "POST /path" registration would |
| +// answer a GET with 405 + Allow, which leaks that the route exists. |
| +func TestArchitectureRefreshOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + out := newBrowser(t) |
| + ghostStatus, _, ghost := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + if ghostStatus != http.StatusNotFound { |
| + t.Fatalf("sanity: missing route = %d", ghostStatus) |
| + } |
| + if st, _, body := postForm(t, out, fx.ts.URL+"/fleet/architecture/refresh", url.Values{}); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("signed-out refresh = %d %q, want the same 404 as a missing route", st, body) |
| + } |
| + c := signInPaul(t, fx) |
| + if st, _, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{}); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("non-operator refresh = %d %q, want the same 404", st, body) |
| + } |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + // A GET on the door is a missing route too, for the same reason. |
| + if st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture/refresh"); st != http.StatusNotFound || body != ghost { |
| + t.Fatalf("GET on the refresh door = %d %q, want the same 404", st, body) |
| + } |
| +} |
| + |
| +// With no inventory ever taken, the page still renders and says WHICH of |
| +// the three states it is in — never a confident "no resources". |
| +func TestArchitecturePageWithoutInventory(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200", st) |
| + } |
| + if !strings.Contains(body, "No inventory has been taken yet") { |
| + t.Errorf("page must name the never-taken state:\n%s", body) |
| + } |
| + if strings.Contains(body, "no resources") { |
| + t.Errorf("page must not claim an empty estate it never read") |
| + } |
| +} |
| + |
| +// A deployment whose inventory grant was refused says so in those words — |
| +// the second of the three states, and the one that must never be rendered |
| +// as "nothing here". |
| +func TestArchitecturePageUngranted(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.infraDisabled.Store(true) |
| + |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200", st) |
| + } |
| + if !strings.Contains(body, "no infrastructure read grant") { |
| + t.Errorf("page must name the ungranted state:\n%s", body) |
| + } |
| + if strings.Contains(body, "No inventory has been taken yet") { |
| + t.Errorf("ungranted must not be reported as never-taken") |
| + } |
| +} |
| + |
| +// The refresh door: CSRF-checked, single-flighted, and it writes an |
| +// inventory the page then reads back. |
| +func TestArchitectureRefreshDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := 0 |
| + fx.srv.inventory = func(ctx context.Context) (infraview.Inventory, error) { |
| + called++ |
| + return infraview.Inventory{Provider: "aws", Account: "123456789012", TakenAt: time.Now().UTC()}, nil |
| + } |
| + |
| + // No CSRF token: refused, and the pass must not have run. |
| + st, _, _ := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{}) |
| + if st != http.StatusForbidden { |
| + t.Fatalf("no-CSRF refresh = %d, want 403", st) |
| + } |
| + if called != 0 { |
| + t.Fatalf("a refused refresh must not run the pass") |
| + } |
| + |
| + // With the token: runs once and redirects back to the page. |
| + csrf := sessionToken(t, fx, c) |
| + st, loc, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{"csrf": {csrf}}) |
| + if st != http.StatusSeeOther || loc != "/fleet/architecture" { |
| + t.Fatalf("refresh = %d -> %q, want 303 back to the page:\n%s", st, loc, body) |
| + } |
| + if called != 1 { |
| + t.Fatalf("pass ran %d times, want 1", called) |
| + } |
| + |
| + // And the page now reports the estate rather than one of the three |
| + // missing states. |
| + st, _, body = get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 || !strings.Contains(body, "123456789012") { |
| + t.Fatalf("page after refresh = %d, want the inventoried account:\n%s", st, body) |
| + } |
| + if strings.Contains(body, "No inventory has been taken yet") { |
| + t.Errorf("page still claims no inventory after a successful refresh") |
| + } |
| +} |
| + |
| +// The refresh door does NOT clear the auth latch: a deployment with no IAM |
| +// grant gets the same honest answer however many times the button is |
| +// pressed, rather than a fresh AWS attempt per click. |
| +func TestArchitectureRefreshKeepsLatch(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + called := 0 |
| + fx.srv.inventory = func(ctx context.Context) (infraview.Inventory, error) { |
| + called++ |
| + return infraview.Inventory{}, nil |
| + } |
| + fx.srv.infraDisabled.Store(true) |
| + |
| + csrf := sessionToken(t, fx, c) |
| + st, loc, body := postForm(t, c, fx.ts.URL+"/fleet/architecture/refresh", url.Values{"csrf": {csrf}}) |
| + if st != http.StatusSeeOther || loc != "/fleet/architecture" { |
| + t.Fatalf("refresh = %d -> %q, want 303 back to the page:\n%s", st, loc, body) |
| + } |
| + if called != 0 { |
| + t.Fatalf("a latched-off deployment must not call the provider (called %d)", called) |
| + } |
| + if !fx.srv.infraDisabled.Load() { |
| + t.Errorf("the refresh door cleared the auth latch") |
| + } |
| + if fx.srv.infraRefreshing.Load() { |
| + t.Errorf("the single-flight latch was left set after the door returned") |
| + } |
| +} |
| + |
| +// The deployment bucket reaches the graph through Config, not the |
| +// environment: a console configured with one draws the box -> bucket edge |
| +// the spec mandates. |
| +func TestArchitectureDrawsConfiguredBucket(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.Bucket = "carlos-deploy-fixture" |
| + |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200", st) |
| + } |
| + if !strings.Contains(body, "carlos-deploy-fixture") { |
| + t.Errorf("the configured deployment bucket is not drawn:\n%s", body) |
| + } |
| +} |
| + |
| +// A read that failed is SAID on the page. An empty Boxes section with |
| +// nothing beside it would read as "this deployment has no boxes" — the one |
| +// claim a page whose registry read just failed must not make. |
| +func TestArchitectureNamesAFailedRead(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.srv.cfg.Registry = filepath.Join(t.TempDir(), "no-such-dir", "registry.db") |
| + |
| + st, _, body := get(t, c, fx.ts.URL+"/fleet/architecture") |
| + if st != 200 { |
| + t.Fatalf("= %d, want 200 — a failed registry read costs its section, not the page", st) |
| + } |
| + if !strings.Contains(body, "Reads that failed") || !strings.Contains(body, "route registry could not be opened") { |
| + t.Errorf("the failed registry read is not named on the page:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/infrapass.go b/internal/console/infrapass.go| index e59bc8d64..58675c802 100644 |
| --- a/internal/console/infrapass.go |
| +++ b/internal/console/infrapass.go |
| @@ -59,6 +59,15 @@ func (s *Server) StartInfraPass(ctx context.Context) { |
| } |
| log.Printf("console: infra pass: %v", err) |
| } |
| + // The pass that FLIPS the latch returns its own wrapped error, not |
| + // the sentinel — it is only the NEXT call that hits the top-of- |
| + // function guard. Checking the latch itself here means the goroutine |
| + // leaves as soon as there is provably nothing left to do, instead of |
| + // parking on the ticker for an hour first. The log line above has |
| + // already been printed exactly once, which is the whole contract. |
| + if s.infraDisabled.Load() { |
| + return |
| + } |
| select { |
| case <-ctx.Done(): |
| return |
diff --git a/internal/console/server.go b/internal/console/server.go| index d9952f180..ccf25ddf2 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -435,8 +435,17 @@ type Config struct { |
| // domains, alerts), but fleets.go's fleet/box/placement doors (customer |
| // fleets plan, Task 7) genuinely write through it via openRegistry |
| // (view.go) — the same cached handle every reader here already uses. |
| - Registry string |
| - Releases release.Store // the deployment bucket |
| + Registry string |
| + Releases release.Store // the deployment bucket |
| + // Bucket is that same deployment bucket's NAME — CARLOS_DEPLOYMENT_BUCKET, |
| + // the one bucket relationship this console can state as fact. |
| + // /fleet/architecture draws the box -> bucket edge from it (archgraph.go's |
| + // ArchInput.Bucket). It rides on Config rather than being read from the |
| + // environment in the handler because it is a deployment fact, and a |
| + // handler reaching for os.Getenv would hide it from every reader of this |
| + // struct. "" is legal — a local CARLOS_DEPLOYMENT_DIR deployment has no |
| + // bucket name to draw — and simply draws no bucket node. |
| + Bucket string |
| ReleasePub ed25519.PublicKey // pinned channel-pointer verification key |
| // Key signs channel pointers for promote/rollback (CARLOS_RELEASE_KEY). |
| // nil is a legal deployment: the console runs read-only, the promotion |
| @@ -794,6 +803,12 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /fleet", s.handleFleet) |
| s.mux.HandleFunc("GET /fleet/metrics", s.handleFleetMetrics) |
| s.mux.HandleFunc("GET /fleet/economics", s.handleEconomics) |
| + // The deployment's map (archpage.go), same operatorOnly gate. The |
| + // refresh door is registered WITHOUT a method prefix for the reason |
| + // spelled out below the economics doors: "POST /path" answers a GET |
| + // with 405 + Allow, which leaks that the route exists. |
| + s.mux.HandleFunc("GET /fleet/architecture", s.handleArchitecture) |
| + s.mux.HandleFunc("/fleet/architecture/refresh", s.handleArchitectureRefresh) |
| // Per-account table (Task 14): same gate, its own search/filter/cursor |
| // controls (econlistquery.go) over the same LoadEconomics rows. |
| s.mux.HandleFunc("GET /fleet/economics/accounts", s.handleEconomicsAccounts) |
| @@ -1083,6 +1098,13 @@ type page struct { |
| Metrics []BoxMetricsView |
| MetricAlerts []AlertRow |
| + // /fleet/architecture (archgraph.go): the deployment's map. Arch is nil |
| + // only on paths this handler never takes; ArchSVG is pre-rendered in Go |
| + // rather than assembled in the template because the layout has to be |
| + // deterministic and testable as a whole. |
| + Arch *ArchView |
| + ArchSVG template.HTML |
| + |
| // /fleet/economics: the operator unit-economics dashboard (Task 13), |
| // from LoadEconomics (economicsview.go). nil only on a rendering path |
| // this handler never takes (handleEconomics always loads one before |
| @@ -1882,27 +1904,43 @@ func (s *Server) handleSignout(w http.ResponseWriter, r *http.Request) { |
| // ---- 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) { |
| +// operatorOnly is the gate every /fleet page and door shares: signed in, |
| +// and an operator. Every failure 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. |
| +// |
| +// One copy rather than one per handler, because the promise being kept is |
| +// byte-identity with a missing route — a promise that only survives being |
| +// written once. ok=false means the response is already written and the |
| +// caller must return immediately. |
| +func (s *Server) operatorOnly(w http.ResponseWriter, r *http.Request) (address, rawToken string, ok bool) { |
| addr, raw, ok := s.session(r) |
| if !ok { |
| http.NotFound(w, r) |
| - return |
| + return "", "", false |
| } |
| isOp, err := s.store.IsOperator(addr) |
| if err != nil { |
| log.Printf("console: IsOperator %s: %v", addr, err) |
| http.NotFound(w, r) |
| - return |
| + return "", "", false |
| } |
| if !isOp { |
| http.NotFound(w, r) |
| + return "", "", false |
| + } |
| + return addr, raw, true |
| +} |
| + |
| +// handleFleet serves the private fleet view: everything status/fleet.json |
| +// carries, server-rendered, for signed-in operators only — behind |
| +// operatorOnly's 404-to-everyone gate above. |
| +func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.operatorOnly(w, r) |
| + if !ok { |
| return |
| } |
| f, err := FleetView(r.Context(), s.cfg.Releases) |
| @@ -1928,19 +1966,8 @@ func (s *Server) handleFleet(w http.ResponseWriter, r *http.Request) { |
| // same gate as handleFleet, same reasoning: every non-operator path is a |
| // plain http.NotFound so the route's existence is not probeable. |
| func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, ok := s.session(r) |
| + addr, raw, ok := s.operatorOnly(w, r) |
| if !ok { |
| - http.NotFound(w, r) |
| - return |
| - } |
| - isOp, err := s.store.IsOperator(addr) |
| - if err != nil { |
| - log.Printf("console: fleet metrics: is-operator: %v", err) |
| - http.NotFound(w, r) |
| - return |
| - } |
| - if !isOp { |
| - http.NotFound(w, r) |
| return |
| } |
| boxes, alerts, err := s.LoadMetrics(r.Context()) |
| @@ -1961,19 +1988,8 @@ func (s *Server) handleFleetMetrics(w http.ResponseWriter, r *http.Request) { |
| // (economicspass.go's handleEconomicsScenarioSave/handleEconomicsBillSave) |
| // redirect back to. |
| func (s *Server) handleEconomics(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, ok := s.session(r) |
| + addr, raw, ok := s.operatorOnly(w, r) |
| if !ok { |
| - http.NotFound(w, r) |
| - return |
| - } |
| - isOp, err := s.store.IsOperator(addr) |
| - if err != nil { |
| - log.Printf("console: economics: is-operator: %v", err) |
| - http.NotFound(w, r) |
| - return |
| - } |
| - if !isOp { |
| - http.NotFound(w, r) |
| return |
| } |
| view, err := s.LoadEconomics(r.Context(), r.URL.Query().Get("period")) |
diff --git a/internal/console/templates/architecture.html b/internal/console/templates/architecture.html| new file mode 100644 |
| index 000000000..b2e9147b0 |
| --- /dev/null |
| +++ b/internal/console/templates/architecture.html |
| @@ -0,0 +1,85 @@ |
| +{{/* /fleet/architecture (archpage.go): the deployment's map — how a request |
| + reaches a process, drawn over the estate the account is carrying. |
| + |
| + Everything here is server-rendered: the diagram is inline SVG built in |
| + Go (archlayout.go's RenderArchSVG) so the picture is deterministic and |
| + testable as a whole, and the page works with JS off because there is no |
| + JS. Every block below is independently guarded — the inventory, the |
| + boxes, the resource tables and the failed-reads footer each appear only |
| + when their own read produced something, so one bad read costs its own |
| + section and never the page. */}} |
| +{{define "architecture"}}{{template "top" .}} |
| +<h1>Architecture</h1> |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{with .Arch}} |
| +{{/* The three missing states are named apart, never collapsed into one |
| + "nothing here": never-taken, ungranted and unreadable are three |
| + different facts about the deployment and only one of them is normal. */}} |
| +{{if .Missing}} |
| +<p class="banner">{{icon "triangle-alert"}} |
| + {{if eq .Missing "never"}}No inventory has been taken yet — the first pass runs within the hour. The boxes below are what CARLOS itself reports. |
| + {{else if eq .Missing "ungranted"}}This deployment has no infrastructure read grant, so the cloud half of this page is unavailable. The boxes below are what CARLOS itself reports. |
| + {{else}}The inventory snapshot could not be read. The boxes below are what CARLOS itself reports.{{end}} |
| +</p> |
| +{{else}} |
| +<p class="small">Estate {{.Account}} · inventoried {{fmtTime .TakenAt}}</p> |
| +{{end}} |
| + |
| +<div class="archwrap">{{$.ArchSVG}}</div> |
| + |
| +<h2>Boxes</h2> |
| +{{range .Boxes}} |
| +<section class="archbox"> |
| + <h3>{{.Label}}{{if .Region}} <span class="small">{{.Region}}</span>{{end}}</h3> |
| + {{/* Each fact is its own span and the separators are drawn by CSS |
| + (.fact + .fact::before), because every one of these is independently |
| + absent — a deployment with no rate card entry has no size and no |
| + price, and a hand-written separator would leave a dangling middot on |
| + the commonest page of all. */}} |
| + <p class="small"> |
| + {{if .InstanceType}}<span class="fact">{{.InstanceType}}</span>{{end}} |
| + {{if .State}}<span class="fact">{{.State}}</span>{{end}} |
| + {{if .MemGB}}<span class="fact">{{printf "%.0f" .MemGB}} GB</span>{{end}} |
| + {{if .DiscoveredDiskGB}}<span class="fact">{{printf "%.0f" .DiscoveredDiskGB}} GB disk</span>{{end}} |
| + {{if .USDMonth}}<span class="fact">${{printf "%.0f" .USDMonth}}/mo</span>{{end}} |
| + {{if not .CloudID}}<span class="fact">no cloud machine (bring-your-own box)</span>{{end}} |
| + </p> |
| + <p class="small">Apps: {{if .Apps}}{{range .Apps}}<code>{{.}}</code> {{end}}{{else}}none reported{{end}}</p> |
| + <p class="small"> |
| + static {{.Static}} · resident {{.Resident}} · hibernating {{.Hibernating}} · |
| + awake {{intOrDash .Awake}} · asleep {{intOrDash .Asleep}} |
| + {{with .Release}}· <code>{{.}}</code>{{end}} |
| + </p> |
| + {{if .Stale}}<p><span class="chip chip-stale">dark{{if .Dark}} {{.Dark}}{{end}}</span></p>{{end}} |
| + {{/* Drift is where the rate card and the estate disagree — a fact to |
| + watch, not an outage, so it wears chip-stale's bordered warn tone |
| + rather than a filled marker. */}} |
| + {{range .Drift}}<p><span class="chip chip-stale">{{.}}</span></p>{{end}} |
| +</section> |
| +{{else}} |
| +<p>No box has reported to this deployment yet.</p> |
| +{{end}} |
| + |
| +{{range .Groups}} |
| +<h2>{{.Title}}</h2> |
| +<div class="tablewrap"><table> |
| + <tr><th>Name</th><th>Type</th><th>Region</th><th>Detail</th></tr> |
| + {{range .Rows}}<tr><td>{{.Name}}</td><td><code>{{.NativeType}}</code></td><td>{{.Region}}</td><td class="small">{{.Detail}}</td></tr>{{end}} |
| +</table></div> |
| +{{end}} |
| + |
| +{{/* The inventory's own partial-read list: which service or region the |
| + estate read could not see. A page that quietly dropped these would be |
| + claiming the estate is smaller than it is. */}} |
| +{{if .Partial}} |
| +<h2>Reads that failed</h2> |
| +<ul class="small">{{range .Partial}}<li>{{.}}</li>{{end}}</ul> |
| +{{end}} |
| +{{end}} |
| + |
| +<form method="post" action="/fleet/architecture/refresh"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit">Refresh inventory</button> |
| +</form> |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/fleet.html b/internal/console/templates/fleet.html| index f6b745edd..e4d6e1ef1 100644 |
| --- a/internal/console/templates/fleet.html |
| +++ b/internal/console/templates/fleet.html |
| @@ -1,5 +1,6 @@ |
| {{define "fleet"}}{{template "top" .}} |
| <h1>Fleet status</h1> |
| +<p class="small"><a href="/fleet/architecture">See the architecture →</a></p> |
| {{if .Fleet}} |
| <p class="small">Generated {{fmtTime .Fleet.GeneratedAt}}</p> |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index b0440d1d8..dcd965c98 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -408,6 +408,29 @@ |
| } |
| .confirm-card .actions { display: flex; gap: 0.9rem; align-items: center; margin-top: 1rem; } |
| .confirm-card .confirm-note { margin: 0.8rem 0 0; } |
| + /* /fleet/architecture: the diagram and its box cards. Colours come from |
| + the same tokens as the rest of the console so it themes light and dark |
| + with no second palette to keep in sync, and the SVG scrolls inside its |
| + own wrapper rather than making the page scroll sideways — the drawing |
| + has a minimum readable width the viewport may not have. */ |
| + .archwrap { overflow-x: auto; margin: 1rem 0 2rem; } |
| + .archsvg { width: 100%; min-width: 720px; height: auto; } |
| + .archsvg rect { fill: var(--accent-soft); stroke: var(--rule); } |
| + .archsvg .archnode-box rect { stroke: var(--accent); } |
| + .archsvg .archtitle { fill: var(--ink); font: 600 13px var(--sans); } |
| + .archsvg .archsub { fill: var(--muted); font: 400 11px var(--sans); } |
| + .archsvg .archedge { fill: none; stroke: var(--rule); stroke-width: 1.5; } |
| + .archbox { |
| + border: 1px solid var(--rule); border-radius: 0.6rem; |
| + padding: 0.9rem 1.1rem; margin: 0 0 0.6rem; max-width: 44rem; |
| + } |
| + .archbox h3 { margin: 0 0 0.3rem; } |
| + .archbox p { margin: 0 0 0.2rem; } |
| + /* The box card's fact line: every fact on it is independently absent (a |
| + BYO box has no instance type, an unpriced box has no size and no |
| + price), so the separator is drawn between surviving neighbours rather |
| + than written after each fact — no dangling middots. */ |
| + .archbox .fact + .fact::before { content: " · "; color: var(--muted); } |
| footer { |
| max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem; |
| border-top: 1px solid var(--rule); |