carlos / platform Public

Merge pull request #89 from carlosframework/worktree-console-app-pages

Console app pages v2: dashboard overview, human ladder, confirm cards, fast instances table, real settings
Paul Campbell dc6a6c346761c2933949829e0fb386becd3de18e parent 82dbca9parent 3b0e7a9
24 files changed, +3173 −202
  • docs/superpowers/specs/2026-08-07-console-app-pages-design.md +144 −0
  • internal/console/appsettings.go +443 −0
  • internal/console/appsettings_test.go +657 −0
  • internal/console/bakecontrols_test.go +6 −6
  • internal/console/confirm.go +233 −0
  • internal/console/confirm_test.go +314 −0
  • internal/console/icons.go +13 −0
  • internal/console/ladder_test.go +44 −32
  • internal/console/metricsview.go +112 −26
  • internal/console/metricsview_test.go +162 −0
  • internal/console/overview.go +192 −0
  • internal/console/overview_test.go +317 −0
  • internal/console/server.go +110 −8
  • internal/console/server_account_test.go +19 −5
  • internal/console/server_test.go +52 −36
  • internal/console/store.go +91 −0
  • internal/console/templates/app_instances.html +32 −38
  • internal/console/templates/app_overview.html +66 −12
  • internal/console/templates/app_releases.html +13 −13
  • internal/console/templates/app_settings.html +69 −0
  • internal/console/templates/confirm_action.html +23 −0
  • internal/console/templates/ladder.html +14 −26
  • internal/console/templates/layout.html +42 −0
  • internal/console/view.go +5 −0
diff --git a/docs/superpowers/specs/2026-08-07-console-app-pages-design.md b/docs/superpowers/specs/2026-08-07-console-app-pages-design.md
new file mode 100644
index 000000000..d8348799c
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-07-console-app-pages-design.md
@@ -0,0 +1,144 @@
+# Console app pages: dashboard, ladder, confirms, instances, settings
+
+2026-08-07. Follows the 2026-08-06 console redesign (PR #68), which split the
+app page into tabs but deliberately moved no furniture and added no features.
+This is the furniture-and-features pass, from Paul's review of the redesign:
+
+> The "Overview" page should be a dashboard rather than the ladder. It should
+> show vital statistics. The ladder should be much more human friendly ...
+> less verbosity, more clarity. Releases should have buttons rather than
+> links for things like Promote, and actions like "Promote" should have a
+> confirm step. Instances view is currently slow. It should be a table, and
+> each instance should show what channel / release / version it's on, as well
+> as its resource usage. Settings doesn't actually have any settings.
+> Settings should allow adding custom domains, attaching them, changing the
+> name of the app, deleting the app, moving to a different account.
+
+## 1. Overview is a dashboard
+
+`app_overview.html` renders `OverviewStats` (overview.go): what stable
+serves, instance count and how many report, CPU/memory summed across
+reporting instances, domains and how many need attention, releases and the
+newest. Every number is derived from reads the app pages already did —
+`AppView`, the shared metrics sweep, the versions list; the dashboard adds no
+bucket or registry I/O, and it DROPS the overview's old self-report HTTP poll
+(`instances.Report`), so the landing tab never waits on a tenant's own
+endpoint. Tiles link to the tab that explains them. A one-line bake status
+and the last five audit events close the page.
+
+The ladder renders on Releases now, above the channels table. Overview keeps
+the AccountMismatch banner (pointing at Settings, which now owns the repair —
+§5), because "this app is not deploying" belongs on the landing tab.
+
+## 2. The ladder, humanized
+
+Template-only change (ladder.html): the three-sentence philosophy intro is
+one line; promotion times read "2h4m ago" (absolute stamp in the title
+attribute); the unrecorded-actor apology is dropped — no actor shown means
+not recorded; hotfix and fast-track become permanent chips (`zap`,
+`fast-forward`, vendored in icons.go) instead of prose clauses; the trailing
+canary essay is gone (the Releases canary section explains them). The
+assembly (ladder.go) and its gate copy are untouched.
+
+## 3. Actions are buttons, with a confirm step
+
+Promote, rollback, fast-track and canary-retire submit via real buttons
+(`.btn-sm`, danger variants filled red) and pass through a server-rendered
+confirm interstitial (confirm.go, confirm_action.html): one card saying what
+serves now, what will serve after, the bake consequence, and — for stable —
+the one red line about every user getting it. No JavaScript; the card is the
+POST's response, and its button re-POSTs the same fields plus `confirmed=1`.
+
+The card is a UI affordance, not a gate. Placement inside each handler is
+load-bearing and tested: after CSRF and input validation (the card never
+describes an invalid request), after any owner-only 404 (a member probing an
+owner action meets the byte-identical 404, never a card), before
+`requireFreshAuth` (the step-up replay carries `confirmed=1`, so confirm and
+passkey each happen exactly once, in that order). A hand-written POST with
+`confirmed=1` meets exactly the doors a button does.
+
+The beta→stable `enable_bake` checkbox moved from the ladder form onto the
+stable confirm card — the card is where that decision is actually being made.
+The production toggle keeps its inline linkish form: OFF already carries the
+passkey ceremony, and ON is the cheap, safe direction.
+
+## 4. Instances: one table, and a sweep that doesn't crawl
+
+`app_instances.html` renders one row per route of the viewed account
+(`InstanceRow`, overview.go): host, channel, the release the host reports it
+is RUNNING (registry `Route.Version`), status (hibernate-aware), and CPU/
+memory with 48h sparklines inline. The join key is the host; the metrics
+side is the same `OwnerAppMetrics` allowlist as before — the tenant boundary
+does not move. The app's own `/.carlos/instances` self-report demotes to an
+"Inside the app" section, for apps that manage internal instances.
+
+The slowness was the cold metrics sweep: `boxes × 48` SEQUENTIAL bucket
+reads (metricsview.go). The sweep now runs boxes 4 at a time and each box's
+48 hourly reads 12 at a time, with results assembled in deterministic order
+(hour-order concatenation before the non-stable sort — equal timestamps must
+not flap between renders) and the same failure semantics: missing hour
+skipped, unparsable chunk skipped, any other read error fails the whole
+sweep and caches nothing. Exec-backed apps that publish no per-instance
+readings (issue: instances-readings gap) are a separate feature, untouched
+here.
+
+## 5. Settings: the settings an app record can have
+
+What Settings now offers, and the line it deliberately draws: the console
+mutates its own database and the bucket's markers, never the registry.
+
+- **Rename** — for an app that has NEVER deployed: nothing under its bucket
+ prefix (account-qualified or legacy), no registry route wearing the name,
+ no legacy owners-table grant on either name. Then the name is still only a
+ console record, and `RenameAppIn` (store.go) renames it transactionally,
+ audit history following. Anything else refuses with the reason: the app
+ name is a literal key segment in bucket prefixes, signed pointer payloads,
+ registry rows, the platform hostname, even a host-role app's Unix user — a
+ live rename is a coordinated migration this platform does not have, not a
+ form.
+- **Delete** — the deletion-policy trash, from the browser: owner types the
+ app's name (a which-app control, checked before step-up so a slip costs no
+ passkey touch), confirms with the passkey, and the handler mirrors the API
+ door exactly — production flag refuses (unreadable = production), the
+ bucket marker is written create-only, and the console row carries the
+ MARKER's clock. Restore and purge stay on the Trash page.
+- **Move to a different account** — the repair half. When the app's routes
+ already read from another account (AccountMismatch), an owner standing in
+ both accounts can make the console record agree — `console migrate-app`
+ from the browser, target taken from the ROUTES, never the form. The full
+ move (bucket objects + route re-stamp) remains `carlos accounts migrate`,
+ an operator act, and the page says so.
+- **Custom domains** — the table stays (DNS/TLS status, pointing guidance),
+ and the page now states the attach recipe honestly: `carlos add` on the
+ box, an operator act. Console-driven attach needs a new platform feature —
+ below.
+
+Gate order throughout, unchanged from ownership.go's mandate: session →
+CSRF → owner (byte-identical 404) → cheap refusals → type-the-name where
+destruction warrants it → confirm card → `requireFreshAuth` LAST.
+
+## 6. Future: console-driven custom domain attach
+
+Filed as an issue rather than built here, because it is a fleet feature, not
+a console handler. The design that fits everything this platform already
+does (mirroring instance provisioning, the closest precedent):
+
+- Write side: Settings/API writes a create-only desired-domain record,
+ `apps/<sqid>/<app>/domains/<host>.json` — host, app, account, actor,
+ added_at — after validating the host is a real FQDN, not a platform
+ domain, not already routed.
+- Box side: the domains reconcile pass (which already minted platform hosts
+ and already drops a trashed app's routes) materializes a route for each
+ record whose app it already serves — copying kind/socket/channel from the
+ app's existing primary route, stamped with a `domain_record` ownership
+ flag (the `Provisioned` precedent) so the reconciler owns the route's
+ lifecycle and never touches operator-added rows. Record deleted → route
+ dropped.
+- Certs need nothing: the edge already mints per-host certificates on first
+ handshake for any route it serves; DNS guidance and health stamps already
+ exist on the Domains table.
+
+Open questions for that issue: which box claims the record when the app
+serves from several (all of them? the stable-route boxes?), the registry
+schema addition (one column, additive), and rollout order (box binaries
+before the console door opens, or records simply sit unmaterialized).
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
new file mode 100644
index 000000000..fcbac4414
--- /dev/null
+++ b/internal/console/appsettings.go
@@ -0,0 +1,443 @@
+// The app Settings tab's write half: rename, delete, and the account-move
+// repair — the app-level siblings of ownership.go's account actions, holding
+// the same gate order (session → CSRF → owner 404 → cheap refusals → confirm
+// → requireFreshAuth LAST) and the same posture: the console mutates its own
+// database and the bucket's markers, never the registry.
+//
+// What is deliberately NOT here, and why:
+//
+// - Renaming a LIVE app. The name is a literal key segment in every store
+// this platform has — bucket prefixes, signed pointer payloads, registry
+// rows, platform hostnames, even a host-role app's Unix user — so a live
+// rename is a coordinated migration, not a settings write. The form
+// refuses with that reason once anything outside this database knows the
+// name.
+// - Creating routes (custom domains). The console reads the registry and
+// writes it nothing; domains attach on the box today, and the console-
+// driven path wants the bucket-record + reconciler design instance
+// provisioning proved out — a platform feature, not a handler here.
+// - The full account move. `carlos accounts migrate` moves bucket objects
+// and re-stamps routes on the box; what the console owns is its own
+// record, so what Settings offers is the repair half — making this
+// database agree with what the routes already say.
+package console
+
+import (
+ "context"
+ "crypto/subtle"
+ "log"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// SettingsView is what the Settings tab knows beyond the shared AppView.
+type SettingsView struct {
+ // CanRename: nothing outside the console database knows this name yet.
+ // RenameBlocked carries the reason when it is false.
+ CanRename bool
+ RenameBlocked string
+
+ // The account-move repair: MismatchTarget is the account the app's
+ // routes say it belongs to ("" when console and routes agree),
+ // TargetKnown whether this console has that account at all, and
+ // ViewerInTarget whether the signed-in owner is a member of it — the
+ // difference between offering the button and explaining the operator
+ // path.
+ MismatchTarget string
+ TargetKnown bool
+ ViewerInTarget bool
+}
+
+// assembleSettings fills the Settings tab's view. Best-effort by design: a
+// bucket hiccup here blocks the rename form (fail closed — an unverifiable
+// emptiness claim must not enable an identity change) and never the page.
+func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView {
+ sv := &SettingsView{}
+ if s.cfg.Releases == nil {
+ sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run."
+ return sv
+ }
+ switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, p.AppName); {
+ case err != nil:
+ sv.RenameBlocked = "Could not check the deployment bucket, so renaming is off until it can be."
+ case len(p.Detail.Routes) > 0:
+ sv.RenameBlocked = "This app is wired to a host — its name is its address, in routes and bucket keys alike."
+ case !empty:
+ sv.RenameBlocked = "This app has shipped — its name is baked into its releases' bucket keys."
+ default:
+ sv.CanRename = true
+ }
+ if ra := p.Detail.RouteAccount; ra != "" {
+ sv.MismatchTarget = ra
+ if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
+ sv.TargetKnown = true
+ if role, err := s.store.Role(target.ID, p.Address); err == nil && role != "" {
+ sv.ViewerInTarget = true
+ }
+ }
+ }
+ return sv
+}
+
+// appDeployEmpty reports whether the bucket holds ANYTHING under the app's
+// prefix — account-qualified or legacy-unqualified. One key is enough to
+// refuse a rename; the caller never needs the listing itself.
+func (s *Server) appDeployEmpty(ctx context.Context, acct, app string) (bool, error) {
+ for _, prefix := range []string{release.AppPrefix(acct, app), release.LegacyAppPrefix(app)} {
+ keys, err := s.cfg.Releases.List(ctx, prefix)
+ if err != nil {
+ return false, err
+ }
+ if len(keys) > 0 {
+ return false, nil
+ }
+ }
+ return true, nil
+}
+
+// appHasRoutes reports whether ANY registry route wears the name — any
+// account's. Deliberately wider than the tenant boundary: a rename must not
+// free a name some other tenant's route still resolves by.
+func (s *Server) appHasRoutes(app string) (bool, error) {
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return false, err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return false, err
+ }
+ for _, rt := range routes {
+ if rt.App == app {
+ return true, nil
+ }
+ }
+ return false, nil
+}
+
+// handleAppRename renames a never-deployed app. Owner's, behind the confirm
+// card, without the passkey: nothing outside this console's database changes,
+// and the refusals above guarantee exactly that.
+func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(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 role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ }
+ // Normalized exactly as the create doors normalize (normalizeAppName),
+ // and refused exactly where claimApp refuses: "new" is the create form's
+ // own literal route, and an app renamed onto it would be unreachable —
+ // the mux prefers the literal, so no console page could ever show it.
+ to := normalizeAppName(r.FormValue("to"))
+ if !release.ValidApp(to) {
+ back("err", "App names are lowercase letters, digits and dashes.")
+ return
+ }
+ if to == "new" {
+ back("err", "That name is reserved.")
+ return
+ }
+ if to == app {
+ back("err", "That is already this app's name.")
+ return
+ }
+ if s.cfg.Releases == nil {
+ back("err", "No deployment bucket on this host, so the never-shipped check cannot run — renaming is refused.")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
+ defer cancel()
+ // The two wider stores that know app names, checked at action time — the
+ // Settings view's CanRename was advice, this is the refusal. Both fail
+ // CLOSED: "could not check" is not "empty".
+ switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); {
+ case err != nil:
+ back("err", "Could not check the deployment bucket — nothing was renamed. Try again.")
+ return
+ case !empty:
+ back("err", "This app has shipped — its name is baked into its releases' bucket keys, so renaming it is not supported. Create a new app and ship there instead.")
+ return
+ }
+ switch wired, err := s.appHasRoutes(app); {
+ case err != nil:
+ back("err", "Could not check the routes — nothing was renamed. Try again.")
+ return
+ case wired:
+ back("err", "A host still routes this name — remove its routes first.")
+ return
+ }
+ if !confirmed(r) {
+ cv := &ConfirmView{
+ Title: "Rename " + app + " to " + to + "?",
+ Lines: []string{
+ app + " has never shipped, so nothing outside this console knows the name yet.",
+ "After this, the app is " + to + " — commands, pages and its future addresses all say so.",
+ },
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rename",
+ Fields: [][2]string{
+ {"csrf", raw}, {"to", to},
+ },
+ Button: "Rename",
+ CancelTab: "settings",
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+ return
+ }
+ if err := s.store.RenameAppIn(acct.ID, app, to); err != nil {
+ log.Printf("console: rename %s/%s -> %s: %v", acct.Sqid, app, to, err)
+ back("err", renameAdvice(err, to))
+ return
+ }
+ // The emptiness checks above cannot join the database transaction — the
+ // bucket and the registry are other stores — so a `carlos ship` or
+ // `carlos add` racing this request can land under the old name between
+ // check and commit. Re-checking AFTER the commit closes most of that
+ // window: on detection the rename is undone and the human told, which
+ // converges toward the safe state (the console record naming what the
+ // bucket and routes name) instead of silently splitting the identity.
+ stale := false
+ if empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); err == nil && !empty {
+ stale = true
+ }
+ if wired, err := s.appHasRoutes(app); err == nil && wired {
+ stale = true
+ }
+ if stale {
+ if err := s.store.RenameAppIn(acct.ID, to, app); err != nil {
+ log.Printf("console: rename %s/%s -> %s: mid-rename ship detected and UNDO FAILED: %v — console record and platform state now disagree", acct.Sqid, app, to, err)
+ back("err", "A ship or route landed under "+app+" while renaming, and undoing hit an error — the record may disagree with the platform. Re-check this page.")
+ return
+ }
+ back("err", "A ship or route landed under "+app+" while renaming — the rename was undone; the name stays.")
+ return
+ }
+ s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to)
+ s.setFlash(w, "ok", "Renamed "+app+" to "+to+".")
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(to)+"/settings", http.StatusSeeOther)
+}
+
+// renameAdvice maps RenameAppIn's refusals onto the sentence a person can
+// act on, without leaking storage details they cannot.
+func renameAdvice(err error, to string) string {
+ msg := err.Error()
+ switch {
+ case strings.Contains(msg, "already has an app"):
+ return "This account already has an app (or a trashed app) named " + to + "."
+ case strings.Contains(msg, "legacy owner grant"):
+ return "One of these names carries a legacy grant — renaming across it is an operator act."
+ case strings.Contains(msg, "app credentials"):
+ return to + " still has app credentials from a previous app of that name — revoke them on the tokens page first."
+ default:
+ return "Could not rename — nothing changed. Try again."
+ }
+}
+
+// handleAppDelete puts the app in the trash: the deletion-policy marker in
+// the bucket, then this console's row — the same order and the same clock
+// discipline as the API door (handleAPITrashApp), behind the account-owner
+// ceremony: type the name, then the passkey. Restorable from the Trash for
+// the cooloff; hosts drop the app's routes on their next reconcile pass.
+func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(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 role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ }
+ // Typing the name is a "did you mean this app?" control, not a security
+ // one — checked before step-up so a slip does not cost a passkey touch.
+ if strings.TrimSpace(r.FormValue("confirm")) != app {
+ back("err", "Type the app's name exactly to delete it.")
+ return
+ }
+ if s.cfg.Releases == nil {
+ back("err", "No deployment bucket on this host — this console cannot delete an app.")
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ // Production outranks deletion, and an unreadable flag IS production —
+ // the deletion-policy reconciler will refuse the marker anyway, so
+ // writing one would only manufacture disagreement.
+ flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: trash %s/%s: production flag: %v", acct.Sqid, app, err)
+ back("err", "Could not read the production flag — an unreadable flag is treated as production, so deleting is refused. Re-declare it on the Releases page, then try again.")
+ return
+ }
+ if flag != nil && flag.Production {
+ back("err", app+" is flagged production, so deleting is refused. Turn the supply-chain bake off first (Releases page — owner, passkey, and a hold on every box), then try again.")
+ return
+ }
+ now := time.Now()
+ if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil {
+ log.Printf("console: trash %s/%s: %v", acct.Sqid, app, err)
+ back("err", "Could not write the trash marker — nothing was deleted. Try again.")
+ return
+ }
+ // The marker's clock is the feature's one clock: TrashApp is create-only,
+ // so a racing or repeated delete keeps the ORIGINAL DeletedAt, and the
+ // row must carry that, not this request's time. A re-read that fails
+ // therefore HARD-FAILS with nothing written to the row — the same rule as
+ // the API door: a row stamped with this request's clock would promise a
+ // restore deadline the purge pass does not honor, and it never self-heals
+ // (materialize short-circuits on a trashed row). The marker is already
+ // written, so a retry simply finishes the job.
+ tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil || tomb == nil {
+ log.Printf("console: trash %s/%s: re-read marker: %v", acct.Sqid, app, err)
+ back("err", "The trash marker was written but could not be read back, so this console's record was left alone — delete again to finish.")
+ return
+ }
+ deletedAt := tomb.DeletedAt
+ if err := s.store.TrashAppRow(acct.ID, app, deletedAt); err != nil {
+ log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err)
+ back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.")
+ return
+ }
+ s.audit(acct, app, addr, "trash", "moved "+app+" to the trash")
+ s.setFlash(w, "ok", "Moved "+app+" to the trash — restorable there until "+
+ deletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02")+"; hosts stop serving it on their next pass.")
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther)
+}
+
+// handleAppMoveAccount is the repair half of an account move: when the
+// app's routes already say another account owns it (AccountMismatch), this
+// makes the console's record agree — `console migrate-app`, from the
+// browser, for the owner standing in both accounts. The target comes from
+// the ROUTES, never the form: this door re-homes the record to where the
+// platform already deploys from, and nowhere else.
+func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, app, ok := s.accountApp(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 role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ }
+ target, err := s.routeAccountFor(app)
+ if err != nil {
+ back("err", "Could not read the routes — nothing was moved. Try again.")
+ return
+ }
+ if target == "" || target == acct.Sqid {
+ back("err", "This app's routes agree with this account — there is nothing to move.")
+ return
+ }
+ dest, err := s.store.AccountBySqid(target)
+ if err != nil || dest == nil {
+ back("err", "The routes name account "+target+", which this console does not know — that is an operator repair.")
+ return
+ }
+ if drole, err := s.store.Role(dest.ID, addr); err != nil || drole == "" {
+ back("err", "You are not a member of "+dest.Name+" ("+target+"), so moving the record there would strand the app for you. Ask an owner of that account, or an operator, to finish the move.")
+ return
+ }
+ // The card PINS its target: the confirmed POST carries the sqid the card
+ // named, and a mismatch with the freshly re-derived target refuses. The
+ // window between card and confirmed POST includes a passkey round trip —
+ // long enough for an operator to re-stamp the routes to some OTHER
+ // account the viewer also belongs to, and the record must never move to
+ // an account nobody saw on a card.
+ if !confirmed(r) {
+ cv := &ConfirmView{
+ Title: "Move " + app + " to " + dest.Name + "?",
+ Lines: []string{
+ "This app's hosts already read releases from " + dest.Name + " (" + target + ") — the console's own record is what still says " + acct.Name + ".",
+ "Moving makes them agree: the app's page, promotes and activity live under " + dest.Name + " from here on.",
+ },
+ Note: "You'll confirm it's really you with your passkey.",
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/move",
+ Fields: [][2]string{
+ {"csrf", raw}, {"to", dest.Sqid},
+ },
+ Button: "Move to " + dest.Name,
+ CancelTab: "settings",
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+ return
+ }
+ if pinned := strings.TrimSpace(r.FormValue("to")); pinned != "" && pinned != dest.Sqid {
+ back("err", "This app's routes changed while you were confirming — review the move again.")
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ if err := s.store.MoveApp(app, acct.ID, dest.ID); err != nil {
+ log.Printf("console: move %s %s -> %s: %v", app, acct.Sqid, target, err)
+ back("err", "Could not move the record — nothing changed. "+moveAdvice(err, dest.Name))
+ return
+ }
+ s.audit(acct, app, addr, "move", "moved "+app+" to account "+dest.Name+" ("+target+")")
+ if err := s.store.RecordEvent(dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil {
+ log.Printf("console: move event %s: %v", app, err)
+ }
+ s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.")
+ http.Redirect(w, r, "/a/"+url.PathEscape(dest.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther)
+}
+
+// moveAdvice maps MoveApp's refusals to actionable text.
+func moveAdvice(err error, destName string) string {
+ if strings.Contains(err.Error(), "already claims") {
+ return destName + " already has a different app with this name."
+ }
+ return "Try again."
+}
+
+// routeAccountFor reads what the ROUTES say the app's account is — the same
+// rule Assemble applies (first non-platform route's stamped account), read
+// fresh at action time so the move never trusts a stale page.
+func (s *Server) routeAccountFor(app string) (string, error) {
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return "", err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return "", err
+ }
+ for _, rt := range routes {
+ if rt.App == app && !rt.Platform {
+ return release.RouteAccount(rt), nil
+ }
+ }
+ return "", nil
+}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
new file mode 100644
index 000000000..aeea374d9
--- /dev/null
+++ b/internal/console/appsettings_test.go
@@ -0,0 +1,657 @@
+package console
+
+// The app Settings tab's write half (appsettings.go): rename, delete, and the
+// account-move repair, plus the store verb rename is built on.
+//
+// The theme running through all of it is that this console may only change its
+// OWN record. Every test here therefore checks two things about a refusal: that
+// the person is told, and that nothing outside this database moved — because
+// the whole reason renaming is refused after a ship, and deletion is refused on
+// a production app, is that the other stores would not follow.
+
+import (
+ "context"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ---- Store.RenameAppIn ------------------------------------------------------
+
+// renameStore is an account with one app in it — the smallest world a rename
+// means anything in.
+func renameStore(t *testing.T, app string) (*Store, int64) {
+ t.Helper()
+ s := openTestStore(t)
+ id := testAccountID()
+ if err := s.CreateAccount(id, "rn"+app, "Renameco", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if created, err := s.CreateAppIn(id, app, "paul@keymail.dev", time.Now()); err != nil || !created {
+ t.Fatalf("CreateAppIn = %v %v", created, err)
+ }
+ return s, id
+}
+
+// The happy path, and the half that is easy to forget: the app's audit history
+// follows it. Its story belongs to the app, not to a name that no longer
+// resolves — a rename that stranded the trail would leave the Activity tab of
+// the renamed app blank and the events unreachable under any name.
+//
+// RED as written (2026-08-07): RenameAppIn's UPDATE is guarded by
+// `deleted_at IS NULL`, and apps.deleted_at is `INTEGER NOT NULL DEFAULT 0` —
+// so the guard matches no row, ever, and every rename returns "this account
+// does not claim %q". The guard the rest of the file uses for "live" is
+// `deleted_at = 0`.
+func TestRenameAppInStoreRenamesRowAndAudit(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if err := s.RecordEvent(id, "alpha", "paul@keymail.dev", "create", "created app alpha", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+
+ if err := s.RenameAppIn(id, "alpha", "beta"); err != nil {
+ t.Fatalf("RenameAppIn(alpha -> beta) = %v, want the rename to land", err)
+ }
+ if has, err := s.HasAppIn(id, "beta"); err != nil || !has {
+ t.Fatalf("HasAppIn(beta) = %v %v, want the row under its new name", has, err)
+ }
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || has {
+ t.Fatalf("HasAppIn(alpha) = %v %v, want the old name gone", has, err)
+ }
+ moved, err := s.Events(id, "beta", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(moved) != 1 || moved[0].Detail != "created app alpha" {
+ t.Fatalf("Events(beta) = %+v, want the app's history to have followed it", moved)
+ }
+ left, err := s.Events(id, "alpha", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(left) != 0 {
+ t.Fatalf("Events(alpha) = %+v, want nothing left under the old name", left)
+ }
+}
+
+// The name must be free in this account — and a TRASHED row still owns its
+// name until it is purged, exactly as CreateAppIn treats it. Renaming onto one
+// would resurrect a collision the moment the trashed app was restored.
+func TestRenameAppInRefusesTakenNames(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if _, err := s.CreateAppIn(id, "live", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.CreateAppIn(id, "binned", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.TrashAppRow(id, "binned", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ for _, to := range []string{"live", "binned"} {
+ err := s.RenameAppIn(id, "alpha", to)
+ if err == nil {
+ t.Fatalf("RenameAppIn(alpha -> %s) succeeded onto a claimed name", to)
+ }
+ if !strings.Contains(err.Error(), "already has an app") {
+ t.Fatalf("RenameAppIn(alpha -> %s) = %v, want the taken-name refusal", to, err)
+ }
+ // And the refusal is total: nothing moved.
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
+ t.Fatalf("HasAppIn(alpha) = %v %v after a refused rename, want it untouched", has, err)
+ }
+ }
+}
+
+// Legacy owners-table grants are keyed by BARE app name across all accounts, so
+// rewriting one could hand an account's grant to another, and renaming ONTO one
+// would let the new name inherit a grant nobody issued it. Both directions are
+// refused.
+func TestRenameAppInRefusesLegacyGrants(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ if err := s.Grant("paul@keymail.dev", "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
+ !strings.Contains(err.Error(), "legacy owner grant") {
+ t.Fatalf("renaming FROM a granted name = %v, want the legacy-grant refusal", err)
+ }
+ if err := s.Revoke("paul@keymail.dev", "alpha"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Grant("paul@keymail.dev", "gamma"); err != nil {
+ t.Fatal(err)
+ }
+ if err := s.RenameAppIn(id, "alpha", "gamma"); err == nil ||
+ !strings.Contains(err.Error(), "legacy owner grant") {
+ t.Fatalf("renaming ONTO a granted name = %v, want the legacy-grant refusal", err)
+ }
+ if has, err := s.HasAppIn(id, "alpha"); err != nil || !has {
+ t.Fatalf("HasAppIn(alpha) = %v %v after the refusals, want it untouched", has, err)
+ }
+}
+
+// Renaming something this account does not claim is an error, not a silent
+// no-op — the handler turns it into "could not rename", and a quiet success
+// would tell the owner an app moved when nothing did.
+func TestRenameAppInRefusesMissingSource(t *testing.T) {
+ s, id := renameStore(t, "alpha")
+ err := s.RenameAppIn(id, "ghost", "gamma")
+ if err == nil {
+ t.Fatal("renaming an app this account does not claim reported success")
+ }
+ if !strings.Contains(err.Error(), "does not claim") {
+ t.Fatalf("RenameAppIn(ghost -> gamma) = %v, want the unclaimed-source refusal", err)
+ }
+ if has, err := s.HasAppIn(id, "gamma"); err != nil || has {
+ t.Fatalf("HasAppIn(gamma) = %v %v, want no row conjured by the failed rename", has, err)
+ }
+}
+
+// ---- handleAppRename --------------------------------------------------------
+
+// freshApp claims an app name in the fixture account with nothing behind it:
+// no bucket keys, no routes. That is the ONLY state a rename is offered in, so
+// it is the state most of these tests start from.
+func freshApp(t *testing.T, fx *consoleFixture, app string) {
+ t.Helper()
+ if created, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", time.Now()); err != nil || !created {
+ t.Fatalf("CreateAppIn(%s) = %v %v", app, created, err)
+ }
+}
+
+// shipSomething puts one object under the app's bucket prefix. Which object is
+// beside the point: appDeployEmpty refuses on ANY key, because one key is
+// enough to prove the name is baked into a storage layer this handler cannot
+// rewrite.
+func shipSomething(t *testing.T, fx *consoleFixture, app string) {
+ t.Helper()
+ key := release.AppPrefix(fx.sqid, app) + "releases/v1/manifest.json"
+ if _, err := fx.rel.Put(context.Background(), key, []byte(`{"schema":1}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// The card, then the rename: a fresh app's name is still only a record here,
+// so the whole ceremony is confirm-and-go, with no passkey.
+//
+// RED as written (2026-08-07) for the same reason as
+// TestRenameAppInStoreRenamesRowAndAudit: the store verb underneath cannot
+// match a live row, so the confirmed POST flashes "Could not rename" and
+// lands back on the OLD settings URL. The card half of this test is green.
+func TestRenameHandlerConfirmsThenRenames(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "to": {"sapling"}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("renaming a never-shipped app demanded a passkey (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Rename sprout to sapling?", csrf)
+ if !strings.Contains(card, `<input type="hidden" name="to" value="sapling">`) {
+ t.Fatalf("the card does not carry the new name:\n%s", card)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has {
+ t.Fatalf("HasAppIn(sprout) = %v %v after the card, want it unrenamed", has, err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, body = postForm(t, c, fx.u("/apps/sprout/rename"), form)
+ if status != http.StatusSeeOther || loc != fx.path("/apps/sapling/settings") {
+ t.Fatalf("confirmed rename = %d -> %q, want 303 to the NEW settings URL:\n%s", status, loc, body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || !has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want the app under its new name", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || has {
+ t.Fatalf("HasAppIn(sprout) = %v %v, want the old name gone", has, err)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "Renamed sprout to sapling.") {
+ t.Fatalf("no rename flash:\n%s", page)
+ }
+ auditedBy(t, fx, "sapling", "rename", "paul@keymail.dev", "renamed sprout to sapling")
+}
+
+// One key under the app's prefix is enough. The refusal is a flash, not a card:
+// the handler decided before asking, so there is nothing to confirm.
+func TestRenameHandlerRefusesShippedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "shipped")
+ shipSomething(t, fx, "shipped")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/shipped/rename"),
+ url.Values{"csrf": {csrf}, "to": {"renamed"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/shipped/settings") {
+ t.Fatalf("rename of a shipped app = %d -> %q, want the flash redirect:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "This app has shipped") {
+ t.Fatalf("no shipped refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "shipped"); err != nil || !has {
+ t.Fatalf("HasAppIn(shipped) = %v %v, want the app untouched", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "renamed"); err != nil || has {
+ t.Fatalf("HasAppIn(renamed) = %v %v, want no new row", has, err)
+ }
+}
+
+// A route wearing the name is the other wider store: the name is the address.
+// Checked at ACTION time, not from the page's advice — a route added since the
+// page rendered must still stop the rename.
+func TestRenameHandlerRefusesRoutedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "wired")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ // Added AFTER the app was claimed and with nothing in the bucket, so the
+ // deploy-empty check passes and the ROUTE check is what refuses.
+ fx.addRoute(t, registry.Route{Host: "wired.carlosapp.dev", Kind: "instance", Status: "ready",
+ App: "wired", Channel: "stable", Addr: "127.0.0.1:9031"})
+
+ status, loc, body := postForm(t, c, fx.u("/apps/wired/rename"),
+ url.Values{"csrf": {csrf}, "to": {"unwired"}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/wired/settings") {
+ t.Fatalf("rename of a routed app = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "A host still routes this name") {
+ t.Fatalf("no routed refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "unwired"); err != nil || has {
+ t.Fatalf("HasAppIn(unwired) = %v %v, want no rename", has, err)
+ }
+}
+
+// Renaming is the owner's. A member meets the byte-identical 404 — before any
+// card could concede the action exists.
+func TestRenameHandlerIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-rename")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {csrf}, "to": {"sapling"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member renaming = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ strangerBody := strangerRefusal(t, fx, "code-zoe-rename", fx.u("/apps/sprout/rename"),
+ url.Values{"to": {"sapling"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
+ }
+}
+
+// CSRF is checked before ownership and before anything is read — a cross-site
+// POST is refused with 403 and never reaches the store.
+func TestRenameHandlerNeedsCSRF(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+
+ status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"),
+ url.Values{"csrf": {"not-the-token"}, "to": {"sapling"}, "confirmed": {"1"}})
+ if status != http.StatusForbidden {
+ t.Fatalf("rename with a bad csrf = %d, want 403:\n%s", status, body)
+ }
+ if !strings.Contains(body, "Cross-site request blocked.") {
+ t.Fatalf("403 body does not name the reason:\n%s", body)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "sapling"); err != nil || has {
+ t.Fatalf("HasAppIn(sapling) = %v %v, want no rename", has, err)
+ }
+}
+
+// ---- handleAppDelete --------------------------------------------------------
+
+// appTombstone reads the bucket's deletion marker — the thing that actually
+// makes a host stop serving. The console row alone is not a deletion.
+func appTombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
+ t.Helper()
+ tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, app)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return tomb
+}
+
+// Typing the name is a "did you mean this app?" control, checked BEFORE the
+// passkey so a slip does not cost a passkey touch — and, more to the point,
+// does not delete anything.
+func TestAppDeleteRequiresTheExactName(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kas"}})
+ if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body)
+ }
+ if loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("mistyped delete redirected to %q, want the Settings tab", loc)
+ }
+ // The apostrophe in "app's" is HTML-escaped on the way out, so the
+ // assertion takes the unambiguous tail of the sentence.
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "name exactly to delete it.") {
+ t.Fatalf("no mistyped-name flash:\n%s", page)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a mistyped delete wrote a trash marker: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+ var stashed int
+ fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
+ if stashed != 0 {
+ t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway")
+ }
+}
+
+// The whole ceremony: the name, then the passkey, then the marker and the row,
+// in that order, landing on the account home.
+func TestAppDeleteTrashesBehindThePasskey(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "confirm": {"kass"}}
+
+ // Without the passkey, nothing moves. (postForm, not postGated: the
+ // un-stepped-up post is what proves the gate is there at all.)
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/delete"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a trash marker was written before the passkey confirmation: %+v", tomb)
+ }
+
+ status, loc, body := completeStepUp(t, fx, c, loc)
+ if status != http.StatusSeeOther || loc != fx.path("/") {
+ t.Fatalf("resumed delete = %d -> %q, want 303 to the account home:\n%s", status, loc, body)
+ }
+ tomb := appTombstone(t, fx, "kass")
+ if tomb == nil {
+ t.Fatal("no trash marker in the bucket — hosts would go on serving the app")
+ }
+ if tomb.Actor != "paul@keymail.dev" {
+ t.Fatalf("marker actor = %q, want the owner who deleted it", tomb.Actor)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn = %v %v, want the live row gone", has, err)
+ }
+ if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasTrashedAppIn = %v %v, want the row in the trash", has, err)
+ }
+ auditedBy(t, fx, "kass", "trash", "paul@keymail.dev", "moved kass to the trash")
+}
+
+// Production outranks deletion, all the way through the ceremony: the flag is
+// read after the passkey, and refuses even a fully confirmed owner.
+func TestAppDeleteRefusesAProductionApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
+ release.OpsAccount, "kass", true, "test"); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kass"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
+ }
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is flagged production, so deleting is refused") {
+ t.Fatalf("no production refusal flash:\n%s", page)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a production app got a trash marker anyway: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+}
+
+// Deleting is the owner's, and the refusal is the byte-identical 404 — a
+// member must not learn that the door exists.
+func TestAppDeleteIsOwnerOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-sam-appdelete")
+ csrf := sessionToken(t, fx, c)
+
+ status, _, memberBody := postForm(t, c, fx.u("/apps/kass/delete"),
+ url.Values{"csrf": {csrf}, "confirm": {"kass"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member deleting an app = %d, want 404", status)
+ }
+ strangerBody := strangerRefusal(t, fx, "code-zoe-appdelete", fx.u("/apps/kass/delete"),
+ url.Values{"confirm": {"kass"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if tomb := appTombstone(t, fx, "kass"); tomb != nil {
+ t.Fatalf("a member's refused delete wrote a marker: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the row live", has, err)
+ }
+}
+
+// ---- handleAppMoveAccount ---------------------------------------------------
+
+// stampKassRoutes re-writes the fixture's kass routes with a stamped account,
+// which is what `carlos accounts migrate` leaves behind: the routes name the
+// new account, the console record still names the old one. That disagreement
+// IS the state this handler repairs — and the target comes from the routes,
+// never the form.
+func stampKassRoutes(t *testing.T, fx *consoleFixture, acct string) {
+ t.Helper()
+ fx.addRoute(t, registry.Route{Host: "kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v1", App: "kass", Channel: "stable", Account: acct, Addr: "127.0.0.1:9001"})
+ fx.addRoute(t, registry.Route{Host: "docs.kass.carlosapp.dev", Kind: "static", Status: "ready",
+ Version: "v1", App: "kass", Account: acct, Hibernate: true})
+}
+
+// The repair: card, passkey, and the record lands in the account the routes
+// already deploy from — with the arrival recorded on BOTH accounts, since
+// neither activity trail alone tells the whole story afterwards.
+func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const destID = 4242
+ if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ stampKassRoutes(t, fx, "cdef")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner met the passkey before the card (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Move kass to Second?", csrf)
+ if !strings.Contains(card, "Move to Second") {
+ t.Fatalf("the card's button does not name the destination:\n%s", card)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(ops) = %v %v after the card, want the record unmoved", has, err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/move"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("confirmed move = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(dest) = %v %v before the passkey, want nothing moved", has, err)
+ }
+
+ status, loc, body = completeStepUp(t, fx, c, loc)
+ if status != http.StatusSeeOther || loc != "/a/cdef/apps/kass" {
+ t.Fatalf("resumed move = %d -> %q, want 303 to the app under its new account:\n%s", status, loc, body)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(dest) = %v %v, want the record in the destination account", has, err)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(ops) = %v %v, want the record gone from the source", has, err)
+ }
+ // Both trails: the source records the departure, the destination the arrival.
+ auditedBy(t, fx, "kass", "move", "paul@keymail.dev", "moved kass to account Second (cdef)")
+ arrived, err := fx.st.Events(destID, "kass", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var found bool
+ for _, e := range arrived {
+ if e.Action == "move" && strings.Contains(e.Detail, "arrived from account") {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("destination events = %+v, want the arrival recorded there too", arrived)
+ }
+}
+
+// Moving the record into an account the viewer does not belong to would strand
+// the app for them — refused, with the operator path named instead.
+func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const destID = 4343
+ if err := fx.st.CreateAccount(destID, "cghi", "Third", "kim@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ stampKassRoutes(t, fx, "cghi")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
+ url.Values{"csrf": {csrf}, "confirmed": {"1"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("move into a stranger account = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "You are not a member of Third (cghi)") {
+ t.Fatalf("no non-membership refusal flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
+ }
+ if has, err := fx.st.HasAppIn(destID, "kass"); err != nil || has {
+ t.Fatalf("HasAppIn(dest) = %v %v, want nothing moved", has, err)
+ }
+}
+
+// With no disagreement there is nothing to repair — and no card, because the
+// handler has already decided.
+func TestAppMoveRefusesWhenRoutesAgree(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // The fixture's routes carry no stamped account, which reads as the ops
+ // account — the very account this page belongs to.
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("move with agreeing routes = %d -> %q:\n%s", status, loc, body)
+ }
+ notACard(t, body)
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
+ "there is nothing to move") {
+ t.Fatalf("no nothing-to-move flash:\n%s", page)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
+ t.Fatalf("HasAppIn = %v %v, want the record untouched", has, err)
+ }
+}
+
+// ---- the Settings page ------------------------------------------------------
+
+// The rename form is offered while the name is still only a record here, and
+// replaced by the reason the moment the bucket knows it.
+func TestSettingsPageRenameFormFollowsTheBucket(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, `id="rename-to"`) ||
+ !strings.Contains(body, `action="/a/bab/apps/sprout/rename"`) {
+ t.Fatalf("a never-shipped app is offered no rename form:\n%s", body)
+ }
+
+ shipSomething(t, fx, "sprout")
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ if strings.Contains(body, `id="rename-to"`) {
+ t.Fatalf("the rename form survived the app's first ship:\n%s", body)
+ }
+ if !strings.Contains(body, "Renaming: This app has shipped") {
+ t.Fatalf("the page does not say why renaming is off:\n%s", body)
+ }
+}
+
+// Everything on this page that changes the app's identity is the owner's. A
+// member sees the identity and the domains, and neither form.
+func TestSettingsPageHidesOwnerFormsFromMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+ // The owner's own view first, so the member-side absences below cannot
+ // pass vacuously: both forms ARE eligible to render in this exact state.
+ owner := signInPaul(t, fx)
+ _, _, obody := get(t, owner, fx.u("/apps/sprout/settings"))
+ if !strings.Contains(obody, `id="rename-to"`) || !strings.Contains(obody, `id="delete-confirm"`) {
+ t.Fatalf("the owner is not offered both forms — the member checks below would be vacuous:\n%s", obody)
+ }
+
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-settings")
+ status, _, body := get(t, cm, fx.u("/apps/sprout/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("member settings page = %d, want 200:\n%s", status, body)
+ }
+ if !strings.Contains(body, "Domains") {
+ t.Fatalf("a member cannot see the page at all:\n%s", body)
+ }
+ if strings.Contains(body, `id="rename-to"`) || strings.Contains(body, "/rename\"") {
+ t.Errorf("a member is offered the rename form:\n%s", body)
+ }
+ if strings.Contains(body, `id="delete-confirm"`) || strings.Contains(body, "/delete\"") {
+ t.Errorf("a member is offered the delete form:\n%s", body)
+ }
+}
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go
index d62dc69e0..7eaf8d748 100644
--- a/internal/console/bakecontrols_test.go
+++ b/internal/console/bakecontrols_test.go
@@ -168,7 +168,7 @@ func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) {
// The fixture's stable already points at v1. Without the passkey the
// waiver does not happen.
status, loc, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("un-stepped-up fast-track = %d -> %q, want a redirect into Keymail", status, loc)
}
@@ -204,7 +204,7 @@ func TestFastTrackRefusesAChannelWithNoBakeWindow(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, _, _ := postGated(t, fx, c, fx.u("/apps/kass/fasttrack"),
- url.Values{"csrf": {csrf}, "channel": {"canary/x"}})
+ url.Values{"csrf": {csrf}, "channel": {"canary/x"}, "confirmed": {"1"}})
if status != http.StatusSeeOther {
t.Fatalf("fast-track of a bake-free channel = %d, want the flash redirect", status)
}
@@ -268,7 +268,7 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) {
csrf := sessionToken(t, fx, c)
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" {
t.Fatalf("stable = %s, want v3", p.Version)
}
@@ -298,7 +298,7 @@ func TestPromoteEnableBakeSaysWhenItCouldNotRead(t *testing.T) {
}
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" {
t.Fatalf("stable = %s, want v3 — the promote itself must not depend on the flag", p.Version)
}
@@ -326,7 +326,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
// Checked, not fired and forgotten: a refused promote would make the
// absence assertion below true for the wrong reason.
if status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}}); status != http.StatusSeeOther ||
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther ||
loc != fx.path("/apps/kass") {
t.Fatalf("beta promote = %d -> %q:\n%s", status, loc, body)
}
@@ -345,7 +345,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
shipVersion(t, fx, "kass", "v4")
seedChannel(t, fx, "kass", "edge", "v4")
if status, _, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}}); status != http.StatusSeeOther {
+ url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
t.Fatalf("beta promote of v4 = %d:\n%s", status, body)
}
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Adopts after the bake window") {
diff --git a/internal/console/confirm.go b/internal/console/confirm.go
new file mode 100644
index 000000000..c088c7b76
--- /dev/null
+++ b/internal/console/confirm.go
@@ -0,0 +1,233 @@
+// The action confirm step: a server-rendered interstitial between clicking a
+// button that changes what serves and it happening. One page, one card: what
+// is about to happen in plain sentences, one button that does it, one link
+// that does not.
+//
+// This is a UI affordance, not a gate — the security gates (session, CSRF,
+// owner 404, requireFreshAuth) neither move nor weaken for it, and a
+// hand-written POST with confirmed=1 meets exactly the doors a button does.
+// Placement inside each handler is therefore load-bearing:
+//
+// - AFTER CSRF and input validation, so the card describes a request that
+// would otherwise proceed, never an invalid one.
+// - AFTER any owner-only 404, never before it: a member probing an
+// owner-only action must meet the byte-identical 404, not a confirm card
+// that concedes the action exists for somebody.
+// - BEFORE requireFreshAuth, so the passkey round trip replays the POST
+// confirmed — confirm asks "do you mean it?", step-up asks "is it you?",
+// in that order, each exactly once.
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "strconv"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ConfirmView is everything the interstitial renders.
+type ConfirmView struct {
+ Title string
+ // Lines are the card's sentences: current state, then what changes.
+ Lines []string
+ // Warn is the one red line, for the part that must not be skimmed past.
+ Warn string
+ // Note is the quiet line after the button — what happens next (a bake
+ // window, a passkey prompt), muted.
+ Note string
+ // Action is the POST target; Fields the hidden name/value pairs carried
+ // through, csrf included. The confirmed=1 marker is the template's.
+ Action string
+ Fields [][2]string
+ // Checkbox, optional: a real choice that belongs at confirm time (the
+ // promote-to-stable bake opt-in). Rendered only when Name is non-empty.
+ CheckboxName string
+ CheckboxLabel string
+ CheckboxChecked bool
+ Button string
+ Danger bool
+ // CancelTab is which app tab the cancel link lands on ("" = Overview);
+ // renderConfirm derives the URL from it, since only the assembled page
+ // knows the account's link segment.
+ CancelTab string
+ CancelURL string
+}
+
+// confirmed reports whether this POST already carries the confirm step's
+// approval. The zero value of a form nobody edited is absence, so any other
+// value stays unconfirmed.
+func confirmed(r *http.Request) bool { return r.FormValue("confirmed") == "1" }
+
+// renderConfirm renders the interstitial with the account chrome, titled by
+// the action. 200, not a redirect: the card IS this POST's response, and the
+// resubmit it hosts is a fresh POST of the same fields plus confirmed=1.
+func (s *Server) renderConfirm(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app string, cv *ConfirmView) {
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = cv.Title
+ p.AppName = app
+ p.ActiveNav = "apps"
+ if cv.CancelURL == "" {
+ cv.CancelURL = p.Nav + "/apps/" + url.PathEscape(app)
+ if cv.CancelTab != "" {
+ cv.CancelURL += "/" + cv.CancelTab
+ }
+ }
+ p.Confirm = cv
+ s.render(w, http.StatusOK, "confirm_action", p)
+}
+
+// backTab maps the action forms' back field onto the tab a cancel link (and
+// nothing else) should land on. Only values the redirects themselves honor.
+func backTab(r *http.Request) string {
+ if r.FormValue("back") == "releases" {
+ return "releases"
+ }
+ return ""
+}
+
+// holdText renders a bake hold the way the card speaks: "~24h".
+func holdText(d time.Duration) string {
+ return "~" + strconv.Itoa(int(d.Hours())) + "h"
+}
+
+// confirmPromote is the promote door's card. Everything here is best-effort
+// reading for the card's sentences — the confirmed POST re-validates from
+// scratch, so a read that fails here costs precision, never safety.
+func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, version string) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ cv := &ConfirmView{
+ Title: "Promote " + version + " to " + channel + "?",
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/promote",
+ Fields: [][2]string{
+ {"csrf", raw}, {"channel", channel}, {"version", version}, {"back", r.FormValue("back")},
+ },
+ Button: "Promote " + version,
+ CancelTab: backTab(r),
+ }
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
+ switch {
+ case errors.Is(err, os.ErrNotExist):
+ cv.Lines = append(cv.Lines, "This creates channel "+channel+", serving "+version+" as soon as a host follows it.")
+ case err != nil:
+ cv.Lines = append(cv.Lines, "After this, "+channel+" serves "+version+".")
+ case cur.Version == version:
+ cv.Lines = append(cv.Lines, channel+" already serves "+version+" — confirming changes nothing.")
+ default:
+ cv.Lines = append(cv.Lines,
+ channel+" serves "+cur.Version+" now.",
+ "After this, it serves "+version+".")
+ }
+ rung, _ := release.Rung(channel)
+ stable := rung == release.RungStable
+ if stable {
+ cv.Warn = "Stable is what every user of " + app + " gets — this puts " + version + " in front of everyone."
+ }
+ // Whether the promotion bakes, and whether to offer turning the bake on:
+ // the same flag logic the confirmed POST applies, read here only to make
+ // the card tell the truth about what happens next.
+ flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ baking := ferr != nil || (flag != nil && flag.Production)
+ if hold := release.HoldFor(release.DefaultHolds, channel); hold > 0 && baking {
+ cv.Lines = append(cv.Lines, "This app bakes: each host holds "+version+" for "+holdText(hold)+" on "+channel+" before adopting it.")
+ }
+ if stable && ferr == nil && !baking {
+ cv.CheckboxName = "enable_bake"
+ cv.CheckboxChecked = true
+ cv.CheckboxLabel = "This app is in production — enable the supply-chain bake (recommended)"
+ }
+ if stable {
+ cv.Note = "You'll confirm it's really you with your passkey."
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+}
+
+// confirmRollback: the card knows both versions because the handler read the
+// pointer before asking — a rollback card that cannot say where it is going
+// has no business asking anyone to confirm it.
+func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, cur, target string) {
+ cv := &ConfirmView{
+ Title: "Roll back " + channel + " to " + target + "?",
+ Lines: []string{
+ channel + " serves " + cur + " now.",
+ "Rolling back re-points it to " + target + ", the version before this one.",
+ },
+ Note: "Rollback never bakes — hosts adopt on their next pass.",
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback",
+ // target pins what the card promised: the confirmed POST refuses if
+ // the channel's history has moved on since this was rendered.
+ Fields: [][2]string{
+ {"csrf", raw}, {"channel", channel}, {"target", target}, {"back", r.FormValue("back")},
+ },
+ Button: "Roll back to " + target,
+ Danger: true,
+ CancelTab: backTab(r),
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+}
+
+// confirmFastTrack reads the channel best-effort for the card's sentences;
+// the confirmed POST re-validates (and still refuses a channel with no bake
+// window) from scratch.
+func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel string) {
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+ cv := &ConfirmView{
+ Title: "Fast-track " + channel + "?",
+ Warn: "The bake window is the supply-chain guard — waiving it ships a build that hasn't finished proving itself.",
+ Note: "You'll confirm it's really you with your passkey. The pointer wears the fast-track badge permanently.",
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/fasttrack",
+ Fields: [][2]string{
+ {"csrf", raw}, {"channel", channel},
+ },
+ Button: "Fast-track",
+ Danger: true,
+ }
+ if cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel); err == nil {
+ cv.Title = "Fast-track " + cur.Version + " on " + channel + "?"
+ cv.Lines = append(cv.Lines,
+ channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its bake window.",
+ "Fast-tracking waives the rest of the window: hosts adopt "+cur.Version+" on their next pass.")
+ cv.Button = "Fast-track " + cur.Version
+ // Pin the version the card names — the waiver must not land on a
+ // build promoted after this was rendered.
+ cv.Fields = append(cv.Fields, [2]string{"version", cur.Version})
+ } else {
+ cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s bake window: hosts adopt its current version on their next pass.")
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+}
+
+// confirmRetireCanary: the handler has already established the canary exists
+// and nothing follows it, so the card states exactly those two facts.
+func (s *Server) confirmRetireCanary(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, name string) {
+ cv := &ConfirmView{
+ Title: "Retire canary " + name + "?",
+ Lines: []string{
+ "Deletes channel canary/" + name + " — the pointer, not any shipped release.",
+ "No host follows it, so nothing serving changes.",
+ },
+ Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/canary/retire",
+ Fields: [][2]string{
+ {"csrf", raw}, {"name", name}, {"back", r.FormValue("back")},
+ },
+ Button: "Retire " + name,
+ Danger: true,
+ CancelTab: backTab(r),
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+}
+
+// ago mirrors the template helper of the same name for the card builders'
+// sentences: humanAge's coarse resolution, dashing out the zero time.
+func ago(t time.Time) string {
+ if t.IsZero() {
+ return "—"
+ }
+ return humanAge(time.Since(t)) + " ago"
+}
diff --git a/internal/console/confirm_test.go b/internal/console/confirm_test.go
new file mode 100644
index 000000000..e14ec9e6f
--- /dev/null
+++ b/internal/console/confirm_test.go
@@ -0,0 +1,314 @@
+package console
+
+// The action confirm interstitial (confirm.go). The properties under test are
+// the ones the file's own doc comment calls load-bearing: the card describes a
+// request that would otherwise proceed, it never renders for someone the owner
+// 404 is meant to turn away, and it sits strictly BETWEEN that 404 and the
+// passkey — so an owner sees the card first and is asked for the passkey only
+// on the confirmed replay.
+//
+// Every card test also lands the confirmed POST. A card that renders but whose
+// resubmit does not carry the action through is a dead end, and asserting only
+// on the markup would not notice.
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "os"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// confirmCard asserts the response IS a confirm card — 200, the titled
+// heading, the confirmed=1 marker the resubmit turns on, the CSRF token
+// carried through, and a submit button — and hands the body back for the
+// card-specific assertions. Everything here is what makes the card a working
+// resubmit rather than a screenshot of one.
+func confirmCard(t *testing.T, status int, body, title, csrf string) string {
+ t.Helper()
+ if status != http.StatusOK {
+ t.Fatalf("confirm step = %d, want a 200 card:\n%s", status, body)
+ }
+ if !strings.Contains(body, title) {
+ t.Fatalf("card is not titled %q:\n%s", title, body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="confirmed" value="1">`) {
+ t.Fatalf("card carries no confirmed=1 marker, so its button cannot land the action:\n%s", body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="csrf" value="`+csrf+`">`) {
+ t.Fatalf("card carries no csrf field, so its resubmit would be refused:\n%s", body)
+ }
+ if !strings.Contains(body, `<button type="submit"`) {
+ t.Fatalf("card has no submit button:\n%s", body)
+ }
+ return body
+}
+
+// notACard is the negative: whatever this response is, it must not be an
+// interstitial. Used wherever a card would concede that an action exists for
+// somebody (the owner-only 404s) or that it would otherwise proceed (the
+// cheap refusals).
+func notACard(t *testing.T, body string) {
+ t.Helper()
+ if strings.Contains(body, `name="confirmed"`) || strings.Contains(body, "confirm-card") {
+ t.Fatalf("a confirm card was rendered where none may be:\n%s", body)
+ }
+}
+
+// The ordinary promote: the unconfirmed POST is answered with the card and
+// changes nothing; the same POST plus confirmed=1 moves the channel.
+func TestConfirmPromoteCardThenLands(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "edge", "v3") // beta is entered from edge
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"beta"}, "version": {"v3"}}
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/promote"), form)
+ card := confirmCard(t, status, body, "Promote v3 to beta?", csrf)
+ // The card carries what the resubmit needs to be the SAME request.
+ for _, want := range []string{
+ `<input type="hidden" name="channel" value="beta">`,
+ `<input type="hidden" name="version" value="v3">`,
+ } {
+ if !strings.Contains(card, want) {
+ t.Fatalf("card does not carry %s:\n%s", want, card)
+ }
+ }
+ if !strings.Contains(card, "Promote v3") {
+ t.Fatalf("the card's button does not name the promotion:\n%s", card)
+ }
+ // A card is a question, not a deferral: beta does not exist yet.
+ if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
+ release.OpsAccount, "kass", "beta"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("beta = %v after an UNconfirmed promote, want it still absent", err)
+ }
+
+ form.Set("confirmed", "1")
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"), form)
+ if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
+ t.Fatalf("confirmed promote = %d -> %q, want 303 to the app page:\n%s", status, loc, body)
+ }
+ if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
+ t.Fatalf("beta = %s after a confirmed promote, want v3", p.Version)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Promoted v3 to beta.") {
+ t.Fatalf("no promote flash:\n%s", page)
+ }
+}
+
+// The stable-target card is the one with a real choice on it: bake is off for
+// this app, so the card offers to turn it on. And the card is strictly for
+// owners — a member probing the stable rung meets the byte-identical 404 with
+// no card in it, because a card would concede the action exists for somebody.
+func TestConfirmStablePromoteOffersBakeAndHidesFromMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "beta", "v3") // stable is entered from beta
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ // The card comes BEFORE the passkey: an owner is asked "do you mean it?"
+ // first, and only the confirmed replay meets requireFreshAuth.
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner was sent to Keymail before the card was shown (-> %q)", loc)
+ }
+ card := confirmCard(t, status, body, "Promote v3 to stable?", csrf)
+ if !strings.Contains(card, `name="enable_bake"`) {
+ t.Fatalf("the stable card carries no enable_bake choice for a bake-off app:\n%s", card)
+ }
+ if !strings.Contains(card, "puts v3 in front of everyone") {
+ t.Fatalf("the stable card does not warn what stable means:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("stable moved to %s on an unconfirmed post", p.Version)
+ }
+
+ // A member: the byte-identical 404, and not a card in sight.
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-confirm-stable")
+ mcsrf := sessionToken(t, fx, cm)
+ status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {mcsrf}, "channel": {"stable"}, "version": {"v3"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member promoting to stable = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ strangerBody := strangerRefusal(t, fx, "code-zoe-confirm-stable", fx.u("/apps/kass/promote"),
+ url.Values{"channel": {"stable"}, "version": {"v3"}})
+ if memberBody != strangerBody {
+ t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody)
+ }
+ if memberBody != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
+ }
+}
+
+// The rollback card names BOTH versions — a rollback card that cannot say
+// where it is going has no business asking anyone to confirm it — and the
+// confirmed post re-points the channel.
+func TestConfirmRollbackCardShowsBothVersions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ // stable: v1 (fixture) then v2, so the pointer has a history to go back to.
+ seedChannel(t, fx, "kass", "stable", "v2")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form)
+ card := confirmCard(t, status, body, "Roll back stable to v1?", csrf)
+ if !strings.Contains(card, "stable serves v2 now.") {
+ t.Fatalf("the card does not say where the channel is now:\n%s", card)
+ }
+ if !strings.Contains(card, "Roll back to v1") {
+ t.Fatalf("the card's button does not name the target version:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v2" {
+ t.Fatalf("stable = %s on an unconfirmed rollback, want it untouched at v2", p.Version)
+ }
+
+ form.Set("confirmed", "1")
+ if status, _, body := postForm(t, c, fx.u("/apps/kass/rollback"), form); status != http.StatusSeeOther {
+ t.Fatalf("confirmed rollback = %d:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
+ t.Fatalf("stable = %s after a confirmed rollback, want v1", p.Version)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page, "Rolled back stable to v1.") {
+ t.Fatalf("no rollback flash:\n%s", page)
+ }
+}
+
+// Fast-track is the whole gate order in one handler: member → 404 with no
+// card; owner → card BEFORE any Keymail round trip; confirmed owner → the
+// passkey, and only then the waiver.
+func TestConfirmFastTrackCardComesBeforeStepUp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("sam@keymail.dev")
+ cm := newBrowser(t)
+ signUp(t, fx, cm, "code-sam-confirm-ft")
+ mcsrf := sessionToken(t, fx, cm)
+ status, _, memberBody := postForm(t, cm, fx.u("/apps/kass/fasttrack"),
+ url.Values{"csrf": {mcsrf}, "channel": {"stable"}})
+ if status != http.StatusNotFound {
+ t.Fatalf("member fast-tracking = %d, want 404", status)
+ }
+ notACard(t, memberBody)
+ if memberBody != "404 page not found\n" {
+ t.Fatalf("refusal body = %q, want the stdlib http.NotFound output", memberBody)
+ }
+
+ fx.setUser("paul@keymail.dev")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ form := url.Values{"csrf": {csrf}, "channel": {"stable"}}
+
+ status, loc, body := postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
+ if strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("the owner met the passkey before the card (-> %q) — confirm asks \"do you mean it?\" first", loc)
+ }
+ card := confirmCard(t, status, body, "Fast-track v1 on stable?", csrf)
+ if !strings.Contains(card, "The bake window is the supply-chain guard") {
+ t.Fatalf("the fast-track card carries no warning:\n%s", card)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
+ t.Fatal("the window was waived by merely asking")
+ }
+
+ // The confirmed replay is where the passkey is demanded — and where it lands.
+ form.Set("confirmed", "1")
+ status, loc, _ = postForm(t, c, fx.u("/apps/kass/fasttrack"), form)
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("confirmed fast-track = %d -> %q, want a redirect into Keymail", status, loc)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
+ t.Fatal("the window was waived with no passkey confirmation")
+ }
+ if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther {
+ t.Fatalf("resumed fast-track = %d:\n%s", status, body)
+ }
+ if p := fx.channelVersion(t, "kass", "stable"); !p.FastTrack {
+ t.Fatalf("pointer = %+v after the confirmed, stepped-up waiver, want fast_track set", p)
+ }
+}
+
+// The retire card states exactly the two facts the handler established before
+// asking — the canary exists, and nothing follows it — so neither a canary
+// with a host on it nor a name that was never a canary gets one. The confirmed
+// post deletes the pointer.
+func TestConfirmRetireCanaryCardAndDeletion(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // No such canary: a refusal, never a card describing a deletion that
+ // could not happen.
+ status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"),
+ url.Values{"csrf": {csrf}, "name": {"nope"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("retire of a missing canary = %d, want the flash redirect:\n%s", status, body)
+ }
+ notACard(t, body)
+
+ // The fixture's canary/x exists and nothing follows it: the card.
+ form := url.Values{"csrf": {csrf}, "name": {"x"}}
+ status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"), form)
+ card := confirmCard(t, status, body, "Retire canary x?", csrf)
+ for _, want := range []string{
+ "Deletes channel canary/x",
+ "No host follows it",
+ `<input type="hidden" name="name" value="x">`,
+ } {
+ if !strings.Contains(card, want) {
+ t.Fatalf("the retire card is missing %q:\n%s", want, card)
+ }
+ }
+ if p := fx.channelVersion(t, "kass", "canary/x"); p.Version != "v2" {
+ t.Fatalf("canary/x = %+v after an unconfirmed retire, want it untouched", p)
+ }
+
+ form.Set("confirmed", "1")
+ if status, _, body := postForm(t, c, fx.u("/apps/kass/canary/retire"), form); status != http.StatusSeeOther {
+ t.Fatalf("confirmed retire = %d:\n%s", status, body)
+ }
+ if _, err := release.Channel(context.Background(), fx.rel, fx.pub,
+ release.OpsAccount, "kass", "canary/x"); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("canary/x = %v after a confirmed retire, want it gone", err)
+ }
+
+ // A canary with a follower never reaches the card at all — the refusal
+ // fires first, and a card here would be describing a deletion the handler
+ // has already decided against.
+ seedChannel(t, fx, "kass", "canary/y", "v2")
+ fx.addRoute(t, registry.Route{Host: "rc.kass.carlosapp.dev", Kind: "instance", Status: "ready",
+ Version: "v2", App: "kass", Channel: "canary/y", Addr: "127.0.0.1:9021"})
+ status, _, body = postForm(t, c, fx.u("/apps/kass/canary/retire"),
+ url.Values{"csrf": {csrf}, "name": {"y"}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("retire of a followed canary = %d, want the flash redirect:\n%s", status, body)
+ }
+ notACard(t, body)
+ if p := fx.channelVersion(t, "kass", "canary/y"); p.Version != "v2" {
+ t.Fatalf("canary/y = %+v, want the followed canary untouched", p)
+ }
+ if _, _, page := get(t, c, fx.u("/apps/kass")); !strings.Contains(page,
+ "still has a host (rc.kass.carlosapp.dev) following it") {
+ t.Fatalf("no follower refusal flash:\n%s", page)
+ }
+}
diff --git a/internal/console/icons.go b/internal/console/icons.go
index 4d6128bd8..ff349519f 100644
--- a/internal/console/icons.go
+++ b/internal/console/icons.go
@@ -96,6 +96,17 @@ const (
`<path d="M5 22h14"/><path d="M5 2h14"/>` +
`<path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/>` +
`<path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>`
+
+ // A pointer that arrived by hotfix — the ladder was bypassed, and the
+ // badge is permanent (Lucide: zap).
+ iconZap template.HTML = iconOpen +
+ `<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>`
+
+ // A pointer whose bake window was waived — fast-tracked, badge equally
+ // permanent (Lucide: fast-forward).
+ iconFastForward template.HTML = iconOpen +
+ `<polygon points="13 19 22 12 13 5 13 19"/>` +
+ `<polygon points="2 19 11 12 2 5 2 19"/></svg>`
)
// icons maps each Lucide slug to its vendored markup. Slugs are the Lucide
@@ -107,6 +118,8 @@ var icons = map[string]template.HTML{
"arrow-down": iconArrowDown,
"flask-conical": iconFlask,
"triangle-alert": iconTriangleAlert,
+ "zap": iconZap,
+ "fast-forward": iconFastForward,
"layers": iconLayers,
"hourglass": iconHourglass,
}
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index d94de51ba..818be79a8 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -90,7 +90,7 @@ func TestLadderRendersFourRungs(t *testing.T) {
release.Promotion{Version: "v4", From: "edge", To: "beta", Actor: "kim@keymail.dev"})
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("app page = %d", status)
}
@@ -110,9 +110,12 @@ func TestLadderRendersFourRungs(t *testing.T) {
if b := rungBlock(t, body, "edge"); !strings.Contains(b, "ci@keymail.dev") {
t.Errorf("the edge rung does not say who promoted it:\n%s", b)
}
- // The pre-audit pointer: rendered, and honest about what it cannot say.
- if b := rungBlock(t, body, "stable"); !strings.Contains(b, "not recorded") {
- t.Errorf("a pointer with no audit trail did not say so:\n%s", b)
+ // The pre-audit pointer: rendered, and honest about what it cannot say —
+ // the template now omits the "by ..." clause entirely rather than
+ // attributing the promotion to anybody, so the dishonest alternative
+ // (inventing an actor) is what must not appear.
+ if b := rungBlock(t, body, "stable"); strings.Contains(b, "ago by") {
+ t.Errorf("a pointer with no audit trail invented an actor:\n%s", b)
}
// Routes sit under the rung they subscribe to.
if b := rungBlock(t, body, "stable"); !strings.Contains(b, "kass.carlosapp.dev") {
@@ -128,7 +131,7 @@ func TestLadderPromoteControlIsOwnerOnly(t *testing.T) {
seedChannel(t, fx, "kass", "edge", "v3")
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "Promote v3 to beta") {
t.Fatalf("owner is offered no edge -> beta control:\n%s", body)
}
@@ -144,7 +147,7 @@ func TestLadderPromoteControlIsOwnerOnly(t *testing.T) {
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-ladder")
- status, _, mbody := get(t, cm, fx.u("/apps/kass"))
+ status, _, mbody := get(t, cm, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("member app page = %d, want 200", status)
}
@@ -167,7 +170,7 @@ func TestLadderPromoteToStableNeedsStepUp(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("un-stepped-up stable promotion = %d -> %q, want a redirect into Keymail", status, loc)
}
@@ -204,7 +207,7 @@ func TestLadderRefusesASkip(t *testing.T) {
// postGated walks the passkey round trip, so this is the refusal of a
// FULLY confirmed owner — the ladder, not the gate, is what stops it.
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v1" {
t.Fatalf("a skip from edge moved stable to %s", p.Version)
}
@@ -230,7 +233,7 @@ func TestProdWiredCanaryIsBadgedDistinctly(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "side.kass.carlosapp.dev", Kind: "instance", Status: "ready",
Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9003"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
sameEnv := routeLine(t, block, "rc.kass.carlosapp.dev")
@@ -270,7 +273,7 @@ func TestProdWiredCanaryOnTheApexHostIsFlaggedWhateverItBinds(t *testing.T) {
Version: "v2", App: "kass", Channel: "canary/x", Environment: "sandbox", Addr: "127.0.0.1:9007"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "kass.carlosapp.dev")
if !strings.Contains(line, "chip-prod") {
@@ -304,7 +307,7 @@ func TestProdWiredCanaryNeedsAStableRouteToCompareAgainst(t *testing.T) {
Version: "v2", App: "kass", Channel: "canary/x", Addr: "127.0.0.1:9004"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "rc.kass.carlosapp.dev")
if strings.Contains(line, "chip-prod") {
@@ -321,7 +324,7 @@ func TestLadderShowsEachRoutesEnvironment(t *testing.T) {
Version: "v1", App: "kass", Channel: "stable", Environment: "eu-prod", Addr: "127.0.0.1:9011"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
unbound := routeLine(t, block, "kass.carlosapp.dev")
@@ -347,7 +350,7 @@ func TestLadderShowsGrantedCapabilities(t *testing.T) {
Version: "v1", App: "kass", Channel: "stable", Caps: "host-role,port:25", Addr: "127.0.0.1:9013"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
granted := routeLine(t, block, "mx.kass.carlosapp.dev")
@@ -392,7 +395,7 @@ func TestLadderGrantedCapabilitiesNeverCrossAccounts(t *testing.T) {
Caps: "host-role,port:587", Addr: "127.0.0.1:9015"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "stable")
own := routeLine(t, block, "mx.kass.carlosapp.dev")
@@ -430,7 +433,7 @@ func TestLadderEnvironmentAndProdWiredVisibleToNonOwner(t *testing.T) {
fx.setUser("sam@keymail.dev")
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-envs")
- status, _, body := get(t, cm, fx.u("/apps/kass"))
+ status, _, body := get(t, cm, fx.u("/apps/kass/releases"))
if status != http.StatusOK {
t.Fatalf("member app page = %d, want 200", status)
}
@@ -459,7 +462,7 @@ func TestLadderFreshCanaryCarriesNoExpiredBadge(t *testing.T) {
seedChannel(t, fx, "kass", "canary/x", "v2") // stamps PromotedAt = now
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
block := rungBlock(t, body, "canary")
line := routeLine(t, block, "rc.kass.carlosapp.dev")
@@ -611,7 +614,7 @@ func TestLadderPromotesOntoAnExpiredCanary(t *testing.T) {
csrf := sessionToken(t, fx, c)
status, _, body := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}})
+ url.Values{"csrf": {csrf}, "channel": {"canary/old-branch"}, "version": {"v8"}, "confirmed": {"1"}})
if status != http.StatusSeeOther {
t.Fatalf("promotion onto an expired canary = %d, want 303:\n%s", status, body)
}
@@ -630,16 +633,21 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
seedChannel(t, fx, "kass", "beta", "v3")
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ csrf := sessionToken(t, fx, c)
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "Supply-chain bake") {
t.Fatalf("no bake status line:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) {
t.Fatal("owner is offered no production toggle")
}
- // Off by default, and the stable promote form asks the question.
- if !strings.Contains(body, `name="enable_bake"`) {
- t.Fatal("the stable promote form does not carry the enable_bake prompt")
+ // Off by default, and the stable promote form asks the question — now on
+ // the confirm card the un-confirmed promote returns, not the ladder
+ // itself (the checkbox moved there; see confirm.go's confirmPromote).
+ _, _, confirmBody := postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ if !strings.Contains(confirmBody, `name="enable_bake"`) {
+ t.Fatal("the stable promote confirm card does not carry the enable_bake prompt")
}
// Flip on; the held beta head now shows its window and the fast-track
@@ -648,14 +656,18 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- _, _, body = get(t, c, fx.u("/apps/kass"))
+ _, _, body = get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, `action="/a/bab/apps/kass/fasttrack"`) {
t.Fatalf("no fast-track control on a held head:\n%s", rungBlock(t, body, "beta"))
}
if !strings.Contains(body, `name="production" value="off"`) {
t.Fatal("owner is offered no turn-off control once bake is on")
}
- if strings.Contains(body, `name="enable_bake"`) {
+ // The confirm card's checkbox no longer offers to enable the bake once
+ // it's already on.
+ _, _, confirmBody = postForm(t, c, fx.u("/apps/kass/promote"),
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}})
+ if strings.Contains(confirmBody, `name="enable_bake"`) {
t.Fatal("the enable_bake prompt survived the flag turning on")
}
@@ -664,7 +676,7 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
release.OpsAccount, "kass", "beta", "test"); err != nil {
t.Fatal(err)
}
- _, _, body = get(t, c, fx.u("/apps/kass"))
+ _, _, body = get(t, c, fx.u("/apps/kass/releases"))
b := rungBlock(t, body, "beta")
if !strings.Contains(b, "fast-tracked") {
t.Errorf("no permanent fast-tracked badge:\n%s", b)
@@ -696,7 +708,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
cm := newBrowser(t)
signUp(t, fx, cm, "code-sam-baketpl")
- _, _, mbody := get(t, cm, fx.u("/apps/kass"))
+ _, _, mbody := get(t, cm, fx.u("/apps/kass/releases"))
if !strings.Contains(mbody, "Supply-chain bake") {
t.Fatal("member sees no bake status")
}
@@ -717,7 +729,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- _, _, obody := get(t, c, fx.u("/apps/kass"))
+ _, _, obody := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(obody, `name="production" value="off"`) {
t.Fatal("owner is not offered the turn-off control once bake is eligible — the member-only checks below would be vacuous")
}
@@ -726,7 +738,7 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
}
// ...then confirm the member does NOT, in that same eligible state.
- _, _, mbody = get(t, cm, fx.u("/apps/kass"))
+ _, _, mbody = get(t, cm, fx.u("/apps/kass/releases"))
if strings.Contains(mbody, `name="production" value="off"`) {
t.Error("member is offered the owner-only turn-off control once bake is on")
}
@@ -747,8 +759,8 @@ func TestLadderUnreadableFlagOffersRepair(t *testing.T) {
release.ProductionKey(release.OpsAccount, "kass"), []byte("{not a wrapper"), ""); err != nil {
t.Fatal(err)
}
- _, _, body := get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "Production flag unreadable") {
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(body, "Supply-chain bake: flag unreadable") {
t.Fatalf("no error line for an unverifiable flag:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) ||
@@ -791,7 +803,7 @@ func TestLadderTurningOffOffersTurnBackOn(t *testing.T) {
if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", false, "test"); err != nil {
t.Fatal(err)
}
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "turning off") {
t.Fatalf("a fresh off-flag on a held app does not say hosts are still ratcheting:\n%s", body)
}
@@ -813,7 +825,7 @@ func TestLadderNudgesProdWiredBakeOff(t *testing.T) {
c := signInPaul(t, fx)
// The fixture's kass.carlosapp.dev is on stable and the flag is off (the
// default): that IS the nudge condition.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, "adopts releases immediately") {
t.Fatalf("no prod-wiring nudge:\n%s", body)
}
@@ -821,7 +833,7 @@ func TestLadderNudgesProdWiredBakeOff(t *testing.T) {
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "adopts releases immediately") {
+ if _, _, body := get(t, c, fx.u("/apps/kass/releases")); strings.Contains(body, "adopts releases immediately") {
t.Fatal("the nudge survived the flag turning on")
}
}
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go
index be27dcb31..7ee92eb31 100644
--- a/internal/console/metricsview.go
+++ b/internal/console/metricsview.go
@@ -80,6 +80,18 @@ const metricsFreshWindow = 5 * time.Minute
// prefix and reads the path segment right after "boxes/".
const metricsBoxesPrefix = "metrics/boxes/"
+// metricsSweepBoxes and metricsSweepReads bound how much of a sweep is in
+// flight at once: boxes swept in parallel, and hourly chunks read in parallel
+// within a box. A cold sweep is boxes x 48 round trips to the bucket and is
+// latency-bound, not CPU-bound — serially that is the whole "Instances page
+// is slow" complaint — but it is still someone else's rate limit at the far
+// end, so the fan-out is bounded rather than "one goroutine per key". The
+// worst case is metricsSweepBoxes x metricsSweepReads requests outstanding.
+const (
+ metricsSweepBoxes = 4
+ metricsSweepReads = 12
+)
+
// metricsSweepTimeout bounds one whole bucket sweep: a List plus 48 Gets per
// box plus the alerts pass. It exists because the sweep no longer runs on the
// requesting page's context (see LoadMetrics) and so has no deadline of its
@@ -194,9 +206,16 @@ func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow,
}
// sweepAll is the bucket half of LoadMetrics: discovery, every box's 48h raw
-// sweep, then the alerts pass, on a context detached from the caller's
-// (WithoutCancel) and bounded by metricsSweepTimeout. It holds no lock — see
-// LoadMetrics's doc comment for why that is the point.
+// sweep (metricsSweepBoxes of them at a time), then the alerts pass, on a
+// context detached from the caller's (WithoutCancel) and bounded by
+// metricsSweepTimeout. It holds no lock — see LoadMetrics's doc comment for
+// why that is the point.
+//
+// Boxes are swept concurrently but land in boxNames order, never completion
+// order: the dashboard's row order must not depend on which box's bucket
+// answered first. Any box failing fails the whole sweep, with the
+// lowest-ordered box's error — the same error the serial sweep returned, so a
+// flaky box reads the same in the logs however the reads interleaved.
func (s *Server) sweepAll(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) {
sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), metricsSweepTimeout)
defer cancel()
@@ -206,13 +225,43 @@ func (s *Server) sweepAll(ctx context.Context) ([]BoxMetricsView, []AlertRow, er
return nil, nil, err
}
- views := make([]BoxMetricsView, 0, len(boxNames))
- for _, box := range boxNames {
- view, err := s.sweepBox(sweepCtx, box)
+ views := make([]BoxMetricsView, len(boxNames))
+ errs := make([]error, len(boxNames))
+ var (
+ wg sync.WaitGroup
+ sem = make(chan struct{}, metricsSweepBoxes)
+ once sync.Once
+ failed = make(chan struct{})
+ )
+ for i, box := range boxNames {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ select {
+ case sem <- struct{}{}:
+ case <-failed:
+ // Another box has already failed the sweep; nothing this one
+ // could read would be returned. Boxes already at the bucket
+ // still finish — they run on sweepCtx, so the error a failed
+ // sweep reports is always a box's own, never one this
+ // function inflicted.
+ return
+ }
+ defer func() { <-sem }()
+ view, err := s.sweepBox(sweepCtx, box)
+ if err != nil {
+ errs[i] = err
+ once.Do(func() { close(failed) })
+ return
+ }
+ views[i] = view
+ }()
+ }
+ wg.Wait()
+ for _, err := range errs {
if err != nil {
return nil, nil, err
}
- views = append(views, view)
}
alerts, err := s.sweepAlerts(sweepCtx)
@@ -248,29 +297,66 @@ func (s *Server) discoverBoxes(ctx context.Context) ([]string, error) {
return boxes, nil
}
-// sweepBox reads box's raw chunks for the last 48 UTC hours and assembles
-// its BoxMetricsView. A missing hour (os.ErrNotExist) or an unparsable chunk
-// is skipped silently — a box that has been up for under 48h, or whose
-// agent hiccuped on one hour, still renders on what it does have. Any other
-// read error fails the whole sweep (and so is never cached).
+// sweepBox reads box's raw chunks for the last 48 UTC hours, metricsSweepReads
+// at a time, and assembles its BoxMetricsView. A missing hour (os.ErrNotExist)
+// or an unparsable chunk is skipped silently — a box that has been up for
+// under 48h, or whose agent hiccuped on one hour, still renders on what it
+// does have. Any other read error fails the whole sweep (and so is never
+// cached), reporting the newest failing hour's error as the serial read did.
+//
+// Each hour lands in its own slot and the slots are concatenated in hour
+// order before sorting, because the sort below is NOT stable: samples sharing
+// a timestamp keep whatever relative order they were appended in, so appending
+// as the reads complete would let a box's current reading flap between
+// renders.
func (s *Server) sweepBox(ctx context.Context, box string) (BoxMetricsView, error) {
now := time.Now().UTC()
- var samples []metrics.Sample
- for i := 0; i < metricsSweepHours; i++ {
- hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
- key := metrics.RawKey(box, hour)
- body, _, err := s.cfg.Releases.Get(ctx, key)
- if err != nil {
- if errors.Is(err, os.ErrNotExist) {
- continue
+ hourly := make([][]metrics.Sample, metricsSweepHours)
+ errs := make([]error, metricsSweepHours)
+ var (
+ wg sync.WaitGroup
+ sem = make(chan struct{}, metricsSweepReads)
+ once sync.Once
+ failed = make(chan struct{})
+ )
+ for i := range metricsSweepHours {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ select {
+ case sem <- struct{}{}:
+ case <-failed:
+ return // an earlier hour already failed the sweep
}
- return BoxMetricsView{}, fmt.Errorf("read %s: %w", key, err)
- }
- var chunk metrics.RawChunk
- if err := json.Unmarshal(body, &chunk); err != nil {
- continue // unparsable chunk: skip, don't fail the sweep
+ defer func() { <-sem }()
+ hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour))
+ key := metrics.RawKey(box, hour)
+ body, _, err := s.cfg.Releases.Get(ctx, key)
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return
+ }
+ errs[i] = fmt.Errorf("read %s: %w", key, err)
+ once.Do(func() { close(failed) })
+ return
+ }
+ var chunk metrics.RawChunk
+ if err := json.Unmarshal(body, &chunk); err != nil {
+ return // unparsable chunk: skip, don't fail the sweep
+ }
+ hourly[i] = chunk.Samples
+ }()
+ }
+ wg.Wait()
+ for _, err := range errs {
+ if err != nil {
+ return BoxMetricsView{}, err
}
- samples = append(samples, chunk.Samples...)
+ }
+
+ var samples []metrics.Sample
+ for _, h := range hourly {
+ samples = append(samples, h...)
}
sort.Slice(samples, func(i, j int) bool { return samples[i].At.Before(samples[j].At) })
return buildBoxView(box, samples), nil
diff --git a/internal/console/metricsview_test.go b/internal/console/metricsview_test.go
index d54d4213c..380b74eba 100644
--- a/internal/console/metricsview_test.go
+++ b/internal/console/metricsview_test.go
@@ -3,6 +3,7 @@ package console
import (
"context"
"encoding/json"
+ "errors"
"path/filepath"
"strconv"
"strings"
@@ -302,6 +303,167 @@ func TestLoadMetricsConcurrentColdCallers(t *testing.T) {
}
}
+// errOnKeyStore fails one specific Get with a non-NotExist error and passes
+// everything else through — the "bucket is answering, but this object will
+// not read" case, which must fail the whole sweep rather than being skipped
+// like a missing hour.
+type errOnKeyStore struct {
+ release.Store
+ key string
+ err error
+}
+
+func (e errOnKeyStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if key == e.key {
+ return nil, "", e.err
+ }
+ return e.Store.Get(ctx, key)
+}
+
+// newMetricsFixtureStore is newMetricsFixture with the server reading through
+// a wrapped store. The returned release.Store is the raw one, for seeding.
+func newMetricsFixtureStore(t *testing.T, wrap func(release.Store) release.Store) (*Server, release.Store) {
+ t.Helper()
+ st, err := Open(filepath.Join(t.TempDir(), "console.db"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { st.Close() })
+ rel := release.NewDirStore(filepath.Join(t.TempDir(), "bucket"))
+ srv, err := NewServer(Config{Releases: wrap(rel)}, st)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return srv, rel
+}
+
+// writeChunk publishes one hour's raw chunk for box, the way the box agent
+// would.
+func writeChunk(t *testing.T, rel release.Store, box, hour string, samples []metrics.Sample) {
+ t.Helper()
+ if err := metrics.WriteRaw(context.Background(), rel, metrics.RawChunk{
+ V: 1, Box: box, Hour: hour, Samples: samples,
+ }); err != nil {
+ t.Fatal(err)
+ }
+}
+
+// seedTiedHours writes three hours for box-a whose samples deliberately SHARE
+// a timestamp across different hours, plus a second box. Ties are the whole
+// point: sort.Slice is not stable, so which tied sample ends up last — and so
+// which reading the box's row prints as current — is decided by the order the
+// hours were concatenated in, not by the sort. Returns the shared timestamp.
+func seedTiedHours(t *testing.T, rel release.Store) time.Time {
+ t.Helper()
+ now := time.Now().UTC()
+ tie := now.Truncate(time.Second)
+ unit := func(cpu float64, mem int64) map[string]metrics.UnitSample {
+ return map[string]metrics.UnitSample{"carlos-app@a.service": {CPUFrac: p(cpu), MemBytes: pi64(mem)}}
+ }
+ writeChunk(t, rel, "box-a", metrics.HourOf(now.Add(-2*time.Hour)), []metrics.Sample{
+ {At: tie.Add(-2 * time.Hour), Host: metrics.HostSample{CPUFrac: p(0.10)}, Units: unit(0.11, 110)},
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.20)}, Units: unit(0.22, 220)},
+ })
+ writeChunk(t, rel, "box-a", metrics.HourOf(now.Add(-time.Hour)), []metrics.Sample{
+ {At: tie.Add(-time.Hour), Host: metrics.HostSample{CPUFrac: p(0.30)}, Units: unit(0.33, 330)},
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.40)}, Units: unit(0.44, 440)},
+ })
+ writeChunk(t, rel, "box-a", metrics.HourOf(now), []metrics.Sample{
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.50)}, Units: unit(0.55, 550)},
+ })
+ writeChunk(t, rel, "box-b", metrics.HourOf(now), []metrics.Sample{
+ {At: tie, Host: metrics.HostSample{CPUFrac: p(0.60)}},
+ })
+ return tie
+}
+
+// TestSweepAllDeterministicOrder pins the sweep's output shape against the
+// timing of whatever reads it in: boxes come back in sorted name order, and a
+// box's samples in hour order (newest hour first, as the loop counts back),
+// so tied timestamps resolve the same way on every render. The pinned values
+// are the serial sweep's own answers — a sweep that assembled hours or boxes
+// in completion order would land on a different reading and the row would
+// flap between page loads.
+func TestSweepAllDeterministicOrder(t *testing.T) {
+ srv, rel := newMetricsFixture(t)
+ tie := seedTiedHours(t, rel)
+
+ views, _, err := srv.sweepAll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(views) != 2 || views[0].Box != "box-a" || views[1].Box != "box-b" {
+ t.Fatalf("boxes = %+v, want box-a then box-b (sorted)", views)
+ }
+ a := views[0]
+ if !a.At.Equal(tie) {
+ t.Fatalf("At = %v, want the tied timestamp %v", a.At, tie)
+ }
+ // Hour order decides the tie: the LAST tied sample appended is the oldest
+ // hour's (0.20), not the current hour's (0.50).
+ if a.CPU == nil || *a.CPU != 0.20 {
+ t.Fatalf("CPU = %v, want 0.20 (the tie broken in hour order)", a.CPU)
+ }
+ if len(a.Units) != 1 || a.Units[0].CPUFrac == nil || *a.Units[0].CPUFrac != 0.22 {
+ t.Fatalf("units = %+v, want the same sample's unit reading 0.22", a.Units)
+ }
+ // The sparkline coordinate string encodes the whole ordered series, so it
+ // is the sharpest oracle for "the samples arrived in the same order".
+ const wantSpark = "0.00,36.00 60.00,28.00 120.00,20.00 180.00,24.00 240.00,32.00"
+ if a.SparkCPU != wantSpark {
+ t.Fatalf("SparkCPU = %q, want %q (samples in hour order)", a.SparkCPU, wantSpark)
+ }
+
+ // Same seed, repeated sweeps: identical every time, not merely plausible
+ // once.
+ for i := 0; i < 20; i++ {
+ got, _, err := srv.sweepAll(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != 2 || got[0].Box != "box-a" || got[1].Box != "box-b" {
+ t.Fatalf("sweep %d box order = %+v", i, got)
+ }
+ if got[0].CPU == nil || *got[0].CPU != *a.CPU || got[0].SparkCPU != a.SparkCPU {
+ t.Fatalf("sweep %d differs: CPU=%v spark=%q, want %v / %q", i, got[0].CPU, got[0].SparkCPU, *a.CPU, a.SparkCPU)
+ }
+ if got[0].Units[0].SparkCPU != a.Units[0].SparkCPU || got[0].Units[0].SparkMem != a.Units[0].SparkMem {
+ t.Fatalf("sweep %d unit sparklines differ: %+v", i, got[0].Units[0])
+ }
+ }
+}
+
+// TestSweepAllFailsOnUnreadableChunk: one hour of one box answering with
+// something other than os.ErrNotExist fails the WHOLE sweep — no partial view
+// is returned and nothing is cached, so the next caller retries rather than
+// inheriting a box's truncated history as fact.
+func TestSweepAllFailsOnUnreadableChunk(t *testing.T) {
+ boom := errors.New("bucket said no")
+ badKey := metrics.RawKey("box-a", metrics.HourOf(time.Now().UTC().Add(-time.Hour)))
+ srv, rel := newMetricsFixtureStore(t, func(s release.Store) release.Store {
+ return errOnKeyStore{Store: s, key: badKey, err: boom}
+ })
+ seedTiedHours(t, rel)
+
+ if _, _, err := srv.LoadMetrics(context.Background()); err == nil {
+ t.Fatal("LoadMetrics succeeded despite an unreadable chunk")
+ } else {
+ if !errors.Is(err, boom) {
+ t.Fatalf("error = %v, want it to wrap the store's error", err)
+ }
+ if !strings.Contains(err.Error(), "read "+badKey) {
+ t.Fatalf("error = %v, want it to name the key it failed to read", err)
+ }
+ }
+ // A failed sweep is never cached.
+ srv.metricsCache.mu.Lock()
+ cachedAt := srv.metricsCache.at
+ srv.metricsCache.mu.Unlock()
+ if !cachedAt.IsZero() {
+ t.Fatalf("a failed sweep was cached (at = %v)", cachedAt)
+ }
+}
+
// TestOwnerAppMetricsEmptyHosts: an app with no routes asks for nothing and
// gets nothing — empty, no error, and (proven by the poisoned-free fixture
// having no boxes at all) never an error path the app page has to handle.
diff --git a/internal/console/overview.go b/internal/console/overview.go
new file mode 100644
index 000000000..caa504880
--- /dev/null
+++ b/internal/console/overview.go
@@ -0,0 +1,192 @@
+// The app Overview dashboard's numbers, and the Instances tab's one table.
+// Both are joins over reads the app pages already do — AppView (routes +
+// channels), InstanceMetrics (the shared metrics sweep) and VersionView —
+// assembled here in Go so the templates render facts, never compute them.
+package console
+
+import (
+ "strings"
+ "time"
+)
+
+// OverviewStats is the Overview dashboard: the handful of numbers an owner
+// checks before anything else. Every field is derived from platform state the
+// page was already reading — this struct adds no bucket or registry I/O.
+type OverviewStats struct {
+ // What stable serves. HasStable false means the app has never been
+ // promoted to stable — the dashboard's honest "not live yet" state.
+ // LiveErr carries an unreadable stable pointer's error text: unknown,
+ // which is not the same as not live.
+ HasStable bool
+ LiveVersion string
+ LivePromotedAt time.Time
+ LiveActor string
+ LiveErr string
+
+ // The app's instances (routes owned by the viewed account), and how many
+ // of them the last metrics sweep actually saw. The TOTAL comes from the
+ // routes, never the sweep: a failed sweep costs the readings, and must
+ // not make the dashboard claim a live fleet has no hosts. Asleep counts
+ // hibernating non-reporters — expected quiet, not missing.
+ InstanceTotal int
+ InstanceReporting int
+ InstanceAsleep int
+
+ // MetricsUnavailable: the sweep itself failed (nil metrics), which is
+ // "readings unavailable", not "zero instances reporting".
+ MetricsUnavailable bool
+
+ // Current resource use summed across every reporting instance. Nil when
+ // nothing reports — the tiles render the em-dash, never zeros.
+ CPUSum *float64
+ MemSum *int64
+
+ // Domains, and how many need someone's attention: a custom domain not
+ // pointing here yet, or a cert that is not ready.
+ DomainTotal int
+ DomainAttention int
+
+ // Shipped releases. The versions list is capped (versionsCap), so
+ // ReleasesCapped says "20+" rather than lying with "20".
+ ReleaseCount int
+ ReleasesCapped bool
+ LatestVersion string
+}
+
+// assembleOverview derives the dashboard from what handleApp already loaded.
+// metrics may be nil (a failed sweep costs the readings and says so, never
+// the page or the instance count); versions may be nil the same way.
+//
+// The tenant boundary is drawn from the ROUTES, on every number here: routes
+// are grouped by app NAME across accounts (Assemble's byApp), so both the
+// instance count and the domains count must skip rows stamped with another
+// account — a same-named app elsewhere must not inflate this dashboard or
+// wave its failed cert on it.
+func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, versions []VersionView) OverviewStats {
+ st := OverviewStats{MetricsUnavailable: metrics == nil}
+ for _, ch := range view.Channels {
+ if ch.Name != "stable" {
+ continue
+ }
+ if ch.Err != "" {
+ st.LiveErr = ch.Err
+ break
+ }
+ st.HasStable = true
+ st.LiveVersion = ch.Version
+ st.LivePromotedAt = ch.PromotedAt
+ st.LiveActor = ch.Actor
+ break
+ }
+ reporting := make(map[string]bool, len(metrics))
+ for _, m := range metrics {
+ if !m.Reporting() {
+ continue
+ }
+ reporting[m.Host] = true
+ if m.CPUFrac != nil {
+ if st.CPUSum == nil {
+ st.CPUSum = new(float64)
+ }
+ *st.CPUSum += *m.CPUFrac
+ }
+ if m.MemBytes != nil {
+ if st.MemSum == nil {
+ st.MemSum = new(int64)
+ }
+ *st.MemSum += *m.MemBytes
+ }
+ }
+ for _, rt := range view.Routes {
+ if rt.Account != acct {
+ continue
+ }
+ st.InstanceTotal++
+ if reporting[rt.Host] {
+ st.InstanceReporting++
+ } else if rt.Hibernate {
+ st.InstanceAsleep++
+ }
+ }
+ // Routes and Domains are parallel slices — Assemble appends one of each
+ // per registry route, in the same loop — which is what lets the domain
+ // rows be account-filtered here at all (DomainView carries no account of
+ // its own). Guarded rather than assumed, and the guard fails toward
+ // counting nothing: a miscount that says 0 invites a look, one that
+ // includes another tenant's failure does not.
+ if len(view.Domains) == len(view.Routes) {
+ for i, rt := range view.Routes {
+ if rt.Account != acct {
+ continue
+ }
+ st.DomainTotal++
+ if domainNeedsAttention(view.Domains[i]) {
+ st.DomainAttention++
+ }
+ }
+ }
+ st.ReleaseCount = len(versions)
+ st.ReleasesCapped = len(versions) == versionsCap
+ if len(versions) > 0 {
+ st.LatestVersion = versions[0].Version
+ }
+ return st
+}
+
+// domainNeedsAttention is the dashboard's one-bit read of a Domains row:
+// guidance text means DNS isn't pointing here, and a TLS status other than
+// ready/issuing means issuance failed. Issuing is patience, not attention.
+func domainNeedsAttention(d DomainView) bool {
+ if d.Guidance != "" {
+ return true
+ }
+ switch {
+ case d.TLS == "", strings.HasPrefix(d.TLS, "ready"), strings.HasPrefix(d.TLS, "issuing"):
+ return false
+ }
+ return true
+}
+
+// InstanceRow is one line of the Instances tab's table: a route joined with
+// its readings from the shared sweep. The join key is the host — the one name
+// owners have for an instance.
+type InstanceRow struct {
+ Host string
+ Channel string
+ Version string // what the route reports it is running, "" unknown
+ Kind string
+ Status string // registry status; "" means ready
+ Hibernate bool
+ Metrics InstanceMetrics // zero-valued (Reporting false) when unswept
+}
+
+// assembleInstanceRows joins the app's routes with the sweep's readings, in
+// host order (the routes' own order). Only routes stamped with the viewed
+// account join — the same tenant boundary appResourceMetrics draws, kept
+// byte-for-byte: rows here and metrics there must cover the same hosts.
+func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetrics) []InstanceRow {
+ byHost := make(map[string]InstanceMetrics, len(metrics))
+ for _, m := range metrics {
+ byHost[m.Host] = m
+ }
+ rows := make([]InstanceRow, 0, len(view.Routes))
+ for _, rt := range view.Routes {
+ if rt.Account != acct.Sqid {
+ continue
+ }
+ status := rt.Status
+ if status == "" {
+ status = "ready" // registry contract: empty status means ready
+ }
+ rows = append(rows, InstanceRow{
+ Host: rt.Host,
+ Channel: rt.Channel,
+ Version: rt.Version,
+ Kind: rt.Kind,
+ Status: status,
+ Hibernate: rt.Hibernate,
+ Metrics: byHost[rt.Host],
+ })
+ }
+ return rows
+}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
new file mode 100644
index 000000000..cab8018de
--- /dev/null
+++ b/internal/console/overview_test.go
@@ -0,0 +1,317 @@
+package console
+
+// The Overview dashboard's numbers and the Instances tab's one table
+// (overview.go). Both assemblers are pure joins over what the app pages
+// already read, so they are exercised directly here — the page tests below
+// then prove the numbers actually reach a rendered page.
+//
+// The properties worth pinning are the ones where a plausible-looking number
+// would be a lie: an unreadable stable pointer is UNKNOWN and not "not live",
+// a sum over instances that reported nothing is nil and not zero, a domain
+// mid-issuance needs patience and not attention, and a capped release list
+// says so.
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// f64 and i64 are the pointer literals InstanceMetrics carries — nil is a
+// meaningful value there (unreadable), so the tests need both.
+func f64(v float64) *float64 { return &v }
+func i64(v int64) *int64 { return &v }
+
+func TestOverviewLiveVersionFromStable(t *testing.T) {
+ promoted := time.Now().Add(-3 * time.Hour)
+ view := &AppView{App: "kass", Channels: []ChannelView{
+ {Name: "beta", Version: "v9"},
+ {Name: "stable", Version: "v7", PromotedAt: promoted, Actor: "kim@keymail.dev"},
+ }}
+ st := assembleOverview(view, "bab", nil, nil)
+ if !st.HasStable {
+ t.Fatal("an app with a stable pointer is not reported live")
+ }
+ if st.LiveVersion != "v7" {
+ t.Fatalf("LiveVersion = %q, want v7 (beta's v9 is not what users get)", st.LiveVersion)
+ }
+ if !st.LivePromotedAt.Equal(promoted) || st.LiveActor != "kim@keymail.dev" {
+ t.Fatalf("promoted %v by %q, want %v by kim@keymail.dev", st.LivePromotedAt, st.LiveActor, promoted)
+ }
+ if st.LiveErr != "" {
+ t.Fatalf("LiveErr = %q on a readable pointer", st.LiveErr)
+ }
+
+ // No stable at all: the honest "not live yet" state, and NOT an error.
+ st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "edge", Version: "v9"}}}, "bab", nil, nil)
+ if st.HasStable || st.LiveVersion != "" {
+ t.Fatalf("a never-promoted app reads as live: %+v", st)
+ }
+ if st.LiveErr != "" {
+ t.Fatalf("LiveErr = %q, want no error for an app that simply has no stable", st.LiveErr)
+ }
+}
+
+// Unknown is not the same as not live. A stable pointer that will not verify
+// says so — claiming "not live" would tell an owner their app is down when the
+// only thing broken is this console's read of it.
+func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
+ view := &AppView{App: "kass", Channels: []ChannelView{
+ {Name: "stable", Err: "signature does not verify"},
+ }}
+ st := assembleOverview(view, "bab", nil, nil)
+ if st.HasStable {
+ t.Fatal("an unreadable pointer was reported as a known live version")
+ }
+ if st.LiveErr != "signature does not verify" {
+ t.Fatalf("LiveErr = %q, want the pointer's own error text", st.LiveErr)
+ }
+ if st.LiveVersion != "" {
+ t.Fatalf("LiveVersion = %q, want nothing claimed from an unreadable pointer", st.LiveVersion)
+ }
+}
+
+// The tiles render an em-dash for "no readings", never zeros — so the sums
+// have to stay nil when nothing reported, and must cover only the instances
+// that actually did. The instance COUNT comes from the routes, so a failed
+// sweep (nil metrics) reads as readings-unavailable, never as a hostless app,
+// and another account's same-named routes count for nothing at all.
+func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab"},
+ {Host: "b", Account: "bab"},
+ {Host: "c", Account: "bab"},
+ {Host: "d", Account: "bab", Hibernate: true},
+ {Host: "e", Account: "bab"},
+ // Another account's same-named app: not this dashboard's instance.
+ {Host: "theirs", Account: "cdef"},
+ }}
+ metrics := []InstanceMetrics{
+ {Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)},
+ {Host: "b", Box: "box-1", CPUFrac: f64(0.5), MemBytes: i64(200)},
+ // Reporting, but this metric was unreadable: contributes nothing.
+ {Host: "c", Box: "box-2"},
+ // Not reporting at all, with numbers that must NOT be counted.
+ {Host: "d", CPUFrac: f64(99), MemBytes: i64(99), Hibernate: true},
+ {Host: "e"},
+ }
+ st := assembleOverview(view, "bab", metrics, nil)
+ if st.InstanceTotal != 5 {
+ t.Fatalf("InstanceTotal = %d, want 5 (the routes, not the sweep, and never another account's)", st.InstanceTotal)
+ }
+ if st.InstanceReporting != 3 {
+ t.Fatalf("InstanceReporting = %d, want 3", st.InstanceReporting)
+ }
+ if st.InstanceAsleep != 1 {
+ t.Fatalf("InstanceAsleep = %d, want only the hibernating non-reporter", st.InstanceAsleep)
+ }
+ if st.CPUSum == nil || *st.CPUSum != 0.75 {
+ t.Fatalf("CPUSum = %v, want 0.75 — the non-reporters must not be summed", st.CPUSum)
+ }
+ if st.MemSum == nil || *st.MemSum != 300 {
+ t.Fatalf("MemSum = %v, want 300", st.MemSum)
+ }
+ if st.MetricsUnavailable {
+ t.Fatal("a sweep that ran reads as unavailable")
+ }
+
+ // Nobody reported: nil, not zero. A zero here would render as "0%" and
+ // claim an idle app where there is simply no reading.
+ quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab"}, {Host: "b", Account: "bab"}}}
+ quiet := assembleOverview(quietView, "bab", []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
+ if quiet.CPUSum != nil || quiet.MemSum != nil {
+ t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash",
+ quiet.CPUSum, quiet.MemSum)
+ }
+ if quiet.InstanceReporting != 0 || quiet.InstanceTotal != 2 {
+ t.Fatalf("quiet counts = %d/%d, want 0/2", quiet.InstanceReporting, quiet.InstanceTotal)
+ }
+
+ // The sweep FAILED (nil metrics): the count must survive — "readings
+ // unavailable" is not "no hosts yet".
+ failed := assembleOverview(quietView, "bab", nil, nil)
+ if failed.InstanceTotal != 2 {
+ t.Fatalf("InstanceTotal = %d after a failed sweep, want the routes' 2", failed.InstanceTotal)
+ }
+ if !failed.MetricsUnavailable {
+ t.Fatal("a failed sweep does not read as unavailable")
+ }
+}
+
+// Attention means someone has to do something: DNS that is not pointing here,
+// or issuance that failed. A cert mid-issuance is patience, not attention —
+// badging it would make every new domain look broken for its first minute.
+func TestOverviewDomainAttentionCountsGuidanceAndFailedTLS(t *testing.T) {
+ // Domains are counted through their PARALLEL routes (that is where the
+ // account lives), so the view carries both — including another tenant's
+ // same-named row, whose failed cert must not wave on this dashboard.
+ view := &AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "a.example.com", Account: "bab"},
+ {Host: "b.example.com", Account: "bab"},
+ {Host: "c.example.com", Account: "bab"},
+ {Host: "d.example.com", Account: "bab"},
+ {Host: "e.example.com", Account: "bab"},
+ {Host: "f.example.com", Account: "cdef"},
+ },
+ Domains: []DomainView{
+ {Domain: "a.example.com", TLS: "ready · renews 2027-01-01"},
+ {Domain: "b.example.com", TLS: "issuing"},
+ {Domain: "c.example.com", TLS: ""},
+ {Domain: "d.example.com", TLS: "failed: dns-01 timeout"},
+ {Domain: "e.example.com", TLS: "ready", Guidance: "Point an A record for e.example.com at ..."},
+ {Domain: "f.example.com", TLS: "failed: dns-01 timeout"},
+ }}
+ st := assembleOverview(view, "bab", nil, nil)
+ if st.DomainTotal != 5 {
+ t.Fatalf("DomainTotal = %d, want 5 — the other account's domain is not this tenant's", st.DomainTotal)
+ }
+ if st.DomainAttention != 2 {
+ t.Fatalf("DomainAttention = %d, want 2 (the failed cert and the unpointed domain, never the other tenant's)", st.DomainAttention)
+ }
+ // The one-bit read on its own, so a miscount above is attributable.
+ for _, tc := range []struct {
+ name string
+ d DomainView
+ want bool
+ }{
+ {"ready", DomainView{TLS: "ready · renews 2027-01-01"}, false},
+ {"issuing", DomainView{TLS: "issuing"}, false},
+ {"unknown", DomainView{TLS: ""}, false},
+ {"failed", DomainView{TLS: "failed: dns-01 timeout"}, true},
+ {"guidance", DomainView{TLS: "ready", Guidance: "point an A record"}, true},
+ } {
+ if got := domainNeedsAttention(tc.d); got != tc.want {
+ t.Errorf("domainNeedsAttention(%s) = %v, want %v", tc.name, got, tc.want)
+ }
+ }
+}
+
+// The versions list is capped, so the tile says "20+" rather than lying with
+// a flat 20 — and the latest version is the newest, which is the list's head.
+func TestOverviewReleasesCapped(t *testing.T) {
+ full := make([]VersionView, versionsCap)
+ for i := range full {
+ full[i] = VersionView{Version: "v" + string(rune('a'+i))}
+ }
+ st := assembleOverview(&AppView{App: "kass"}, "bab", nil, full)
+ if st.ReleaseCount != versionsCap || !st.ReleasesCapped {
+ t.Fatalf("count/capped = %d/%v, want %d/true", st.ReleaseCount, st.ReleasesCapped, versionsCap)
+ }
+ if st.LatestVersion != full[0].Version {
+ t.Fatalf("LatestVersion = %q, want the head of the newest-first list (%q)", st.LatestVersion, full[0].Version)
+ }
+
+ short := assembleOverview(&AppView{App: "kass"}, "bab", nil, full[:versionsCap-1])
+ if short.ReleasesCapped {
+ t.Fatalf("a list one short of the cap was reported capped (%d)", short.ReleaseCount)
+ }
+ none := assembleOverview(&AppView{App: "kass"}, "bab", nil, nil)
+ if none.ReleaseCount != 0 || none.ReleasesCapped || none.LatestVersion != "" {
+ t.Fatalf("empty release list = %+v, want zeros and no version", none)
+ }
+}
+
+// The join key is the host — the one name owners have for an instance — and
+// an unswept route still gets its row, with Reporting false rather than zeros.
+func TestInstanceRowsJoinMetricsByHost(t *testing.T) {
+ acct := &Account{ID: 1, Sqid: "bab"}
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a.example.com", Account: "bab", Channel: "stable", Version: "v1", Kind: "instance", Status: "ready"},
+ {Host: "b.example.com", Account: "bab", Channel: "canary/x", Kind: "instance", Status: "", Hibernate: true},
+ }}
+ metrics := []InstanceMetrics{
+ {Host: "b.example.com", Box: "box-1", CPUFrac: f64(0.1)},
+ {Host: "ghost.example.com", Box: "box-1", CPUFrac: f64(9)},
+ }
+ rows := assembleInstanceRows(view, acct, metrics)
+ if len(rows) != 2 {
+ t.Fatalf("rows = %+v, want one per route (a metrics row for an unrouted host adds nothing)", rows)
+ }
+ if rows[0].Host != "a.example.com" || rows[0].Metrics.Reporting() {
+ t.Fatalf("row[0] = %+v, want the unswept route present and not reporting", rows[0])
+ }
+ // The registry contract: an empty status means ready, and the table must
+ // not print a blank cell for the commonest state there is.
+ if rows[1].Status != "ready" {
+ t.Fatalf("row[1].Status = %q, want the empty registry status rendered as \"ready\"", rows[1].Status)
+ }
+ if rows[0].Status != "ready" {
+ t.Fatalf("row[0].Status = %q, want its own explicit status kept", rows[0].Status)
+ }
+ if !rows[1].Hibernate {
+ t.Fatal("the route's hibernation setting did not reach its row")
+ }
+ if !rows[1].Metrics.Reporting() || rows[1].Metrics.CPUFrac == nil || *rows[1].Metrics.CPUFrac != 0.1 {
+ t.Fatalf("row[1].Metrics = %+v, want the sweep's reading joined by host", rows[1].Metrics)
+ }
+ if rows[0].Channel != "stable" || rows[1].Channel != "canary/x" || rows[0].Version != "v1" {
+ t.Fatalf("rows lost their route facts: %+v", rows)
+ }
+}
+
+// The tenant boundary, kept byte-for-byte with appResourceMetrics': routes are
+// grouped by app NAME and app names are unique per ACCOUNT, so another
+// account's route can legitimately arrive in this view — and must not become a
+// row on this page.
+func TestInstanceRowsSkipOtherAccountsRoutes(t *testing.T) {
+ acct := &Account{ID: 1, Sqid: "bab"}
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "mine.example.com", Account: "bab", Channel: "stable"},
+ {Host: "theirs.example.com", Account: "cdef", Channel: "stable"},
+ }}
+ metrics := []InstanceMetrics{{Host: "theirs.example.com", Box: "box-9", CPUFrac: f64(0.9)}}
+ rows := assembleInstanceRows(view, acct, metrics)
+ if len(rows) != 1 || rows[0].Host != "mine.example.com" {
+ t.Fatalf("rows = %+v, want only the viewing account's own route", rows)
+ }
+}
+
+// The Overview page is the tiles, and only the tiles: the ladder moved to the
+// Releases tab, and a copy left behind here would be two places to read the
+// same state from.
+func TestOverviewPageRendersTiles(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass"))
+ if status != http.StatusOK {
+ t.Fatalf("overview page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, `class="tile"`) {
+ t.Fatalf("no tiles on the Overview:\n%s", body)
+ }
+ // The fixture's stable holds v1 — the number the "Live now" tile exists
+ // to show.
+ if !strings.Contains(body, "Live now") || !strings.Contains(body, "<code>v1</code>") {
+ t.Fatalf("the Live now tile does not show what stable serves:\n%s", body)
+ }
+ if !strings.Contains(body, "on stable · promoted") {
+ t.Fatalf("the Live now tile does not say when it was promoted:\n%s", body)
+ }
+ if strings.Contains(body, `id="rung-`) {
+ t.Fatalf("the ladder is still rendered on the Overview:\n%s", body)
+ }
+}
+
+// The Instances tab's one table, with the columns an owner reads across.
+func TestOverviewInstancesPageRendersTable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != http.StatusOK {
+ t.Fatalf("instances page = %d:\n%s", status, body)
+ }
+ for _, th := range []string{"Instance", "Channel", "Running", "Status", "CPU", "Memory"} {
+ if !strings.Contains(body, "<th>"+th+"</th>") {
+ t.Fatalf("the instances table has no %s column:\n%s", th, body)
+ }
+ }
+ // And the fixture's own routes are the rows — a header-only table would
+ // pass every assertion above.
+ if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "<code>stable</code>") {
+ t.Fatalf("the app's routes are not listed in the table:\n%s", body)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 4158735fa..eac1fd1df 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -33,6 +33,19 @@ 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") },
+ // ago renders a past instant the way a person scanning a dashboard reads
+ // one — "2h4m ago" — at humanAge's deliberately coarse resolution. The
+ // absolute stamp stays available where precision matters (title attrs,
+ // the Activity tab); a zero time is unknown, so it dashes out.
+ "ago": func(t time.Time) string {
+ if t.IsZero() {
+ return "—"
+ }
+ return humanAge(time.Since(t)) + " ago"
+ },
+ // trashCooloffDays is the deletion policy's restore window, in the unit
+ // the Settings page speaks. One source: release.TrashCooloff.
+ "trashCooloffDays": func() int { return int(release.TrashCooloff.Hours() / 24) },
// 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 —
@@ -271,6 +284,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary)
+ // The app Settings writes (appsettings.go): owner-only, and delete/move
+ // behind passkey step-up besides.
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite)
s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite)
// Owner-only account administration, every one of them behind passkey
@@ -396,6 +414,20 @@ type page struct {
// 12), one row per route host. Nil renders no section at all — metrics
// are decoration on this page and must never be the reason it fails.
AppMetrics []InstanceMetrics
+
+ // App Overview: the dashboard's vital statistics (overview.go), derived
+ // from reads the page does anyway.
+ Overview OverviewStats
+
+ // App Instances: the one table — routes joined with their readings.
+ InstanceRows []InstanceRow
+
+ // Action confirm interstitial (confirm.go): what is about to happen,
+ // carried fields and all. Nil everywhere except the confirm page.
+ Confirm *ConfirmView
+
+ // App Settings (appsettings.go): what the tab knows beyond the AppView.
+ Settings *SettingsView
}
// IsOwner is the template's one permission question.
@@ -1341,6 +1373,9 @@ func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Ac
return am
}
+// handleApp is the Overview dashboard: the vital statistics, from reads the
+// app pages already do — no self-report poll here, so the landing tab never
+// waits on a tenant's own HTTP endpoint.
func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
p, acct, ok := s.appPage(w, r, "overview")
if !ok {
@@ -1353,10 +1388,20 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
p.FlashErr = "Could not list this app's releases."
}
p.Versions = versions
- p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ metrics := s.appResourceMetrics(r.Context(), p.Detail, acct)
+ p.Overview = assembleOverview(p.Detail, acct.Sqid, metrics, versions)
+ if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil {
+ log.Printf("console: events %s: %v", app, err)
+ } else {
+ p.Events = events
+ }
s.render(w, http.StatusOK, "app_overview", p)
}
+// overviewActivityCap bounds the Overview's recent-activity strip; the
+// Activity tab has the room this strip never will.
+const overviewActivityCap = 5
+
// handleAppReleases is the deploy surface: every shipped release, the
// channels they promote onto, and the canaries riding ahead.
func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
@@ -1381,8 +1426,10 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "app_releases", p)
}
-// handleAppInstances is what the app is actually running: its self-reported
-// instances, and each one's CPU/memory on the box it runs on.
+// handleAppInstances is what the app is actually running: one row per
+// instance — host, the channel it follows, what it is running right now, and
+// its readings — plus the app's own self-report below, for apps that manage
+// internal instances of their own.
func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
p, acct, ok := s.appPage(w, r, "instances")
if !ok {
@@ -1390,6 +1437,7 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
}
p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
+ p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics)
s.render(w, http.StatusOK, "app_instances", p)
}
@@ -1407,13 +1455,15 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "app_activity", p)
}
-// handleAppSettings is the app's own record: identity, and its domains —
-// the two things about an app that aren't a deploy.
+// handleAppSettings is the app's own record and the controls over it:
+// identity (and renaming it, while that is still only a record), domains,
+// the account-move repair, and deletion.
func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
- p, _, ok := s.appPage(w, r, "settings")
+ p, acct, ok := s.appPage(w, r, "settings")
if !ok {
return
}
+ p.Settings = s.assembleSettings(r.Context(), &p, acct)
s.render(w, http.StatusOK, "app_settings", p)
}
@@ -1649,6 +1699,20 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
back("err", "Version "+version+" is not a shipped release of "+app+".")
return
}
+ // The stable rung's owner gate fires BEFORE the confirm card: a member
+ // probing an owner-only promote must meet the byte-identical 404, never a
+ // card that concedes the action would otherwise proceed. promoteGate
+ // re-checks this for owners on the confirmed POST; the duplication is the
+ // point — the confirm step must sit strictly between owner 404 and
+ // requireFreshAuth without weakening either.
+ if rung, _ := release.Rung(channel); rung == release.RungStable && role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ if !confirmed(r) {
+ s.confirmPromote(w, r, addr, raw, acct, role, app, channel, version)
+ return
+ }
// The rung's own gate, LAST — after membership, after the app check, after
// CSRF, and after every refusal that costs nothing. Order is load-bearing:
// a non-member must meet the byte-identical 404 before step-up is ever
@@ -1746,7 +1810,7 @@ func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw,
// handleRollback swaps a channel back to its previous version.
func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -1784,6 +1848,18 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) {
return
}
target := cur.History[0]
+ if !confirmed(r) {
+ s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target)
+ return
+ }
+ // The card pinned where it promised to go; a channel whose history moved
+ // since then must be looked at again, not rolled to wherever it points
+ // now. Checked only when the pin is present: a pinless POST (the CLI
+ // shape) rolls back the current head exactly as it always has.
+ if pinned := strings.TrimSpace(r.FormValue("target")); pinned != "" && pinned != target {
+ back("err", "Channel "+channel+" changed while you were confirming — review the rollback again.")
+ return
+ }
if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil {
log.Printf("console: rollback %s %s: %v", app, channel, err)
back("err", "Rollback failed: "+err.Error())
@@ -1888,6 +1964,13 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
return
}
+ // The confirm card sits strictly between the owner 404 and the passkey:
+ // only owners ever see it, and the confirmed replay still meets
+ // requireFreshAuth below.
+ if !confirmed(r) {
+ s.confirmFastTrack(w, r, addr, raw, acct, role, app, channel)
+ return
+ }
if !s.requireFreshAuth(w, r, addr, raw) {
return
}
@@ -1910,6 +1993,21 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
+ // The card pinned the version whose window it offered to waive; if the
+ // channel has moved since, the waiver must not land on the newcomer.
+ // Pin-present-only, like the rollback pin: a pinless POST waives the
+ // current head, which is FastTrack's own contract.
+ if pinned := strings.TrimSpace(r.FormValue("version")); pinned != "" {
+ cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel)
+ if err != nil {
+ back("err", "Could not read channel "+channel+".")
+ return
+ }
+ if cur.Version != pinned {
+ back("err", "Channel "+channel+" changed while you were confirming — review the fast-track again.")
+ return
+ }
+ }
ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr)
if err != nil {
log.Printf("console: fast-track %s %s: %v", app, channel, err)
@@ -2092,7 +2190,7 @@ func attachCommands(app, channel, domain, appsDomain, sqid string, absent bool)
// handleRetireCanary removes a canary's channel pointer. Shipped releases
// are untouched — retirement un-points, it never deletes bits.
func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
- addr, raw, acct, _, app, ok := s.accountApp(w, r)
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
if !ok {
return
}
@@ -2143,6 +2241,10 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
back("err", "Could not read the release store. Please try again.")
return
}
+ if !confirmed(r) {
+ s.confirmRetireCanary(w, r, addr, raw, acct, role, app, name)
+ return
+ }
if err := s.cfg.Releases.Delete(ctx, key); err != nil {
log.Printf("console: retire %s %s: %v", app, channel, err)
back("err", "Retiring the canary failed: "+err.Error())
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index 1eae5aa5e..700346616 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -658,7 +658,10 @@ func TestAuditIsScopedToTheAccount(t *testing.T) {
// account's keys while every host reads another's. "Promoted v2 to stable"
// flashes green and nothing deploys, forever, with no error anywhere.
//
-// So the app page says so, and names the command that fixes it.
+// So the app page says so, and points at the fix: the overview and releases
+// tabs both link to Settings, which is where the repair itself now lives
+// (appsettings.go) — a button when the signed-in owner can reach the target
+// account, the operator command as a fallback when they cannot.
func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -692,8 +695,8 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if !strings.Contains(body, "not deploying") {
t.Errorf("no warning on a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
- t.Errorf("the warning does not name the command that fixes it:\n%s", body)
+ if !strings.Contains(body, `/apps/kass/settings"`) {
+ t.Errorf("the warning does not link to the Settings tab, where the fix now lives:\n%s", body)
}
// The promote/rollback/canary forms live on the releases tab, not the
@@ -703,8 +706,19 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if !strings.Contains(body, "not deploying") {
t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, "console migrate-app -app kass -to "+sqid) {
- t.Errorf("the releases tab warning does not name the command that fixes it:\n%s", body)
+ if !strings.Contains(body, `/apps/kass/settings"`) {
+ t.Errorf("the releases tab warning does not link to the Settings tab, where the fix now lives:\n%s", body)
+ }
+
+ // Settings itself holds the actual repair. paul owns the target account
+ // here (CreateAccount above makes its creator the owner), so he can reach
+ // it — the button, not the operator-command fallback, is what he's owed.
+ _, _, body = get(t, c, fx.u("/apps/kass/settings"))
+ if !strings.Contains(body, "Move to "+sqid) {
+ t.Errorf("Settings does not offer the move-to-target button for an owner who can reach it:\n%s", body)
+ }
+ if !strings.Contains(body, `action="`+fx.path("/apps/kass/move")+`"`) {
+ t.Errorf("Settings' move button does not post to the move endpoint:\n%s", body)
}
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 3526a6f9d..7e2edace1 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -244,14 +244,15 @@ func TestConsoleE2E(t *testing.T) {
t.Fatal(err)
}
- // Overview: the ladder, with channel names and versions on it.
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ // The ladder, with channel names and versions on it, now lives on the
+ // Releases tab rather than the Overview page.
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
if status != 200 {
- t.Fatalf("/apps/kass = %d", status)
+ t.Fatalf("/apps/kass/releases = %d", status)
}
for _, want := range []string{"v1", "stable", "canary/x", "v2"} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
}
}
@@ -440,7 +441,7 @@ func TestCreateApp(t *testing.T) {
if status != 200 {
t.Fatalf("/apps/zephyr = %d", status)
}
- for _, want := range []string{"Created app zephyr", "0 releases"} {
+ for _, want := range []string{"Created app zephyr", "nothing shipped yet"} {
if !strings.Contains(body, want) {
t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
}
@@ -521,12 +522,16 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
_, _, body := get(t, c, fx.u("/apps/kass"))
- for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ // The redesign moved the ladder itself, and the full listing sections it
+ // used to sit beside (Channels, Canaries, the Releases table), off
+ // Overview entirely — it is now a stats dashboard of tiles, not a second
+ // copy of the Releases tab.
+ for _, gone := range []string{`class="ladder"`, "<table", "<h2>Channels</h2>", "<h2>Canaries</h2>"} {
if strings.Contains(body, gone) {
t.Fatalf("overview still renders %q", gone)
}
}
- for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ for _, want := range []string{`class="tiles stat-tiles"`, "Releases", "Domains", "Instances"} {
if !strings.Contains(body, want) {
t.Fatalf("overview missing %q", want)
}
@@ -747,7 +752,7 @@ func TestPromoteFlow(t *testing.T) {
// ladder promotes neither — see TestLadderRefusesASkip.)
shipVersion(t, fx, "kass", "v3")
status, loc, _ := postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "new_channel": {"edge"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "new_channel": {"edge"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("promote = %d -> %q", status, loc)
}
@@ -765,7 +770,7 @@ func TestPromoteFlow(t *testing.T) {
// One rung at a time, up to stable — which is the rung that asks for a
// passkey, so postGated walks the confirmation the console demands.
postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"beta"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"beta"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
t.Fatalf("beta pointer = %+v, want v3", p)
}
@@ -777,14 +782,14 @@ func TestPromoteFlow(t *testing.T) {
t.Fatalf("beta promotion flash incorrect (a flagless app must not be promised a bake window):\n%s", body)
}
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}})
if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" || len(p.History) == 0 || p.History[0] != "v1" {
t.Fatalf("stable pointer = %+v, want v3 with v1 in history", p)
}
// Running the same promotion again is a no-op success, not a failure.
postGated(t, fx, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}, "confirmed": {"1"}})
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "already serves v3") {
t.Fatalf("a repeated promotion was not a no-op:\n%s", body)
}
@@ -845,7 +850,7 @@ func TestRollbackFlow(t *testing.T) {
t.Fatal("no rollback button after stable gained history")
}
status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("rollback = %d -> %q", status, loc)
}
@@ -861,7 +866,7 @@ func TestRollbackFlow(t *testing.T) {
// redirect must land back on the tab, not the app page. stable is v1 with
// v2 in history after the rollback above, so a second rollback is valid.
status, loc, _ = postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "back": {"releases"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass/releases") {
t.Fatalf("rollback with back=releases = %d -> %q, want %q", status, loc, fx.path("/apps/kass/releases"))
}
@@ -971,8 +976,10 @@ func TestCanaryURLFromRoute(t *testing.T) {
if !strings.Contains(body, "live at https://rc.kass.carlosapp.dev") {
t.Fatalf("create flash has no canary URL:\n%s", body)
}
- if !strings.Contains(body, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) {
- t.Fatalf("canary table has no host link:\n%s", body)
+ // The Canaries table itself is on the Releases tab now.
+ _, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
+ if !strings.Contains(rbody, `<a href="https://rc.kass.carlosapp.dev">rc.kass.carlosapp.dev</a>`) {
+ t.Fatalf("canary table has no host link:\n%s", rbody)
}
}
@@ -1001,7 +1008,7 @@ func TestRetireCanary(t *testing.T) {
if err := reg.Delete("x.kass.carlosapp.dev"); err != nil {
t.Fatal(err)
}
- status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}})
+ status, loc, _ := postForm(t, c, fx.u("/apps/kass/canary/retire"), url.Values{"csrf": {csrf}, "name": {"x"}, "confirmed": {"1"}})
if status != http.StatusSeeOther || loc != fx.path("/apps/kass") {
t.Fatalf("retire = %d -> %q", status, loc)
}
@@ -1063,10 +1070,11 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before any reporting route exists: no Instances section — the tab is
- // exactly the route-level view.
- if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Instances</h2>") {
- t.Fatal("Instances section rendered with nothing reporting")
+ // Before the app reports its own internal instances: no "Inside the app"
+ // section — the tab is exactly the route-level view (which always
+ // renders its own "Instances" header, fixture routes and all).
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Inside the app</h2>") {
+ t.Fatal("\"Inside the app\" section rendered with nothing reporting")
}
// Kass starts reporting its three training instances.
@@ -1075,7 +1083,7 @@ func TestAppPageInstances(t *testing.T) {
Status: "ready", App: "kass"})
_, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
- "<h2>Instances</h2>", "reports 3 internal instances",
+ "<h2>Instances</h2>", "<h2>Inside the app</h2>", "manages its own internal instances",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
@@ -1105,13 +1113,13 @@ func TestRecentActivity(t *testing.T) {
shipVersion(t, fx, "kass", "v3")
seedChannel(t, fx, "kass", "stable", "v2")
postForm(t, c, fx.u("/apps/kass/promote"),
- url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}})
+ url.Values{"csrf": {csrf}, "version": {"v3"}, "new_channel": {"edge"}, "confirmed": {"1"}})
postForm(t, c, fx.u("/apps/kass/rollback"),
- url.Values{"csrf": {csrf}, "channel": {"stable"}})
+ url.Values{"csrf": {csrf}, "channel": {"stable"}, "confirmed": {"1"}})
postForm(t, c, fx.u("/apps/kass/canary"),
url.Values{"csrf": {csrf}, "name": {"rc"}, "version": {"v1"}})
postForm(t, c, fx.u("/apps/kass/canary/retire"),
- url.Values{"csrf": {csrf}, "name": {"rc"}})
+ url.Values{"csrf": {csrf}, "name": {"rc"}, "confirmed": {"1"}})
_, _, body := get(t, c, fx.u("/apps/kass/activity"))
for _, want := range []string{
@@ -1743,10 +1751,14 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body)
}
+ // The per-host CPU/Mem readings now live as columns on the Instances
+ // table itself (no separate Resources section) — each phrase below is
+ // unique to the one fixture route it describes, so checking presence
+ // page-wide still pins it to that route.
for _, want := range []string{
- "Resources", "owned.example.com", "11%", "1.0 KB",
- "asleep.example.com</code> — asleep — no recent readings",
- "unmetered.example.com</code> — no per-instance readings",
+ "owned.example.com", "11%", "1.0 KB",
+ "asleep.example.com", "asleep — no recent readings",
+ "unmetered.example.com", "no per-instance readings",
} {
if !strings.Contains(body, want) {
t.Fatalf("app page missing %q in:\n%s", want, body)
@@ -1780,12 +1792,12 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) {
if !strings.Contains(body, `aria-current="page">Instances</a>`) {
t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
- // Content, not just chrome: with the metrics read failing closed
- // (appResourceMetrics returns nil) and the fixture route unreachable
- // (Instances.Reporting false), the tab's real nil-metrics empty state
- // is what a reader sees — not a partially-rendered page.
- if !strings.Contains(body, "Nothing to show yet") {
- t.Fatalf("instances tab did not render its nil-metrics empty state: %s", body)
+ // Content, not just chrome: CPU/Mem readings now live as columns on the
+ // route's own row, so a failed metrics read fails closed there — the
+ // owned route still renders, with "no per-instance readings" in place
+ // of numbers, not a partially-rendered page.
+ if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no per-instance readings") {
+ t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body)
}
}
@@ -1861,15 +1873,19 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
}
// resourcesSection returns just the app page's Resources block.
+// resourcesSection used to cut out a dedicated "Resources" section; CPU/Mem
+// readings now live as columns on the Instances table itself, so this cuts
+// from that table's header to the end of the page's main content instead —
+// the same "just the per-route metrics chrome" scope, at its new address.
func resourcesSection(t *testing.T, body string) string {
t.Helper()
- i := strings.Index(body, "<h2>Resources</h2>")
+ i := strings.Index(body, "<h2>Instances</h2>")
if i < 0 {
- t.Fatalf("no Resources section in:\n%s", body)
+ t.Fatalf("no Instances section in:\n%s", body)
}
j := strings.Index(body[i:], "</main>")
if j < 0 {
- t.Fatalf("Resources section never ended:\n%s", body[i:])
+ t.Fatalf("Instances section never ended:\n%s", body[i:])
}
return body[i : i+j]
}
diff --git a/internal/console/store.go b/internal/console/store.go
index e6d0fc23a..6c76b98c6 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1231,10 +1231,101 @@ func (s *Store) MoveApp(app string, from, to int64) error {
if atSrc == 0 {
return fmt.Errorf("console: account %d does not claim %q", from, app)
}
+ // App-kind CLI tokens are bound (account, name), so they must follow the
+ // app or the running deployment's credential 404s while retaining
+ // authority over the name in the SOURCE account. Tokens already at the
+ // destination under this name are a purged predecessor's leftovers —
+ // re-homing onto them would hand this app a credential nobody minted for
+ // it, so that refuses, exactly as RenameAppIn refuses.
+ var staleTokens int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`,
+ cliKindApp, to, app).Scan(&staleTokens); err != nil {
+ return err
+ }
+ if staleTokens > 0 {
+ return fmt.Errorf("console: the destination account still has app credentials for %q from a previous app — revoke them first", app)
+ }
if _, err := tx.Exec(`UPDATE apps SET account_id = ? WHERE app = ? AND account_id = ?`,
to, app, from); err != nil {
return err
}
+ if _, err := tx.Exec(`UPDATE cli_tokens SET account_id = ? WHERE kind = ? AND account_id = ? AND app = ?`,
+ to, cliKindApp, from, app); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
+// RenameAppIn renames an app that has never deployed. The CALLER holds the
+// wider refusals — nothing under the app's bucket prefix, no registry route
+// wearing the name — because an app name is a literal key segment in both,
+// and this database is the only store a rename may touch. Here the refusals
+// are this database's own:
+//
+// - The new name must be free in this account, live or trashed — a trashed
+// row still owns its name until purged, exactly as CreateAppIn treats it.
+// - Neither name may appear in the legacy owners table. Those grants are
+// keyed by bare name across ALL accounts, so rewriting them could hand
+// one account's grant to another, and renaming ONTO one would let the new
+// name inherit a grant nobody issued it. Claim-proof stays claim-proof.
+// - The new name must carry no app-kind CLI tokens. A purged app's tokens
+// outlive its row, and a credential that suddenly binds to a NEW app is
+// authority nobody minted.
+//
+// What follows the app: its audit rows — but only those written since THIS
+// app's row was created, because a purged predecessor of the same name left
+// its rows behind and its history is not this app's; and its app-kind CLI
+// tokens, which are bound (account, name) and would otherwise 404 the
+// running app while retaining authority over the freed name. The rename
+// itself is the caller's audit line.
+func (s *Store) RenameAppIn(accountID int64, from, to string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback()
+ var createdAt int64
+ err = tx.QueryRow(`SELECT created_at FROM apps WHERE account_id = ? AND app = ? AND deleted_at = 0`,
+ accountID, from).Scan(&createdAt)
+ if err == sql.ErrNoRows {
+ return fmt.Errorf("console: this account does not claim %q", from)
+ } else if err != nil {
+ return err
+ }
+ var legacy int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM owners WHERE app IN (?, ?)`, from, to).Scan(&legacy); err != nil {
+ return err
+ }
+ if legacy > 0 {
+ return fmt.Errorf("console: %q or %q carries a legacy owner grant — renaming across it is an operator act", from, to)
+ }
+ var taken int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, accountID, to).Scan(&taken); err != nil {
+ return err
+ }
+ if taken > 0 {
+ return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to)
+ }
+ var staleTokens int
+ if err := tx.QueryRow(`SELECT COUNT(*) FROM cli_tokens WHERE kind = ? AND account_id = ? AND app = ?`,
+ cliKindApp, accountID, to).Scan(&staleTokens); err != nil {
+ return err
+ }
+ if staleTokens > 0 {
+ return fmt.Errorf("console: %q still has app credentials from a previous app of that name — revoke them first", to)
+ }
+ if _, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at = 0`,
+ to, accountID, from); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ? AND created_at >= ?`,
+ to, accountID, from, createdAt); err != nil {
+ return err
+ }
+ if _, err := tx.Exec(`UPDATE cli_tokens SET app = ? WHERE kind = ? AND account_id = ? AND app = ?`,
+ to, cliKindApp, accountID, from); err != nil {
+ return err
+ }
return tx.Commit()
}
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index 5463f5085..ef50d2681 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -3,10 +3,39 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-{{if and .Instances .Instances.Reporting}}
<h2>Instances</h2>
-<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
-{{if .Instances.Instances}}
+{{if .InstanceRows}}
+<div class="tablewrap">
+<table class="instances">
+ <tr><th>Instance</th><th>Channel</th><th>Running</th><th>Status</th><th>CPU</th><th>Memory</th></tr>
+ {{range .InstanceRows}}
+ <tr>
+ <td><a href="https://{{.Host}}">{{.Host}}</a><span class="small"> · {{.Kind}}</span></td>
+ <td><code>{{.Channel}}</code></td>
+ <td>{{if .Version}}<code>{{.Version}}</code>{{else}}<span title="the host has not reported an adopted release yet">—</span>{{end}}</td>
+ <td>{{if and .Hibernate (not .Metrics.Reporting)}}asleep{{else}}{{.Status}}{{end}}</td>
+ {{if .Metrics.Reporting}}
+ <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td>
+ <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td>
+ {{else}}
+ <td colspan="2"><span class="small">{{if .Hibernate}}asleep — no recent readings{{else}}no per-instance readings{{end}}</span></td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Running is the release each host reports it has adopted;
+sparklines cover the last 48 hours. The memory line is drawn against that
+instance's own peak.</p>
+{{else}}
+<p class="small">No instances yet — a host appears here when a route wires it
+to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
+{{end}}
+
+{{if and .Instances .Instances.Reporting .Instances.Instances}}
+<h2>Inside the app</h2>
+<p class="small">This app manages its own internal instances, and reports
+{{len .Instances.Instances}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
<div class="tablewrap">
<table>
<tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
@@ -21,39 +50,4 @@
</table>
</div>
{{end}}
-{{end}}
-
-{{if .AppMetrics}}
-<h2>Resources</h2>
-<p class="small">CPU and memory for each of this app&#39;s instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance&#39;s own peak, and the number beside it is the current figure.</p>
-{{range .AppMetrics}}
-{{if .Reporting}}
-<p class="small"><code>{{.Host}}</code></p>
-<section class="tiles" aria-label="{{.Host}} resources">
- <div class="tile">
- <h2>CPU</h2>
- <p class="tile-value">{{pct .CPUFrac}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>
- </div>
- <div class="tile">
- <h2>Memory</h2>
- <p class="tile-value">{{bytesOrDash .MemBytes}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg>
- </div>
-</section>
-{{else}}
-{{if .Hibernate}}
-<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
-{{else}}
-<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
-{{end}}
-{{end}}
-{{end}}
-{{end}}
-
-{{if and (or (not .Instances) (not .Instances.Reporting)) (not .AppMetrics)}}
-<p class="small">Nothing to show yet — instances appear when the app serves
-<code>/.carlos/instances</code>, and resource readings when it runs on a
-reporting box.</p>
-{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index c312c4804..2afa9ea83 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -7,19 +7,73 @@
<strong>This app is not deploying.</strong> Its hosts read releases from
account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
<code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Its bucket objects were moved without the console's own record
- following. On the console's box, run:
- <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
- <br><span class="small">If it is the routes that are in the wrong account,
- move those back instead — whichever of the two is right, they have to
- agree.</span>
+ nothing. Finish the move on this app's
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
+ <span class="small">If it is the routes that are in the wrong account, move
+ those back instead — whichever of the two is right, they have to agree.</span>
</p>
{{end}}
-{{template "ladder" .}}
-<p class="small">
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a>
- {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}}
- · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a>
-</p>
+{{with .Overview}}
+<section class="tiles stat-tiles" aria-label="Vital statistics">
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
+ <h2>Live now</h2>
+ {{if .HasStable}}
+ <p class="tile-value"><code>{{.LiveVersion}}</code></p>
+ <p class="tile-detail">on stable · promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</p>
+ {{else if .LiveErr}}
+ <p class="tile-value">—</p>
+ <p class="tile-detail error">stable pointer unreadable</p>
+ {{else}}
+ <p class="tile-value">not live</p>
+ <p class="tile-detail">nothing on stable yet</p>
+ {{end}}
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Instances</h2>
+ <p class="tile-value">{{.InstanceTotal}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}no hosts yet{{else if .MetricsUnavailable}}readings unavailable{{else}}{{.InstanceReporting}} reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>CPU</h2>
+ <p class="tile-value">{{pct .CPUSum}}</p>
+ <p class="tile-detail">{{if .CPUSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Memory</h2>
+ <p class="tile-value">{{bytesOrDash .MemSum}}</p>
+ <p class="tile-detail">{{if .MemSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
+ <h2>Domains</h2>
+ <p class="tile-value">{{.DomainTotal}}</p>
+ <p class="tile-detail">{{if eq .DomainTotal 0}}the first route brings one{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need attention</span>{{else}}all pointing, TLS ready{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
+ <h2>Releases</h2>
+ <p class="tile-value">{{.ReleaseCount}}{{if .ReleasesCapped}}+{{end}}</p>
+ <p class="tile-detail">{{if .LatestVersion}}latest <code>{{.LatestVersion}}</code>{{else}}nothing shipped yet{{end}}</p>
+ </a>
+</section>
+{{end}}
+{{with .Detail.Production}}
+{{if .Err}}
+<p class="small error">Supply-chain bake: flag unreadable ({{.Err}}) — hosts keep baking. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a> page.</p>
+{{else if .On}}
+<p class="small">Supply-chain bake: <strong>on</strong> — releases hold on beta and stable before hosts adopt. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a>.</p>
+{{else if .TurningOff}}
+<p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock.</p>
+{{else}}
+<p class="small">Supply-chain bake: off — hosts adopt promoted releases immediately.{{if .Nudge}} {{icon "triangle-alert"}} This app looks production-wired — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a>.{{end}}</p>
+{{end}}
+{{end}}
+{{if .Events}}
+<h2>Recent activity</h2>
+<ul class="feed">
+ {{range .Events}}
+ <li><span class="small" title="{{fmtTime .CreatedAt}}">{{ago .CreatedAt}}</span> {{.Address}} — {{.Detail}}</li>
+ {{end}}
+</ul>
+<p class="small"><a href="{{.Nav}}/apps/{{.AppName}}/activity">All activity</a></p>
+{{end}}
{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index f2b8eec76..7c1c5a8c8 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -7,15 +7,13 @@
<strong>This app is not deploying.</strong> Its hosts read releases from
account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
<code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Its bucket objects were moved without the console's own record
- following. On the console's box, run:
- <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
- <br><span class="small">If it is the routes that are in the wrong account,
- move those back instead — whichever of the two is right, they have to
- agree.</span>
+ nothing. Finish the move on this app's
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
</p>
{{end}}
+{{template "ladder" .}}
+
<h2>Channels</h2>
{{if .Detail.Channels}}
<div class="tablewrap">
@@ -28,7 +26,7 @@
<td colspan="3" class="error">{{.Err}}</td>
{{else}}
<td><code>{{.Version}}</code></td>
- <td>{{fmtTime .PromotedAt}}</td>
+ <td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td>
<td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td>
{{end}}
<td>{{if and $.CanPromote .HasHistory (not .Err)}}
@@ -36,14 +34,16 @@
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Name}}">
<input type="hidden" name="back" value="releases">
- <button type="submit" class="linkish danger">Roll back</button>
+ <button type="submit" class="btn-sm btn-danger">Roll back</button>
</form>
{{end}}</td>
</tr>
{{end}}
</table>
</div>
-<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p>
+<p class="small">Bake times are an approximation from each channel's default
+hold; the authoritative clock is each box's own first-seen record. Rollback
+never bakes.</p>
{{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}}
<h2>Canaries</h2>
@@ -59,7 +59,7 @@
<td colspan="3" class="error">{{.Err}}</td>
{{else}}
<td><code>{{.Version}}</code></td>
- <td>{{fmtTime .PromotedAt}}</td>
+ <td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td>
<td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td>
{{end}}
<td>{{if $.CanPromote}}
@@ -67,7 +67,7 @@
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="name" value="{{.Name}}">
<input type="hidden" name="back" value="releases">
- <button type="submit" class="linkish danger">Retire</button>
+ <button type="submit" class="btn-sm btn-danger">Retire</button>
</form>
{{end}}</td>
</tr>
@@ -83,7 +83,7 @@
<select name="version">
{{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}}
</select>
- <button type="submit">Create canary</button>
+ <button type="submit" class="btn-sm">Create canary</button>
</form>
<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel:</p>
{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
@@ -117,7 +117,7 @@
{{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
</select>
<input type="text" name="new_channel" placeholder="or a new channel" size="14">
- <button type="submit" class="linkish">Promote</button>
+ <button type="submit" class="btn-sm">Promote</button>
</form>
</td>{{end}}
</tr>
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 02017bcf5..4e24ef77b 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -10,6 +10,23 @@
<tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
</table>
</div>
+{{if .IsOwner}}
+{{if .Settings.CanRename}}
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="rename-to">New name</label>
+ <input type="text" id="rename-to" name="to" placeholder="new-name"
+ required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32">
+ <button type="submit" class="btn-sm">Rename</button>
+</form>
+<p class="small">This app has never shipped, so its name is still only a
+record here — after the first ship, the name is wired into addresses and
+bucket keys and stays.</p>
+{{else}}
+<p class="small">Renaming: {{.Settings.RenameBlocked}}</p>
+{{end}}
+{{end}}
+
<h2>Domains &amp; TLS</h2>
{{if .Detail.Domains}}
<div class="tablewrap">
@@ -29,4 +46,56 @@
</table>
</div>
{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
+<h3>Add a custom domain</h3>
+<p class="small">A custom domain is attached where the app runs — on its box,
+by an operator:</p>
+<pre><code>carlos add -host www.example.com -app {{.Detail.App}} -account {{.Account.Sqid}} \
+ -channel stable ...</code></pre>
+<p class="small">Then point the domain's A record at this deployment's edge;
+the certificate is minted on the first request and renews on its own.
+Attaching a domain from this page, without the box, is on the platform's
+roadmap.</p>
+
+{{if and .Settings .Settings.MismatchTarget}}
+<h2>Account</h2>
+<p>This app's hosts read releases from account
+<code>{{.Settings.MismatchTarget}}</code>, but the console's record says
+{{.Account.Name}} — so promotes from here reach nothing.</p>
+{{if and .IsOwner .Settings.TargetKnown .Settings.ViewerInTarget}}
+<p>Move the record to where the platform already deploys from:</p>
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/move">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" class="btn-sm">Move to {{.Settings.MismatchTarget}}</button>
+</form>
+{{else}}
+<p class="small">{{if not .IsOwner}}Moving the record is the account owner's
+to do{{else if not .Settings.TargetKnown}}The routes name an account this
+console does not know{{else}}You are not a member of that account{{end}} —
+the operator path is <code>console migrate-app -app {{.Detail.App}} -to
+{{.Settings.MismatchTarget}}</code> on the console's box.</p>
+{{end}}
+{{else}}{{if .IsOwner}}
+<h2>Account</h2>
+<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a
+different account moves its bucket objects and re-stamps its routes —
+<code>carlos accounts migrate</code>, an operator act; once run, this page
+offers the final console-record step.</p>
+{{end}}{{end}}
+{{if .IsOwner}}
+
+<h2 class="danger-head">Delete this app</h2>
+<p class="small">Deleting puts {{.Detail.App}} in the account's
+<a href="{{.Nav}}/trash">trash</a>: hosts stop serving it on their next pass
+(custom domains included), and it is restorable for
+{{trashCooloffDays}} days before the platform destroys its releases for
+good.{{if .Detail.Production.On}} It is flagged production right now, which
+refuses deletion — turn the supply-chain bake off first.{{end}}</p>
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/delete" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <label class="visually-hidden" for="delete-confirm">Type the app's name to confirm</label>
+ <input type="text" id="delete-confirm" name="confirm" placeholder="type {{.Detail.App}} to confirm" autocomplete="off" required>
+ <button type="submit" class="btn-sm btn-danger">Delete {{.Detail.App}}</button>
+</form>
+<p class="small">Confirms with your passkey.</p>
+{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/confirm_action.html b/internal/console/templates/confirm_action.html
new file mode 100644
index 000000000..4cd986c2c
--- /dev/null
+++ b/internal/console/templates/confirm_action.html
@@ -0,0 +1,23 @@
+{{define "confirm_action"}}{{template "top" .}}
+<div class="pagehead">
+ <p class="eyebrow">Confirm · {{.AppName}}</p>
+ <h1>{{.Confirm.Title}}</h1>
+</div>
+<div class="confirm-card">
+ {{range .Confirm.Lines}}<p>{{.}}</p>{{end}}
+ {{if .Confirm.Warn}}<p class="error">{{.Confirm.Warn}}</p>{{end}}
+ <form method="post" action="{{.Confirm.Action}}">
+ {{range .Confirm.Fields}}<input type="hidden" name="{{index . 0}}" value="{{index . 1}}">{{end}}
+ <input type="hidden" name="confirmed" value="1">
+ {{if .Confirm.CheckboxName}}
+ <p><label class="small"><input type="checkbox" name="{{.Confirm.CheckboxName}}" value="1"{{if .Confirm.CheckboxChecked}} checked{{end}}>
+ {{.Confirm.CheckboxLabel}}</label></p>
+ {{end}}
+ <div class="actions">
+ <button type="submit"{{if .Confirm.Danger}} class="btn-danger"{{end}}>{{.Confirm.Button}}</button>
+ <a href="{{.Confirm.CancelURL}}">Cancel</a>
+ </div>
+ </form>
+ {{if .Confirm.Note}}<p class="small confirm-note">{{.Confirm.Note}}</p>{{end}}
+</div>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index a89a4b18d..26e69c263 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -1,35 +1,33 @@
{{define "ladder"}}
<h2>Ladder</h2>
-<p class="small">A channel is maturity, not a place: the same build climbs
-canary → edge → beta → stable, and a promotion moves a signed pointer without
-touching what any host is wired to.</p>
+<p class="small">The same build climbs canary → edge → beta → stable; a
+promotion moves a signed pointer, and hosts follow their channel.</p>
{{with .Detail.Production}}
<div class="bake">
{{if .Err}}
- <p class="error">Production flag unreadable: {{.Err}} — hosts treat this app as in production and keep baking.
+ <p class="small error">Supply-chain bake: flag unreadable: {{.Err}} — hosts keep baking.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="production" value="on">
<button type="submit" class="linkish">Re-declare production</button>
</form>
- <span class="small">— writes a fresh signed flag over the unreadable one. Without this the app
- bakes forever; turning it off afterwards is the owner's, through the ordinary control.</span>
+ — writes a fresh signed flag over the unreadable one.
{{end}}
</p>
{{else if .On}}
- <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h and stable 72h on each host before it adopts.
+ <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h, stable 72h, on each host's own clock.
{{if and $.CanPromote $.IsOwner}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="production" value="off">
<button type="submit" class="linkish">Turn off</button>
</form>
- <span class="small">— confirms with your passkey; each host honors it after the stable window (~72h) on its own clock</span>
+ <span title="Turning off confirms with your passkey; each host honors it after the stable window (~72h) on its own clock.">— passkey; honored after ~72h</span>
{{end}}
</p>
{{else if .TurningOff}}
- <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock.
+ <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -39,7 +37,7 @@ touching what any host is wired to.</p>
{{end}}
</p>
{{else}}
- <p class="small">Supply-chain bake: <strong>off</strong> (default) — hosts adopt releases immediately.
+ <p class="small">Supply-chain bake: off — hosts adopt promoted releases immediately.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -65,17 +63,16 @@ touching what any host is wired to.</p>
<p class="error"><code>{{.Channel}}</code> — {{.Err}}</p>
{{else}}
<p><code>{{.Channel}}</code> holds <code>{{.Version}}</code>
- <span class="small">promoted {{fmtTime .PromotedAt}}
- {{if .Actor}}by {{.Actor}}{{else}}— by whom is not recorded, on a pointer written before the trail existed{{end}}
- {{if .Hotfix}}· ladder bypassed (hotfix){{end}}
- {{if .FastTrack}}· fast-tracked past the bake window{{end}}</span></p>
+ <span class="small" title="{{fmtTime .PromotedAt}}">promoted {{ago .PromotedAt}}{{if .Actor}} by {{.Actor}}{{end}}</span>
+ {{if .Hotfix}}<span class="chip chip-stale">{{icon "zap"}} hotfix — ladder bypassed</span>{{end}}
+ {{if .FastTrack}}<span class="chip chip-stale">{{icon "fast-forward"}} fast-tracked</span>{{end}}</p>
{{if and (not .BakedUntil.IsZero) $.CanPromote $.IsOwner}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/fasttrack" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Channel}}">
- <button type="submit" class="linkish">Fast-track {{.Version}}</button>
+ <button type="submit" class="btn-sm">Fast-track {{.Version}}</button>
</form>
- <span class="small">— in its bake window until ~{{fmtTime .BakedUntil}}; confirms with your passkey</span>
+ <span class="small">baking until ~{{fmtTime .BakedUntil}}</span>
{{end}}
{{end}}
{{if .Routes}}
@@ -107,13 +104,8 @@ touching what any host is wired to.</p>
<input type="hidden" name="csrf" value="{{$.CSRF}}">
<input type="hidden" name="channel" value="{{.Up.To}}">
<input type="hidden" name="version" value="{{.Up.Version}}">
- {{if and (eq .Up.To "stable") (not $.Detail.Production.On)}}
- <label class="small"><input type="checkbox" name="enable_bake" value="1" checked>
- This app is in production — enable the supply-chain bake (recommended)</label>
- {{end}}
- <button type="submit" class="linkish">Promote {{.Up.Version}} to {{.Up.To}}</button>
+ <button type="submit" class="btn-sm">Promote {{.Up.Version}} to {{.Up.To}}</button>
</form>
- {{if .Up.StepUp}}<span class="small">— confirms with your passkey</span>{{end}}
{{else if .Up.Note}}
<span class="small">{{.Up.Note}}</span>
{{else}}
@@ -124,8 +116,4 @@ touching what any host is wired to.</p>
</li>
{{end}}
</ol>
-<p class="small">A canary is never refused by the ladder and never gates
-anything: it is where a change is proven before it is a candidate. Everything
-above edge is entered one rung at a time — the only bypass is
-<code>carlos promote -hotfix</code>, which says so on the pointer it moves.</p>
{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 21494314e..362bdd63b 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -44,6 +44,10 @@
border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.3rem 0.5rem;
}
td form { display: flex; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin: 0; }
+ /* A form living inside a sentence (the bake toggle, a rung's fast-track):
+ it must not break the line it is part of. */
+ form.inline { display: inline; margin: 0; }
+ form.inline button { vertical-align: baseline; }
/* Interface icons: inline SVG from internal/console/icons.go — Lucide,
vendored, because this page fetches nothing from anywhere. They carry no
width/height of their own, so they size to the text beside them and
@@ -113,6 +117,11 @@
main { max-width: 64rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; }
h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; }
h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; }
+ h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; }
+ /* Settings: an input-and-button row, and the deletion section's heading
+ in the ink that says be careful. */
+ .settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; }
+ .danger-head { color: var(--bad); }
p { max-width: 40rem; margin: 0 0 1rem; }
.lede { font-size: 1.15rem; color: var(--muted); }
.small { font-size: 0.9rem; color: var(--muted); }
@@ -131,6 +140,13 @@
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; }
+ /* A tile that is also a link (the Overview dashboard): the tile itself is
+ the affordance, so no underline and no accent ink — hover carries it. */
+ a.tile { color: var(--ink); text-decoration: none; }
+ a.tile:hover { outline: 1px solid var(--accent); }
+ /* The dashboard's six statistics as an even grid — flex would let the
+ last row's stragglers stretch full-width. */
+ .stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); }
.tile h2 {
margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--muted); font-weight: 600;
@@ -208,6 +224,32 @@
}
button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
button.linkish.danger { color: var(--bad); }
+ /* Actions that change what serves are BUTTONS, not links — small enough to
+ sit inside a table row or a ladder rung, unmistakably a button all the
+ same. Danger is filled red: rollbacks and retirements should not dress
+ like promotions. */
+ .btn-sm, button.btn-sm {
+ font-size: 0.85rem; padding: 0.35rem 0.8rem; border-radius: 0.4rem;
+ }
+ button.btn-danger, .btn-danger { background: var(--bad); color: var(--bg); }
+ button.btn-danger:hover, .btn-danger:hover { background: var(--bad); filter: brightness(0.9); }
+ /* The Instances table's in-cell readings: number first, then the 48h
+ shape beside it, low-contrast so the number stays the headline. */
+ td .cell-value { display: inline-block; min-width: 3.5rem; font-weight: 600; }
+ .cellspark { width: 6rem; height: 1.1rem; vertical-align: -0.2rem; color: var(--muted); }
+ .cellspark polyline { stroke-width: 2.5; }
+ /* The Overview's recent-activity strip. */
+ .feed { list-style: none; margin: 0 0 0.5rem; padding: 0; max-width: 40rem; }
+ .feed li { padding: 0.25rem 0; border-bottom: 1px solid var(--rule); }
+ .feed li:last-child { border-bottom: 0; }
+ /* The confirm interstitial: one card saying exactly what is about to
+ happen, one button that does it, one link that does not. */
+ .confirm-card {
+ border: 1px solid var(--rule); border-radius: 0.6rem;
+ padding: 1.25rem 1.4rem; max-width: 40rem; margin: 0 0 1rem;
+ }
+ .confirm-card .actions { display: flex; gap: 0.9rem; align-items: center; margin-top: 1rem; }
+ .confirm-card .confirm-note { margin: 0.8rem 0 0; }
footer {
max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem;
border-top: 1px solid var(--rule);
diff --git a/internal/console/view.go b/internal/console/view.go
index b3071e974..b9de2b31e 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -345,6 +345,11 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
if rtAcct == acct {
capsFor = humanizeCaps(rt.Caps)
}
+ // One RouteView and one DomainView per route, appended together:
+ // AppView.Routes and AppView.Domains are PARALLEL slices, and
+ // assembleOverview leans on that to account-filter domain rows
+ // (DomainView carries no account of its own). Splitting these
+ // appends apart breaks that join silently.
byApp[rt.App] = append(byApp[rt.App], RouteView{
Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,