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
24 files changed,
+3173
−202
docs/superpowers/specs/2026-08-07-console-app-pages-design.md+144 −0internal/console/appsettings.go+443 −0internal/console/appsettings_test.go+657 −0internal/console/bakecontrols_test.go+6 −6internal/console/confirm.go+233 −0internal/console/confirm_test.go+314 −0internal/console/icons.go+13 −0internal/console/ladder_test.go+44 −32internal/console/metricsview.go+112 −26internal/console/metricsview_test.go+162 −0internal/console/overview.go+192 −0internal/console/overview_test.go+317 −0internal/console/server.go+110 −8internal/console/server_account_test.go+19 −5internal/console/server_test.go+52 −36internal/console/store.go+91 −0internal/console/templates/app_instances.html+32 −38internal/console/templates/app_overview.html+66 −12internal/console/templates/app_releases.html+13 −13internal/console/templates/app_settings.html+69 −0internal/console/templates/confirm_action.html+23 −0internal/console/templates/ladder.html+14 −26internal/console/templates/layout.html+42 −0internal/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}} <host></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's instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance'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/<name></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 & 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, |