carlos / platform Public

console: the app pages grow up — dashboard, human ladder, confirms, settings

Overview becomes a dashboard of vital statistics (what stable serves,
instances reporting, CPU/mem, domains, releases) and stops polling the
app's own self-report endpoint; the ladder moves to Releases and loses
its verbosity (chips for hotfix/fast-track, relative times, one-line
intro). Promote, rollback, fast-track and canary-retire become real
buttons behind a server-rendered confirm card that sits strictly between
the owner 404 and requireFreshAuth. Instances is one table — host,
channel, running version, status, readings — and the metrics sweep runs
concurrently (boxes x4, hourly reads x12, deterministic order, same
failure semantics) instead of boxes x 48 serial bucket reads. Settings
gets real settings: rename while the name is still only a console record
(RenameAppIn, transactional, claim-proof), delete into the deletion-
policy trash (type the name, passkey, marker's clock), the account-move
repair (console migrate-app from the browser, target read from the
routes), and the honest custom-domain recipe — console-driven attach is
issue #82.

Spec: docs/superpowers/specs/2026-08-07-console-app-pages-design.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2532718cc7333890f76d1b5624aed7f43318b5ad parent b918b56
15 files changed, +1441 −123
  • docs/superpowers/specs/2026-08-07-console-app-pages-design.md +144 −0
  • internal/console/appsettings.go +394 −0
  • internal/console/confirm.go +228 −0
  • internal/console/icons.go +13 −0
  • internal/console/metricsview.go +112 −26
  • internal/console/overview.go +159 −0
  • internal/console/server.go +87 −8
  • internal/console/store.go +52 −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 +35 −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..637793fba
--- /dev/null
+++ b/internal/console/appsettings.go
@@ -0,0 +1,394 @@
+// 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)
+ }
+ // Lowercased before validation, matching the create form: the input
+ // accepts what the server accepts, and the server speaks lowercase.
+ to := strings.ToLower(strings.TrimSpace(r.FormValue("to")))
+ if !release.ValidApp(to) {
+ back("err", "App names are lowercase letters, digits and dashes.")
+ 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
+ }
+ 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."
+ 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.
+ deletedAt := now
+ if tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && tomb != nil {
+ 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
+ }
+ 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},
+ },
+ Button: "Move to " + dest.Name,
+ CancelTab: "settings",
+ }
+ s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
+ 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/confirm.go b/internal/console/confirm.go
new file mode 100644
index 000000000..62d496227
--- /dev/null
+++ b/internal/console/confirm.go
@@ -0,0 +1,228 @@
+// 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",
+ Fields: [][2]string{
+ {"csrf", raw}, {"channel", channel}, {"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
+ } 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/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/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/overview.go b/internal/console/overview.go
new file mode 100644
index 000000000..bdeaef9f2
--- /dev/null
+++ b/internal/console/overview.go
@@ -0,0 +1,159 @@
+// 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. Asleep counts hibernating
+ // non-reporters — expected quiet, not missing.
+ InstanceTotal int
+ InstanceReporting int
+ InstanceAsleep int
+
+ // 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 CPU/memory tiles, never the
+// page); versions may be nil the same way.
+func assembleOverview(view *AppView, metrics []InstanceMetrics, versions []VersionView) OverviewStats {
+ st := OverviewStats{}
+ 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
+ }
+ for _, m := range metrics {
+ st.InstanceTotal++
+ if m.Reporting() {
+ st.InstanceReporting++
+ 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
+ }
+ } else if m.Hibernate {
+ st.InstanceAsleep++
+ }
+ }
+ st.DomainTotal = len(view.Domains)
+ for _, d := range view.Domains {
+ if domainNeedsAttention(d) {
+ 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/server.go b/internal/console/server.go
index c31e827de..f0208a93a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -32,6 +32,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 —
@@ -270,6 +283,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
@@ -387,6 +405,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.
@@ -1332,6 +1364,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 {
@@ -1344,10 +1379,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, 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) {
@@ -1364,8 +1409,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 {
@@ -1373,6 +1420,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)
}
@@ -1390,13 +1438,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)
}
@@ -1632,6 +1682,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
@@ -1729,7 +1793,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
}
@@ -1767,6 +1831,10 @@ 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
+ }
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())
@@ -1871,6 +1939,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
}
@@ -2014,7 +2089,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
// 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
}
@@ -2065,6 +2140,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/store.go b/internal/console/store.go
index e6d0fc23a..cc62b7ab2 100644
--- a/internal/console/store.go
+++ b/internal/console/store.go
@@ -1238,6 +1238,58 @@ func (s *Store) MoveApp(app string, from, to int64) error {
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.
+//
+// Audit history follows the app: its story belongs to the app, not to a name
+// that no longer resolves. 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 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)
+ }
+ res, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at IS NULL`,
+ to, accountID, from)
+ if err != nil {
+ return err
+ }
+ if n, err := res.RowsAffected(); err != nil {
+ return err
+ } else if n == 0 {
+ return fmt.Errorf("console: this account does not claim %q", from)
+ }
+ if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ?`,
+ to, accountID, from); err != nil {
+ return err
+ }
+ return tx.Commit()
+}
+
// HasApp reports whether an app name is claimed in the apps table.
func (s *Store) HasApp(app string) (bool, error) {
var n int
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..affd9aad1 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" 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}}{{.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}}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}}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 — 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 .At}}">{{ago .At}}</span> {{.Actor}} — {{.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 27dd4c509..b84554eb8 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: <code>carlos add -app {{.Detail.App}} -channel canary/&lt;name&gt; &lt;host&gt;</code>.</p>
{{end}}
@@ -111,7 +111,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..a299350a2 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 .IsOwner}}
+{{if and .Settings .Settings.MismatchTarget}}
+<h2>Account</h2>
+{{if and .Settings.TargetKnown .Settings.ViewerInTarget}}
+<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. 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">This app's hosts read releases from account
+<code>{{.Settings.MismatchTarget}}</code>{{if not .Settings.TargetKnown}},
+which this console does not know{{else}}, which you are not a member of{{end}}
+— finishing the move is an operator act: <code>console migrate-app -app
+{{.Detail.App}} -to {{.Settings.MismatchTarget}}</code> on the console's box.</p>
+{{end}}
+{{else}}
+<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}}
+
+<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..81be54174 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 — 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..41ea9a67d 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -113,6 +113,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 +136,10 @@
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); }
.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 +217,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);