console: the app pages grow up — dashboard, human ladder, confirms, settings
Overview becomes a dashboard of vital statistics (what stable serves, instances reporting, CPU/mem, domains, releases) and stops polling the app's own self-report endpoint; the ladder moves to Releases and loses its verbosity (chips for hotfix/fast-track, relative times, one-line intro). Promote, rollback, fast-track and canary-retire become real buttons behind a server-rendered confirm card that sits strictly between the owner 404 and requireFreshAuth. Instances is one table — host, channel, running version, status, readings — and the metrics sweep runs concurrently (boxes x4, hourly reads x12, deterministic order, same failure semantics) instead of boxes x 48 serial bucket reads. Settings gets real settings: rename while the name is still only a console record (RenameAppIn, transactional, claim-proof), delete into the deletion- policy trash (type the name, passkey, marker's clock), the account-move repair (console migrate-app from the browser, target read from the routes), and the honest custom-domain recipe — console-driven attach is issue #82. Spec: docs/superpowers/specs/2026-08-07-console-app-pages-design.md
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
15 files changed,
+1441
−123
docs/superpowers/specs/2026-08-07-console-app-pages-design.md+144 −0internal/console/appsettings.go+394 −0internal/console/confirm.go+228 −0internal/console/icons.go+13 −0internal/console/metricsview.go+112 −26internal/console/overview.go+159 −0internal/console/server.go+87 −8internal/console/store.go+52 −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+35 −0
diff --git a/docs/superpowers/specs/2026-08-07-console-app-pages-design.md b/docs/superpowers/specs/2026-08-07-console-app-pages-design.md| new file mode 100644 |
| index 000000000..d8348799c |
| --- /dev/null |
| +++ b/docs/superpowers/specs/2026-08-07-console-app-pages-design.md |
| @@ -0,0 +1,144 @@ |
| +# Console app pages: dashboard, ladder, confirms, instances, settings |
| + |
| +2026-08-07. Follows the 2026-08-06 console redesign (PR #68), which split the |
| +app page into tabs but deliberately moved no furniture and added no features. |
| +This is the furniture-and-features pass, from Paul's review of the redesign: |
| + |
| +> The "Overview" page should be a dashboard rather than the ladder. It should |
| +> show vital statistics. The ladder should be much more human friendly ... |
| +> less verbosity, more clarity. Releases should have buttons rather than |
| +> links for things like Promote, and actions like "Promote" should have a |
| +> confirm step. Instances view is currently slow. It should be a table, and |
| +> each instance should show what channel / release / version it's on, as well |
| +> as its resource usage. Settings doesn't actually have any settings. |
| +> Settings should allow adding custom domains, attaching them, changing the |
| +> name of the app, deleting the app, moving to a different account. |
| + |
| +## 1. Overview is a dashboard |
| + |
| +`app_overview.html` renders `OverviewStats` (overview.go): what stable |
| +serves, instance count and how many report, CPU/memory summed across |
| +reporting instances, domains and how many need attention, releases and the |
| +newest. Every number is derived from reads the app pages already did — |
| +`AppView`, the shared metrics sweep, the versions list; the dashboard adds no |
| +bucket or registry I/O, and it DROPS the overview's old self-report HTTP poll |
| +(`instances.Report`), so the landing tab never waits on a tenant's own |
| +endpoint. Tiles link to the tab that explains them. A one-line bake status |
| +and the last five audit events close the page. |
| + |
| +The ladder renders on Releases now, above the channels table. Overview keeps |
| +the AccountMismatch banner (pointing at Settings, which now owns the repair — |
| +§5), because "this app is not deploying" belongs on the landing tab. |
| + |
| +## 2. The ladder, humanized |
| + |
| +Template-only change (ladder.html): the three-sentence philosophy intro is |
| +one line; promotion times read "2h4m ago" (absolute stamp in the title |
| +attribute); the unrecorded-actor apology is dropped — no actor shown means |
| +not recorded; hotfix and fast-track become permanent chips (`zap`, |
| +`fast-forward`, vendored in icons.go) instead of prose clauses; the trailing |
| +canary essay is gone (the Releases canary section explains them). The |
| +assembly (ladder.go) and its gate copy are untouched. |
| + |
| +## 3. Actions are buttons, with a confirm step |
| + |
| +Promote, rollback, fast-track and canary-retire submit via real buttons |
| +(`.btn-sm`, danger variants filled red) and pass through a server-rendered |
| +confirm interstitial (confirm.go, confirm_action.html): one card saying what |
| +serves now, what will serve after, the bake consequence, and — for stable — |
| +the one red line about every user getting it. No JavaScript; the card is the |
| +POST's response, and its button re-POSTs the same fields plus `confirmed=1`. |
| + |
| +The card is a UI affordance, not a gate. Placement inside each handler is |
| +load-bearing and tested: after CSRF and input validation (the card never |
| +describes an invalid request), after any owner-only 404 (a member probing an |
| +owner action meets the byte-identical 404, never a card), before |
| +`requireFreshAuth` (the step-up replay carries `confirmed=1`, so confirm and |
| +passkey each happen exactly once, in that order). A hand-written POST with |
| +`confirmed=1` meets exactly the doors a button does. |
| + |
| +The beta→stable `enable_bake` checkbox moved from the ladder form onto the |
| +stable confirm card — the card is where that decision is actually being made. |
| +The production toggle keeps its inline linkish form: OFF already carries the |
| +passkey ceremony, and ON is the cheap, safe direction. |
| + |
| +## 4. Instances: one table, and a sweep that doesn't crawl |
| + |
| +`app_instances.html` renders one row per route of the viewed account |
| +(`InstanceRow`, overview.go): host, channel, the release the host reports it |
| +is RUNNING (registry `Route.Version`), status (hibernate-aware), and CPU/ |
| +memory with 48h sparklines inline. The join key is the host; the metrics |
| +side is the same `OwnerAppMetrics` allowlist as before — the tenant boundary |
| +does not move. The app's own `/.carlos/instances` self-report demotes to an |
| +"Inside the app" section, for apps that manage internal instances. |
| + |
| +The slowness was the cold metrics sweep: `boxes × 48` SEQUENTIAL bucket |
| +reads (metricsview.go). The sweep now runs boxes 4 at a time and each box's |
| +48 hourly reads 12 at a time, with results assembled in deterministic order |
| +(hour-order concatenation before the non-stable sort — equal timestamps must |
| +not flap between renders) and the same failure semantics: missing hour |
| +skipped, unparsable chunk skipped, any other read error fails the whole |
| +sweep and caches nothing. Exec-backed apps that publish no per-instance |
| +readings (issue: instances-readings gap) are a separate feature, untouched |
| +here. |
| + |
| +## 5. Settings: the settings an app record can have |
| + |
| +What Settings now offers, and the line it deliberately draws: the console |
| +mutates its own database and the bucket's markers, never the registry. |
| + |
| +- **Rename** — for an app that has NEVER deployed: nothing under its bucket |
| + prefix (account-qualified or legacy), no registry route wearing the name, |
| + no legacy owners-table grant on either name. Then the name is still only a |
| + console record, and `RenameAppIn` (store.go) renames it transactionally, |
| + audit history following. Anything else refuses with the reason: the app |
| + name is a literal key segment in bucket prefixes, signed pointer payloads, |
| + registry rows, the platform hostname, even a host-role app's Unix user — a |
| + live rename is a coordinated migration this platform does not have, not a |
| + form. |
| +- **Delete** — the deletion-policy trash, from the browser: owner types the |
| + app's name (a which-app control, checked before step-up so a slip costs no |
| + passkey touch), confirms with the passkey, and the handler mirrors the API |
| + door exactly — production flag refuses (unreadable = production), the |
| + bucket marker is written create-only, and the console row carries the |
| + MARKER's clock. Restore and purge stay on the Trash page. |
| +- **Move to a different account** — the repair half. When the app's routes |
| + already read from another account (AccountMismatch), an owner standing in |
| + both accounts can make the console record agree — `console migrate-app` |
| + from the browser, target taken from the ROUTES, never the form. The full |
| + move (bucket objects + route re-stamp) remains `carlos accounts migrate`, |
| + an operator act, and the page says so. |
| +- **Custom domains** — the table stays (DNS/TLS status, pointing guidance), |
| + and the page now states the attach recipe honestly: `carlos add` on the |
| + box, an operator act. Console-driven attach needs a new platform feature — |
| + below. |
| + |
| +Gate order throughout, unchanged from ownership.go's mandate: session → |
| +CSRF → owner (byte-identical 404) → cheap refusals → type-the-name where |
| +destruction warrants it → confirm card → `requireFreshAuth` LAST. |
| + |
| +## 6. Future: console-driven custom domain attach |
| + |
| +Filed as an issue rather than built here, because it is a fleet feature, not |
| +a console handler. The design that fits everything this platform already |
| +does (mirroring instance provisioning, the closest precedent): |
| + |
| +- Write side: Settings/API writes a create-only desired-domain record, |
| + `apps/<sqid>/<app>/domains/<host>.json` — host, app, account, actor, |
| + added_at — after validating the host is a real FQDN, not a platform |
| + domain, not already routed. |
| +- Box side: the domains reconcile pass (which already minted platform hosts |
| + and already drops a trashed app's routes) materializes a route for each |
| + record whose app it already serves — copying kind/socket/channel from the |
| + app's existing primary route, stamped with a `domain_record` ownership |
| + flag (the `Provisioned` precedent) so the reconciler owns the route's |
| + lifecycle and never touches operator-added rows. Record deleted → route |
| + dropped. |
| +- Certs need nothing: the edge already mints per-host certificates on first |
| + handshake for any route it serves; DNS guidance and health stamps already |
| + exist on the Domains table. |
| + |
| +Open questions for that issue: which box claims the record when the app |
| +serves from several (all of them? the stable-route boxes?), the registry |
| +schema addition (one column, additive), and rollout order (box binaries |
| +before the console door opens, or records simply sit unmaterialized). |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| new file mode 100644 |
| index 000000000..637793fba |
| --- /dev/null |
| +++ b/internal/console/appsettings.go |
| @@ -0,0 +1,394 @@ |
| +// The app Settings tab's write half: rename, delete, and the account-move |
| +// repair — the app-level siblings of ownership.go's account actions, holding |
| +// the same gate order (session → CSRF → owner 404 → cheap refusals → confirm |
| +// → requireFreshAuth LAST) and the same posture: the console mutates its own |
| +// database and the bucket's markers, never the registry. |
| +// |
| +// What is deliberately NOT here, and why: |
| +// |
| +// - Renaming a LIVE app. The name is a literal key segment in every store |
| +// this platform has — bucket prefixes, signed pointer payloads, registry |
| +// rows, platform hostnames, even a host-role app's Unix user — so a live |
| +// rename is a coordinated migration, not a settings write. The form |
| +// refuses with that reason once anything outside this database knows the |
| +// name. |
| +// - Creating routes (custom domains). The console reads the registry and |
| +// writes it nothing; domains attach on the box today, and the console- |
| +// driven path wants the bucket-record + reconciler design instance |
| +// provisioning proved out — a platform feature, not a handler here. |
| +// - The full account move. `carlos accounts migrate` moves bucket objects |
| +// and re-stamps routes on the box; what the console owns is its own |
| +// record, so what Settings offers is the repair half — making this |
| +// database agree with what the routes already say. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// SettingsView is what the Settings tab knows beyond the shared AppView. |
| +type SettingsView struct { |
| + // CanRename: nothing outside the console database knows this name yet. |
| + // RenameBlocked carries the reason when it is false. |
| + CanRename bool |
| + RenameBlocked string |
| + |
| + // The account-move repair: MismatchTarget is the account the app's |
| + // routes say it belongs to ("" when console and routes agree), |
| + // TargetKnown whether this console has that account at all, and |
| + // ViewerInTarget whether the signed-in owner is a member of it — the |
| + // difference between offering the button and explaining the operator |
| + // path. |
| + MismatchTarget string |
| + TargetKnown bool |
| + ViewerInTarget bool |
| +} |
| + |
| +// assembleSettings fills the Settings tab's view. Best-effort by design: a |
| +// bucket hiccup here blocks the rename form (fail closed — an unverifiable |
| +// emptiness claim must not enable an identity change) and never the page. |
| +func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *SettingsView { |
| + sv := &SettingsView{} |
| + if s.cfg.Releases == nil { |
| + sv.RenameBlocked = "No deployment bucket on this host, so the never-shipped check cannot run." |
| + return sv |
| + } |
| + switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, p.AppName); { |
| + case err != nil: |
| + sv.RenameBlocked = "Could not check the deployment bucket, so renaming is off until it can be." |
| + case len(p.Detail.Routes) > 0: |
| + sv.RenameBlocked = "This app is wired to a host — its name is its address, in routes and bucket keys alike." |
| + case !empty: |
| + sv.RenameBlocked = "This app has shipped — its name is baked into its releases' bucket keys." |
| + default: |
| + sv.CanRename = true |
| + } |
| + if ra := p.Detail.RouteAccount; ra != "" { |
| + sv.MismatchTarget = ra |
| + if target, err := s.store.AccountBySqid(ra); err == nil && target != nil { |
| + sv.TargetKnown = true |
| + if role, err := s.store.Role(target.ID, p.Address); err == nil && role != "" { |
| + sv.ViewerInTarget = true |
| + } |
| + } |
| + } |
| + return sv |
| +} |
| + |
| +// appDeployEmpty reports whether the bucket holds ANYTHING under the app's |
| +// prefix — account-qualified or legacy-unqualified. One key is enough to |
| +// refuse a rename; the caller never needs the listing itself. |
| +func (s *Server) appDeployEmpty(ctx context.Context, acct, app string) (bool, error) { |
| + for _, prefix := range []string{release.AppPrefix(acct, app), release.LegacyAppPrefix(app)} { |
| + keys, err := s.cfg.Releases.List(ctx, prefix) |
| + if err != nil { |
| + return false, err |
| + } |
| + if len(keys) > 0 { |
| + return false, nil |
| + } |
| + } |
| + return true, nil |
| +} |
| + |
| +// appHasRoutes reports whether ANY registry route wears the name — any |
| +// account's. Deliberately wider than the tenant boundary: a rename must not |
| +// free a name some other tenant's route still resolves by. |
| +func (s *Server) appHasRoutes(app string) (bool, error) { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return false, err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return false, err |
| + } |
| + for _, rt := range routes { |
| + if rt.App == app { |
| + return true, nil |
| + } |
| + } |
| + return false, nil |
| +} |
| + |
| +// handleAppRename renames a never-deployed app. Owner's, behind the confirm |
| +// card, without the passkey: nothing outside this console's database changes, |
| +// and the refusals above guarantee exactly that. |
| +func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| + } |
| + // Lowercased before validation, matching the create form: the input |
| + // accepts what the server accepts, and the server speaks lowercase. |
| + to := strings.ToLower(strings.TrimSpace(r.FormValue("to"))) |
| + if !release.ValidApp(to) { |
| + back("err", "App names are lowercase letters, digits and dashes.") |
| + return |
| + } |
| + if to == app { |
| + back("err", "That is already this app's name.") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", "No deployment bucket on this host, so the never-shipped check cannot run — renaming is refused.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + // The two wider stores that know app names, checked at action time — the |
| + // Settings view's CanRename was advice, this is the refusal. Both fail |
| + // CLOSED: "could not check" is not "empty". |
| + switch empty, err := s.appDeployEmpty(ctx, acct.Sqid, app); { |
| + case err != nil: |
| + back("err", "Could not check the deployment bucket — nothing was renamed. Try again.") |
| + return |
| + case !empty: |
| + back("err", "This app has shipped — its name is baked into its releases' bucket keys, so renaming it is not supported. Create a new app and ship there instead.") |
| + return |
| + } |
| + switch wired, err := s.appHasRoutes(app); { |
| + case err != nil: |
| + back("err", "Could not check the routes — nothing was renamed. Try again.") |
| + return |
| + case wired: |
| + back("err", "A host still routes this name — remove its routes first.") |
| + return |
| + } |
| + if !confirmed(r) { |
| + cv := &ConfirmView{ |
| + Title: "Rename " + app + " to " + to + "?", |
| + Lines: []string{ |
| + app + " has never shipped, so nothing outside this console knows the name yet.", |
| + "After this, the app is " + to + " — commands, pages and its future addresses all say so.", |
| + }, |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rename", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, {"to", to}, |
| + }, |
| + Button: "Rename", |
| + CancelTab: "settings", |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| + return |
| + } |
| + if err := s.store.RenameAppIn(acct.ID, app, to); err != nil { |
| + log.Printf("console: rename %s/%s -> %s: %v", acct.Sqid, app, to, err) |
| + back("err", renameAdvice(err, to)) |
| + return |
| + } |
| + s.audit(acct, to, addr, "rename", "renamed "+app+" to "+to) |
| + s.setFlash(w, "ok", "Renamed "+app+" to "+to+".") |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(to)+"/settings", http.StatusSeeOther) |
| +} |
| + |
| +// renameAdvice maps RenameAppIn's refusals onto the sentence a person can |
| +// act on, without leaking storage details they cannot. |
| +func renameAdvice(err error, to string) string { |
| + msg := err.Error() |
| + switch { |
| + case strings.Contains(msg, "already has an app"): |
| + return "This account already has an app (or a trashed app) named " + to + "." |
| + case strings.Contains(msg, "legacy owner grant"): |
| + return "One of these names carries a legacy grant — renaming across it is an operator act." |
| + default: |
| + return "Could not rename — nothing changed. Try again." |
| + } |
| +} |
| + |
| +// handleAppDelete puts the app in the trash: the deletion-policy marker in |
| +// the bucket, then this console's row — the same order and the same clock |
| +// discipline as the API door (handleAPITrashApp), behind the account-owner |
| +// ceremony: type the name, then the passkey. Restorable from the Trash for |
| +// the cooloff; hosts drop the app's routes on their next reconcile pass. |
| +func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| + } |
| + // Typing the name is a "did you mean this app?" control, not a security |
| + // one — checked before step-up so a slip does not cost a passkey touch. |
| + if strings.TrimSpace(r.FormValue("confirm")) != app { |
| + back("err", "Type the app's name exactly to delete it.") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", "No deployment bucket on this host — this console cannot delete an app.") |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second) |
| + defer cancel() |
| + // Production outranks deletion, and an unreadable flag IS production — |
| + // the deletion-policy reconciler will refuse the marker anyway, so |
| + // writing one would only manufacture disagreement. |
| + flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if err != nil { |
| + log.Printf("console: trash %s/%s: production flag: %v", acct.Sqid, app, err) |
| + back("err", "Could not read the production flag — an unreadable flag is treated as production, so deleting is refused. Re-declare it on the Releases page, then try again.") |
| + return |
| + } |
| + if flag != nil && flag.Production { |
| + back("err", app+" is flagged production, so deleting is refused. Turn the supply-chain bake off first (Releases page — owner, passkey, and a hold on every box), then try again.") |
| + return |
| + } |
| + now := time.Now() |
| + if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil { |
| + log.Printf("console: trash %s/%s: %v", acct.Sqid, app, err) |
| + back("err", "Could not write the trash marker — nothing was deleted. Try again.") |
| + return |
| + } |
| + // The marker's clock is the feature's one clock: TrashApp is create-only, |
| + // so a racing or repeated delete keeps the ORIGINAL DeletedAt, and the |
| + // row must carry that, not this request's time. |
| + deletedAt := now |
| + if tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app); err == nil && tomb != nil { |
| + deletedAt = tomb.DeletedAt |
| + } |
| + if err := s.store.TrashAppRow(acct.ID, app, deletedAt); err != nil { |
| + log.Printf("console: trash row %s/%s: %v", acct.Sqid, app, err) |
| + back("err", "The trash marker was written, but this console's record did not update — reload; if the app still shows, try deleting again.") |
| + return |
| + } |
| + s.audit(acct, app, addr, "trash", "moved "+app+" to the trash") |
| + s.setFlash(w, "ok", "Moved "+app+" to the trash — restorable there until "+ |
| + deletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02")+"; hosts stop serving it on their next pass.") |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) |
| +} |
| + |
| +// handleAppMoveAccount is the repair half of an account move: when the |
| +// app's routes already say another account owns it (AccountMismatch), this |
| +// makes the console's record agree — `console migrate-app`, from the |
| +// browser, for the owner standing in both accounts. The target comes from |
| +// the ROUTES, never the form: this door re-homes the record to where the |
| +// platform already deploys from, and nowhere else. |
| +func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther) |
| + } |
| + target, err := s.routeAccountFor(app) |
| + if err != nil { |
| + back("err", "Could not read the routes — nothing was moved. Try again.") |
| + return |
| + } |
| + if target == "" || target == acct.Sqid { |
| + back("err", "This app's routes agree with this account — there is nothing to move.") |
| + return |
| + } |
| + dest, err := s.store.AccountBySqid(target) |
| + if err != nil || dest == nil { |
| + back("err", "The routes name account "+target+", which this console does not know — that is an operator repair.") |
| + return |
| + } |
| + if drole, err := s.store.Role(dest.ID, addr); err != nil || drole == "" { |
| + back("err", "You are not a member of "+dest.Name+" ("+target+"), so moving the record there would strand the app for you. Ask an owner of that account, or an operator, to finish the move.") |
| + return |
| + } |
| + if !confirmed(r) { |
| + cv := &ConfirmView{ |
| + Title: "Move " + app + " to " + dest.Name + "?", |
| + Lines: []string{ |
| + "This app's hosts already read releases from " + dest.Name + " (" + target + ") — the console's own record is what still says " + acct.Name + ".", |
| + "Moving makes them agree: the app's page, promotes and activity live under " + dest.Name + " from here on.", |
| + }, |
| + Note: "You'll confirm it's really you with your passkey.", |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/move", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, |
| + }, |
| + Button: "Move to " + dest.Name, |
| + CancelTab: "settings", |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + if err := s.store.MoveApp(app, acct.ID, dest.ID); err != nil { |
| + log.Printf("console: move %s %s -> %s: %v", app, acct.Sqid, target, err) |
| + back("err", "Could not move the record — nothing changed. "+moveAdvice(err, dest.Name)) |
| + return |
| + } |
| + s.audit(acct, app, addr, "move", "moved "+app+" to account "+dest.Name+" ("+target+")") |
| + if err := s.store.RecordEvent(dest.ID, app, addr, "move", "arrived from account "+acct.Name+" ("+acct.Sqid+")", time.Now()); err != nil { |
| + log.Printf("console: move event %s: %v", app, err) |
| + } |
| + s.setFlash(w, "ok", "Moved "+app+" to "+dest.Name+" — console and routes agree again.") |
| + http.Redirect(w, r, "/a/"+url.PathEscape(dest.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| +} |
| + |
| +// moveAdvice maps MoveApp's refusals to actionable text. |
| +func moveAdvice(err error, destName string) string { |
| + if strings.Contains(err.Error(), "already claims") { |
| + return destName + " already has a different app with this name." |
| + } |
| + return "Try again." |
| +} |
| + |
| +// routeAccountFor reads what the ROUTES say the app's account is — the same |
| +// rule Assemble applies (first non-platform route's stamped account), read |
| +// fresh at action time so the move never trusts a stale page. |
| +func (s *Server) routeAccountFor(app string) (string, error) { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return "", err |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + return "", err |
| + } |
| + for _, rt := range routes { |
| + if rt.App == app && !rt.Platform { |
| + return release.RouteAccount(rt), nil |
| + } |
| + } |
| + return "", nil |
| +} |
diff --git a/internal/console/confirm.go b/internal/console/confirm.go| new file mode 100644 |
| index 000000000..62d496227 |
| --- /dev/null |
| +++ b/internal/console/confirm.go |
| @@ -0,0 +1,228 @@ |
| +// The action confirm step: a server-rendered interstitial between clicking a |
| +// button that changes what serves and it happening. One page, one card: what |
| +// is about to happen in plain sentences, one button that does it, one link |
| +// that does not. |
| +// |
| +// This is a UI affordance, not a gate — the security gates (session, CSRF, |
| +// owner 404, requireFreshAuth) neither move nor weaken for it, and a |
| +// hand-written POST with confirmed=1 meets exactly the doors a button does. |
| +// Placement inside each handler is therefore load-bearing: |
| +// |
| +// - AFTER CSRF and input validation, so the card describes a request that |
| +// would otherwise proceed, never an invalid one. |
| +// - AFTER any owner-only 404, never before it: a member probing an |
| +// owner-only action must meet the byte-identical 404, not a confirm card |
| +// that concedes the action exists for somebody. |
| +// - BEFORE requireFreshAuth, so the passkey round trip replays the POST |
| +// confirmed — confirm asks "do you mean it?", step-up asks "is it you?", |
| +// in that order, each exactly once. |
| +package console |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "strconv" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConfirmView is everything the interstitial renders. |
| +type ConfirmView struct { |
| + Title string |
| + // Lines are the card's sentences: current state, then what changes. |
| + Lines []string |
| + // Warn is the one red line, for the part that must not be skimmed past. |
| + Warn string |
| + // Note is the quiet line after the button — what happens next (a bake |
| + // window, a passkey prompt), muted. |
| + Note string |
| + // Action is the POST target; Fields the hidden name/value pairs carried |
| + // through, csrf included. The confirmed=1 marker is the template's. |
| + Action string |
| + Fields [][2]string |
| + // Checkbox, optional: a real choice that belongs at confirm time (the |
| + // promote-to-stable bake opt-in). Rendered only when Name is non-empty. |
| + CheckboxName string |
| + CheckboxLabel string |
| + CheckboxChecked bool |
| + Button string |
| + Danger bool |
| + // CancelTab is which app tab the cancel link lands on ("" = Overview); |
| + // renderConfirm derives the URL from it, since only the assembled page |
| + // knows the account's link segment. |
| + CancelTab string |
| + CancelURL string |
| +} |
| + |
| +// confirmed reports whether this POST already carries the confirm step's |
| +// approval. The zero value of a form nobody edited is absence, so any other |
| +// value stays unconfirmed. |
| +func confirmed(r *http.Request) bool { return r.FormValue("confirmed") == "1" } |
| + |
| +// renderConfirm renders the interstitial with the account chrome, titled by |
| +// the action. 200, not a redirect: the card IS this POST's response, and the |
| +// resubmit it hosts is a fresh POST of the same fields plus confirmed=1. |
| +func (s *Server) renderConfirm(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app string, cv *ConfirmView) { |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = cv.Title |
| + p.AppName = app |
| + p.ActiveNav = "apps" |
| + if cv.CancelURL == "" { |
| + cv.CancelURL = p.Nav + "/apps/" + url.PathEscape(app) |
| + if cv.CancelTab != "" { |
| + cv.CancelURL += "/" + cv.CancelTab |
| + } |
| + } |
| + p.Confirm = cv |
| + s.render(w, http.StatusOK, "confirm_action", p) |
| +} |
| + |
| +// backTab maps the action forms' back field onto the tab a cancel link (and |
| +// nothing else) should land on. Only values the redirects themselves honor. |
| +func backTab(r *http.Request) string { |
| + if r.FormValue("back") == "releases" { |
| + return "releases" |
| + } |
| + return "" |
| +} |
| + |
| +// holdText renders a bake hold the way the card speaks: "~24h". |
| +func holdText(d time.Duration) string { |
| + return "~" + strconv.Itoa(int(d.Hours())) + "h" |
| +} |
| + |
| +// confirmPromote is the promote door's card. Everything here is best-effort |
| +// reading for the card's sentences — the confirmed POST re-validates from |
| +// scratch, so a read that fails here costs precision, never safety. |
| +func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, version string) { |
| + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| + defer cancel() |
| + cv := &ConfirmView{ |
| + Title: "Promote " + version + " to " + channel + "?", |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/promote", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, {"channel", channel}, {"version", version}, {"back", r.FormValue("back")}, |
| + }, |
| + Button: "Promote " + version, |
| + CancelTab: backTab(r), |
| + } |
| + cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) |
| + switch { |
| + case errors.Is(err, os.ErrNotExist): |
| + cv.Lines = append(cv.Lines, "This creates channel "+channel+", serving "+version+" as soon as a host follows it.") |
| + case err != nil: |
| + cv.Lines = append(cv.Lines, "After this, "+channel+" serves "+version+".") |
| + case cur.Version == version: |
| + cv.Lines = append(cv.Lines, channel+" already serves "+version+" — confirming changes nothing.") |
| + default: |
| + cv.Lines = append(cv.Lines, |
| + channel+" serves "+cur.Version+" now.", |
| + "After this, it serves "+version+".") |
| + } |
| + rung, _ := release.Rung(channel) |
| + stable := rung == release.RungStable |
| + if stable { |
| + cv.Warn = "Stable is what every user of " + app + " gets — this puts " + version + " in front of everyone." |
| + } |
| + // Whether the promotion bakes, and whether to offer turning the bake on: |
| + // the same flag logic the confirmed POST applies, read here only to make |
| + // the card tell the truth about what happens next. |
| + flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + baking := ferr != nil || (flag != nil && flag.Production) |
| + if hold := release.HoldFor(release.DefaultHolds, channel); hold > 0 && baking { |
| + cv.Lines = append(cv.Lines, "This app bakes: each host holds "+version+" for "+holdText(hold)+" on "+channel+" before adopting it.") |
| + } |
| + if stable && ferr == nil && !baking { |
| + cv.CheckboxName = "enable_bake" |
| + cv.CheckboxChecked = true |
| + cv.CheckboxLabel = "This app is in production — enable the supply-chain bake (recommended)" |
| + } |
| + if stable { |
| + cv.Note = "You'll confirm it's really you with your passkey." |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| +} |
| + |
| +// confirmRollback: the card knows both versions because the handler read the |
| +// pointer before asking — a rollback card that cannot say where it is going |
| +// has no business asking anyone to confirm it. |
| +func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel, cur, target string) { |
| + cv := &ConfirmView{ |
| + Title: "Roll back " + channel + " to " + target + "?", |
| + Lines: []string{ |
| + channel + " serves " + cur + " now.", |
| + "Rolling back re-points it to " + target + ", the version before this one.", |
| + }, |
| + Note: "Rollback never bakes — hosts adopt on their next pass.", |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, {"channel", channel}, {"back", r.FormValue("back")}, |
| + }, |
| + Button: "Roll back to " + target, |
| + Danger: true, |
| + CancelTab: backTab(r), |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| +} |
| + |
| +// confirmFastTrack reads the channel best-effort for the card's sentences; |
| +// the confirmed POST re-validates (and still refuses a channel with no bake |
| +// window) from scratch. |
| +func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, channel string) { |
| + ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) |
| + defer cancel() |
| + cv := &ConfirmView{ |
| + Title: "Fast-track " + channel + "?", |
| + Warn: "The bake window is the supply-chain guard — waiving it ships a build that hasn't finished proving itself.", |
| + Note: "You'll confirm it's really you with your passkey. The pointer wears the fast-track badge permanently.", |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/fasttrack", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, {"channel", channel}, |
| + }, |
| + Button: "Fast-track", |
| + Danger: true, |
| + } |
| + if cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel); err == nil { |
| + cv.Title = "Fast-track " + cur.Version + " on " + channel + "?" |
| + cv.Lines = append(cv.Lines, |
| + channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its bake window.", |
| + "Fast-tracking waives the rest of the window: hosts adopt "+cur.Version+" on their next pass.") |
| + cv.Button = "Fast-track " + cur.Version |
| + } else { |
| + cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s bake window: hosts adopt its current version on their next pass.") |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| +} |
| + |
| +// confirmRetireCanary: the handler has already established the canary exists |
| +// and nothing follows it, so the card states exactly those two facts. |
| +func (s *Server) confirmRetireCanary(w http.ResponseWriter, r *http.Request, addr, raw string, acct *Account, role, app, name string) { |
| + cv := &ConfirmView{ |
| + Title: "Retire canary " + name + "?", |
| + Lines: []string{ |
| + "Deletes channel canary/" + name + " — the pointer, not any shipped release.", |
| + "No host follows it, so nothing serving changes.", |
| + }, |
| + Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/canary/retire", |
| + Fields: [][2]string{ |
| + {"csrf", raw}, {"name", name}, {"back", r.FormValue("back")}, |
| + }, |
| + Button: "Retire " + name, |
| + Danger: true, |
| + CancelTab: backTab(r), |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| +} |
| + |
| +// ago mirrors the template helper of the same name for the card builders' |
| +// sentences: humanAge's coarse resolution, dashing out the zero time. |
| +func ago(t time.Time) string { |
| + if t.IsZero() { |
| + return "—" |
| + } |
| + return humanAge(time.Since(t)) + " ago" |
| +} |
diff --git a/internal/console/icons.go b/internal/console/icons.go| index 4d6128bd8..ff349519f 100644 |
| --- a/internal/console/icons.go |
| +++ b/internal/console/icons.go |
| @@ -96,6 +96,17 @@ const ( |
| `<path d="M5 22h14"/><path d="M5 2h14"/>` + |
| `<path d="M17 22v-4.172a2 2 0 0 0-.586-1.414L12 12l-4.414 4.414A2 2 0 0 0 7 17.828V22"/>` + |
| `<path d="M7 2v4.172a2 2 0 0 0 .586 1.414L12 12l4.414-4.414A2 2 0 0 0 17 6.172V2"/></svg>` |
| + |
| + // A pointer that arrived by hotfix — the ladder was bypassed, and the |
| + // badge is permanent (Lucide: zap). |
| + iconZap template.HTML = iconOpen + |
| + `<path d="M4 14a1 1 0 0 1-.78-1.63l9.9-10.2a.5.5 0 0 1 .86.46l-1.92 6.02A1 1 0 0 0 13 10h7a1 1 0 0 1 .78 1.63l-9.9 10.2a.5.5 0 0 1-.86-.46l1.92-6.02A1 1 0 0 0 11 14z"/></svg>` |
| + |
| + // A pointer whose bake window was waived — fast-tracked, badge equally |
| + // permanent (Lucide: fast-forward). |
| + iconFastForward template.HTML = iconOpen + |
| + `<polygon points="13 19 22 12 13 5 13 19"/>` + |
| + `<polygon points="2 19 11 12 2 5 2 19"/></svg>` |
| ) |
| // icons maps each Lucide slug to its vendored markup. Slugs are the Lucide |
| @@ -107,6 +118,8 @@ var icons = map[string]template.HTML{ |
| "arrow-down": iconArrowDown, |
| "flask-conical": iconFlask, |
| "triangle-alert": iconTriangleAlert, |
| + "zap": iconZap, |
| + "fast-forward": iconFastForward, |
| "layers": iconLayers, |
| "hourglass": iconHourglass, |
| } |
diff --git a/internal/console/metricsview.go b/internal/console/metricsview.go| index be27dcb31..7ee92eb31 100644 |
| --- a/internal/console/metricsview.go |
| +++ b/internal/console/metricsview.go |
| @@ -80,6 +80,18 @@ const metricsFreshWindow = 5 * time.Minute |
| // prefix and reads the path segment right after "boxes/". |
| const metricsBoxesPrefix = "metrics/boxes/" |
| +// metricsSweepBoxes and metricsSweepReads bound how much of a sweep is in |
| +// flight at once: boxes swept in parallel, and hourly chunks read in parallel |
| +// within a box. A cold sweep is boxes x 48 round trips to the bucket and is |
| +// latency-bound, not CPU-bound — serially that is the whole "Instances page |
| +// is slow" complaint — but it is still someone else's rate limit at the far |
| +// end, so the fan-out is bounded rather than "one goroutine per key". The |
| +// worst case is metricsSweepBoxes x metricsSweepReads requests outstanding. |
| +const ( |
| + metricsSweepBoxes = 4 |
| + metricsSweepReads = 12 |
| +) |
| + |
| // metricsSweepTimeout bounds one whole bucket sweep: a List plus 48 Gets per |
| // box plus the alerts pass. It exists because the sweep no longer runs on the |
| // requesting page's context (see LoadMetrics) and so has no deadline of its |
| @@ -194,9 +206,16 @@ func (s *Server) LoadMetrics(ctx context.Context) ([]BoxMetricsView, []AlertRow, |
| } |
| // sweepAll is the bucket half of LoadMetrics: discovery, every box's 48h raw |
| -// sweep, then the alerts pass, on a context detached from the caller's |
| -// (WithoutCancel) and bounded by metricsSweepTimeout. It holds no lock — see |
| -// LoadMetrics's doc comment for why that is the point. |
| +// sweep (metricsSweepBoxes of them at a time), then the alerts pass, on a |
| +// context detached from the caller's (WithoutCancel) and bounded by |
| +// metricsSweepTimeout. It holds no lock — see LoadMetrics's doc comment for |
| +// why that is the point. |
| +// |
| +// Boxes are swept concurrently but land in boxNames order, never completion |
| +// order: the dashboard's row order must not depend on which box's bucket |
| +// answered first. Any box failing fails the whole sweep, with the |
| +// lowest-ordered box's error — the same error the serial sweep returned, so a |
| +// flaky box reads the same in the logs however the reads interleaved. |
| func (s *Server) sweepAll(ctx context.Context) ([]BoxMetricsView, []AlertRow, error) { |
| sweepCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), metricsSweepTimeout) |
| defer cancel() |
| @@ -206,13 +225,43 @@ func (s *Server) sweepAll(ctx context.Context) ([]BoxMetricsView, []AlertRow, er |
| return nil, nil, err |
| } |
| - views := make([]BoxMetricsView, 0, len(boxNames)) |
| - for _, box := range boxNames { |
| - view, err := s.sweepBox(sweepCtx, box) |
| + views := make([]BoxMetricsView, len(boxNames)) |
| + errs := make([]error, len(boxNames)) |
| + var ( |
| + wg sync.WaitGroup |
| + sem = make(chan struct{}, metricsSweepBoxes) |
| + once sync.Once |
| + failed = make(chan struct{}) |
| + ) |
| + for i, box := range boxNames { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + select { |
| + case sem <- struct{}{}: |
| + case <-failed: |
| + // Another box has already failed the sweep; nothing this one |
| + // could read would be returned. Boxes already at the bucket |
| + // still finish — they run on sweepCtx, so the error a failed |
| + // sweep reports is always a box's own, never one this |
| + // function inflicted. |
| + return |
| + } |
| + defer func() { <-sem }() |
| + view, err := s.sweepBox(sweepCtx, box) |
| + if err != nil { |
| + errs[i] = err |
| + once.Do(func() { close(failed) }) |
| + return |
| + } |
| + views[i] = view |
| + }() |
| + } |
| + wg.Wait() |
| + for _, err := range errs { |
| if err != nil { |
| return nil, nil, err |
| } |
| - views = append(views, view) |
| } |
| alerts, err := s.sweepAlerts(sweepCtx) |
| @@ -248,29 +297,66 @@ func (s *Server) discoverBoxes(ctx context.Context) ([]string, error) { |
| return boxes, nil |
| } |
| -// sweepBox reads box's raw chunks for the last 48 UTC hours and assembles |
| -// its BoxMetricsView. A missing hour (os.ErrNotExist) or an unparsable chunk |
| -// is skipped silently — a box that has been up for under 48h, or whose |
| -// agent hiccuped on one hour, still renders on what it does have. Any other |
| -// read error fails the whole sweep (and so is never cached). |
| +// sweepBox reads box's raw chunks for the last 48 UTC hours, metricsSweepReads |
| +// at a time, and assembles its BoxMetricsView. A missing hour (os.ErrNotExist) |
| +// or an unparsable chunk is skipped silently — a box that has been up for |
| +// under 48h, or whose agent hiccuped on one hour, still renders on what it |
| +// does have. Any other read error fails the whole sweep (and so is never |
| +// cached), reporting the newest failing hour's error as the serial read did. |
| +// |
| +// Each hour lands in its own slot and the slots are concatenated in hour |
| +// order before sorting, because the sort below is NOT stable: samples sharing |
| +// a timestamp keep whatever relative order they were appended in, so appending |
| +// as the reads complete would let a box's current reading flap between |
| +// renders. |
| func (s *Server) sweepBox(ctx context.Context, box string) (BoxMetricsView, error) { |
| now := time.Now().UTC() |
| - var samples []metrics.Sample |
| - for i := 0; i < metricsSweepHours; i++ { |
| - hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour)) |
| - key := metrics.RawKey(box, hour) |
| - body, _, err := s.cfg.Releases.Get(ctx, key) |
| - if err != nil { |
| - if errors.Is(err, os.ErrNotExist) { |
| - continue |
| + hourly := make([][]metrics.Sample, metricsSweepHours) |
| + errs := make([]error, metricsSweepHours) |
| + var ( |
| + wg sync.WaitGroup |
| + sem = make(chan struct{}, metricsSweepReads) |
| + once sync.Once |
| + failed = make(chan struct{}) |
| + ) |
| + for i := range metricsSweepHours { |
| + wg.Add(1) |
| + go func() { |
| + defer wg.Done() |
| + select { |
| + case sem <- struct{}{}: |
| + case <-failed: |
| + return // an earlier hour already failed the sweep |
| } |
| - return BoxMetricsView{}, fmt.Errorf("read %s: %w", key, err) |
| - } |
| - var chunk metrics.RawChunk |
| - if err := json.Unmarshal(body, &chunk); err != nil { |
| - continue // unparsable chunk: skip, don't fail the sweep |
| + defer func() { <-sem }() |
| + hour := metrics.HourOf(now.Add(-time.Duration(i) * time.Hour)) |
| + key := metrics.RawKey(box, hour) |
| + body, _, err := s.cfg.Releases.Get(ctx, key) |
| + if err != nil { |
| + if errors.Is(err, os.ErrNotExist) { |
| + return |
| + } |
| + errs[i] = fmt.Errorf("read %s: %w", key, err) |
| + once.Do(func() { close(failed) }) |
| + return |
| + } |
| + var chunk metrics.RawChunk |
| + if err := json.Unmarshal(body, &chunk); err != nil { |
| + return // unparsable chunk: skip, don't fail the sweep |
| + } |
| + hourly[i] = chunk.Samples |
| + }() |
| + } |
| + wg.Wait() |
| + for _, err := range errs { |
| + if err != nil { |
| + return BoxMetricsView{}, err |
| } |
| - samples = append(samples, chunk.Samples...) |
| + } |
| + |
| + var samples []metrics.Sample |
| + for _, h := range hourly { |
| + samples = append(samples, h...) |
| } |
| sort.Slice(samples, func(i, j int) bool { return samples[i].At.Before(samples[j].At) }) |
| return buildBoxView(box, samples), nil |
diff --git a/internal/console/overview.go b/internal/console/overview.go| new file mode 100644 |
| index 000000000..bdeaef9f2 |
| --- /dev/null |
| +++ b/internal/console/overview.go |
| @@ -0,0 +1,159 @@ |
| +// The app Overview dashboard's numbers, and the Instances tab's one table. |
| +// Both are joins over reads the app pages already do — AppView (routes + |
| +// channels), InstanceMetrics (the shared metrics sweep) and VersionView — |
| +// assembled here in Go so the templates render facts, never compute them. |
| +package console |
| + |
| +import ( |
| + "strings" |
| + "time" |
| +) |
| + |
| +// OverviewStats is the Overview dashboard: the handful of numbers an owner |
| +// checks before anything else. Every field is derived from platform state the |
| +// page was already reading — this struct adds no bucket or registry I/O. |
| +type OverviewStats struct { |
| + // What stable serves. HasStable false means the app has never been |
| + // promoted to stable — the dashboard's honest "not live yet" state. |
| + // LiveErr carries an unreadable stable pointer's error text: unknown, |
| + // which is not the same as not live. |
| + HasStable bool |
| + LiveVersion string |
| + LivePromotedAt time.Time |
| + LiveActor string |
| + LiveErr string |
| + |
| + // The app's instances (routes owned by the viewed account), and how many |
| + // of them the last metrics sweep actually saw. Asleep counts hibernating |
| + // non-reporters — expected quiet, not missing. |
| + InstanceTotal int |
| + InstanceReporting int |
| + InstanceAsleep int |
| + |
| + // Current resource use summed across every reporting instance. Nil when |
| + // nothing reports — the tiles render the em-dash, never zeros. |
| + CPUSum *float64 |
| + MemSum *int64 |
| + |
| + // Domains, and how many need someone's attention: a custom domain not |
| + // pointing here yet, or a cert that is not ready. |
| + DomainTotal int |
| + DomainAttention int |
| + |
| + // Shipped releases. The versions list is capped (versionsCap), so |
| + // ReleasesCapped says "20+" rather than lying with "20". |
| + ReleaseCount int |
| + ReleasesCapped bool |
| + LatestVersion string |
| +} |
| + |
| +// assembleOverview derives the dashboard from what handleApp already loaded. |
| +// metrics may be nil (a failed sweep costs the CPU/memory tiles, never the |
| +// page); versions may be nil the same way. |
| +func assembleOverview(view *AppView, metrics []InstanceMetrics, versions []VersionView) OverviewStats { |
| + st := OverviewStats{} |
| + for _, ch := range view.Channels { |
| + if ch.Name != "stable" { |
| + continue |
| + } |
| + if ch.Err != "" { |
| + st.LiveErr = ch.Err |
| + break |
| + } |
| + st.HasStable = true |
| + st.LiveVersion = ch.Version |
| + st.LivePromotedAt = ch.PromotedAt |
| + st.LiveActor = ch.Actor |
| + break |
| + } |
| + for _, m := range metrics { |
| + st.InstanceTotal++ |
| + if m.Reporting() { |
| + st.InstanceReporting++ |
| + if m.CPUFrac != nil { |
| + if st.CPUSum == nil { |
| + st.CPUSum = new(float64) |
| + } |
| + *st.CPUSum += *m.CPUFrac |
| + } |
| + if m.MemBytes != nil { |
| + if st.MemSum == nil { |
| + st.MemSum = new(int64) |
| + } |
| + *st.MemSum += *m.MemBytes |
| + } |
| + } else if m.Hibernate { |
| + st.InstanceAsleep++ |
| + } |
| + } |
| + st.DomainTotal = len(view.Domains) |
| + for _, d := range view.Domains { |
| + if domainNeedsAttention(d) { |
| + st.DomainAttention++ |
| + } |
| + } |
| + st.ReleaseCount = len(versions) |
| + st.ReleasesCapped = len(versions) == versionsCap |
| + if len(versions) > 0 { |
| + st.LatestVersion = versions[0].Version |
| + } |
| + return st |
| +} |
| + |
| +// domainNeedsAttention is the dashboard's one-bit read of a Domains row: |
| +// guidance text means DNS isn't pointing here, and a TLS status other than |
| +// ready/issuing means issuance failed. Issuing is patience, not attention. |
| +func domainNeedsAttention(d DomainView) bool { |
| + if d.Guidance != "" { |
| + return true |
| + } |
| + switch { |
| + case d.TLS == "", strings.HasPrefix(d.TLS, "ready"), strings.HasPrefix(d.TLS, "issuing"): |
| + return false |
| + } |
| + return true |
| +} |
| + |
| +// InstanceRow is one line of the Instances tab's table: a route joined with |
| +// its readings from the shared sweep. The join key is the host — the one name |
| +// owners have for an instance. |
| +type InstanceRow struct { |
| + Host string |
| + Channel string |
| + Version string // what the route reports it is running, "" unknown |
| + Kind string |
| + Status string // registry status; "" means ready |
| + Hibernate bool |
| + Metrics InstanceMetrics // zero-valued (Reporting false) when unswept |
| +} |
| + |
| +// assembleInstanceRows joins the app's routes with the sweep's readings, in |
| +// host order (the routes' own order). Only routes stamped with the viewed |
| +// account join — the same tenant boundary appResourceMetrics draws, kept |
| +// byte-for-byte: rows here and metrics there must cover the same hosts. |
| +func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetrics) []InstanceRow { |
| + byHost := make(map[string]InstanceMetrics, len(metrics)) |
| + for _, m := range metrics { |
| + byHost[m.Host] = m |
| + } |
| + rows := make([]InstanceRow, 0, len(view.Routes)) |
| + for _, rt := range view.Routes { |
| + if rt.Account != acct.Sqid { |
| + continue |
| + } |
| + status := rt.Status |
| + if status == "" { |
| + status = "ready" // registry contract: empty status means ready |
| + } |
| + rows = append(rows, InstanceRow{ |
| + Host: rt.Host, |
| + Channel: rt.Channel, |
| + Version: rt.Version, |
| + Kind: rt.Kind, |
| + Status: status, |
| + Hibernate: rt.Hibernate, |
| + Metrics: byHost[rt.Host], |
| + }) |
| + } |
| + return rows |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index c31e827de..f0208a93a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -32,6 +32,19 @@ var tmplFS embed.FS |
| var templates = template.Must(template.New("").Funcs(template.FuncMap{ |
| "fmtTime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04 UTC") }, |
| + // ago renders a past instant the way a person scanning a dashboard reads |
| + // one — "2h4m ago" — at humanAge's deliberately coarse resolution. The |
| + // absolute stamp stays available where precision matters (title attrs, |
| + // the Activity tab); a zero time is unknown, so it dashes out. |
| + "ago": func(t time.Time) string { |
| + if t.IsZero() { |
| + return "—" |
| + } |
| + return humanAge(time.Since(t)) + " ago" |
| + }, |
| + // trashCooloffDays is the deletion policy's restore window, in the unit |
| + // the Settings page speaks. One source: release.TrashCooloff. |
| + "trashCooloffDays": func() int { return int(release.TrashCooloff.Hours() / 24) }, |
| // The fleet template's em-dash helpers: fleet.json's null-propagation |
| // rule (internal/status/fleet.go) means these *string/*int/*bool fields |
| // are unknown, not zero, and unknown must render as a titled em-dash — |
| @@ -270,6 +283,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| + // The app Settings writes (appsettings.go): owner-only, and delete/move |
| + // behind passkey step-up besides. |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount) |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| // Owner-only account administration, every one of them behind passkey |
| @@ -387,6 +405,20 @@ type page struct { |
| // 12), one row per route host. Nil renders no section at all — metrics |
| // are decoration on this page and must never be the reason it fails. |
| AppMetrics []InstanceMetrics |
| + |
| + // App Overview: the dashboard's vital statistics (overview.go), derived |
| + // from reads the page does anyway. |
| + Overview OverviewStats |
| + |
| + // App Instances: the one table — routes joined with their readings. |
| + InstanceRows []InstanceRow |
| + |
| + // Action confirm interstitial (confirm.go): what is about to happen, |
| + // carried fields and all. Nil everywhere except the confirm page. |
| + Confirm *ConfirmView |
| + |
| + // App Settings (appsettings.go): what the tab knows beyond the AppView. |
| + Settings *SettingsView |
| } |
| // IsOwner is the template's one permission question. |
| @@ -1332,6 +1364,9 @@ func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Ac |
| return am |
| } |
| +// handleApp is the Overview dashboard: the vital statistics, from reads the |
| +// app pages already do — no self-report poll here, so the landing tab never |
| +// waits on a tenant's own HTTP endpoint. |
| func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.appPage(w, r, "overview") |
| if !ok { |
| @@ -1344,10 +1379,20 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) { |
| p.FlashErr = "Could not list this app's releases." |
| } |
| p.Versions = versions |
| - p.Instances = s.instances.Report(r.Context(), p.Detail.Routes) |
| + metrics := s.appResourceMetrics(r.Context(), p.Detail, acct) |
| + p.Overview = assembleOverview(p.Detail, metrics, versions) |
| + if events, err := s.store.Events(acct.ID, app, overviewActivityCap); err != nil { |
| + log.Printf("console: events %s: %v", app, err) |
| + } else { |
| + p.Events = events |
| + } |
| s.render(w, http.StatusOK, "app_overview", p) |
| } |
| +// overviewActivityCap bounds the Overview's recent-activity strip; the |
| +// Activity tab has the room this strip never will. |
| +const overviewActivityCap = 5 |
| + |
| // handleAppReleases is the deploy surface: every shipped release, the |
| // channels they promote onto, and the canaries riding ahead. |
| func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| @@ -1364,8 +1409,10 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app_releases", p) |
| } |
| -// handleAppInstances is what the app is actually running: its self-reported |
| -// instances, and each one's CPU/memory on the box it runs on. |
| +// handleAppInstances is what the app is actually running: one row per |
| +// instance — host, the channel it follows, what it is running right now, and |
| +// its readings — plus the app's own self-report below, for apps that manage |
| +// internal instances of their own. |
| func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| p, acct, ok := s.appPage(w, r, "instances") |
| if !ok { |
| @@ -1373,6 +1420,7 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| } |
| p.Instances = s.instances.Report(r.Context(), p.Detail.Routes) |
| p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| + p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| s.render(w, http.StatusOK, "app_instances", p) |
| } |
| @@ -1390,13 +1438,15 @@ func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app_activity", p) |
| } |
| -// handleAppSettings is the app's own record: identity, and its domains — |
| -// the two things about an app that aren't a deploy. |
| +// handleAppSettings is the app's own record and the controls over it: |
| +// identity (and renaming it, while that is still only a record), domains, |
| +// the account-move repair, and deletion. |
| func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) { |
| - p, _, ok := s.appPage(w, r, "settings") |
| + p, acct, ok := s.appPage(w, r, "settings") |
| if !ok { |
| return |
| } |
| + p.Settings = s.assembleSettings(r.Context(), &p, acct) |
| s.render(w, http.StatusOK, "app_settings", p) |
| } |
| @@ -1632,6 +1682,20 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| back("err", "Version "+version+" is not a shipped release of "+app+".") |
| return |
| } |
| + // The stable rung's owner gate fires BEFORE the confirm card: a member |
| + // probing an owner-only promote must meet the byte-identical 404, never a |
| + // card that concedes the action would otherwise proceed. promoteGate |
| + // re-checks this for owners on the confirmed POST; the duplication is the |
| + // point — the confirm step must sit strictly between owner 404 and |
| + // requireFreshAuth without weakening either. |
| + if rung, _ := release.Rung(channel); rung == release.RungStable && role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !confirmed(r) { |
| + s.confirmPromote(w, r, addr, raw, acct, role, app, channel, version) |
| + return |
| + } |
| // The rung's own gate, LAST — after membership, after the app check, after |
| // CSRF, and after every refusal that costs nothing. Order is load-bearing: |
| // a non-member must meet the byte-identical 404 before step-up is ever |
| @@ -1729,7 +1793,7 @@ func (s *Server) promoteGate(w http.ResponseWriter, r *http.Request, addr, raw, |
| // handleRollback swaps a channel back to its previous version. |
| func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -1767,6 +1831,10 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| target := cur.History[0] |
| + if !confirmed(r) { |
| + s.confirmRollback(w, r, addr, raw, acct, role, app, channel, cur.Version, target) |
| + return |
| + } |
| if err := release.Rollback(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel); err != nil { |
| log.Printf("console: rollback %s %s: %v", app, channel, err) |
| back("err", "Rollback failed: "+err.Error()) |
| @@ -1871,6 +1939,13 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| http.NotFound(w, r) |
| return |
| } |
| + // The confirm card sits strictly between the owner 404 and the passkey: |
| + // only owners ever see it, and the confirmed replay still meets |
| + // requireFreshAuth below. |
| + if !confirmed(r) { |
| + s.confirmFastTrack(w, r, addr, raw, acct, role, app, channel) |
| + return |
| + } |
| if !s.requireFreshAuth(w, r, addr, raw) { |
| return |
| } |
| @@ -2014,7 +2089,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) { |
| // handleRetireCanary removes a canary's channel pointer. Shipped releases |
| // are untouched — retirement un-points, it never deletes bits. |
| func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| - addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| if !ok { |
| return |
| } |
| @@ -2065,6 +2140,10 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| back("err", "Could not read the release store. Please try again.") |
| return |
| } |
| + if !confirmed(r) { |
| + s.confirmRetireCanary(w, r, addr, raw, acct, role, app, name) |
| + return |
| + } |
| if err := s.cfg.Releases.Delete(ctx, key); err != nil { |
| log.Printf("console: retire %s %s: %v", app, channel, err) |
| back("err", "Retiring the canary failed: "+err.Error()) |
diff --git a/internal/console/store.go b/internal/console/store.go| index e6d0fc23a..cc62b7ab2 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -1238,6 +1238,58 @@ func (s *Store) MoveApp(app string, from, to int64) error { |
| return tx.Commit() |
| } |
| +// RenameAppIn renames an app that has never deployed. The CALLER holds the |
| +// wider refusals — nothing under the app's bucket prefix, no registry route |
| +// wearing the name — because an app name is a literal key segment in both, |
| +// and this database is the only store a rename may touch. Here the refusals |
| +// are this database's own: |
| +// |
| +// - The new name must be free in this account, live or trashed — a trashed |
| +// row still owns its name until purged, exactly as CreateAppIn treats it. |
| +// - Neither name may appear in the legacy owners table. Those grants are |
| +// keyed by bare name across ALL accounts, so rewriting them could hand |
| +// one account's grant to another, and renaming ONTO one would let the new |
| +// name inherit a grant nobody issued it. Claim-proof stays claim-proof. |
| +// |
| +// Audit history follows the app: its story belongs to the app, not to a name |
| +// that no longer resolves. The rename itself is the caller's audit line. |
| +func (s *Store) RenameAppIn(accountID int64, from, to string) error { |
| + tx, err := s.db.Begin() |
| + if err != nil { |
| + return err |
| + } |
| + defer tx.Rollback() |
| + var legacy int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM owners WHERE app IN (?, ?)`, from, to).Scan(&legacy); err != nil { |
| + return err |
| + } |
| + if legacy > 0 { |
| + return fmt.Errorf("console: %q or %q carries a legacy owner grant — renaming across it is an operator act", from, to) |
| + } |
| + var taken int |
| + if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, accountID, to).Scan(&taken); err != nil { |
| + return err |
| + } |
| + if taken > 0 { |
| + return fmt.Errorf("console: this account already has an app (or a trashed app) named %q", to) |
| + } |
| + res, err := tx.Exec(`UPDATE apps SET app = ? WHERE account_id = ? AND app = ? AND deleted_at IS NULL`, |
| + to, accountID, from) |
| + if err != nil { |
| + return err |
| + } |
| + if n, err := res.RowsAffected(); err != nil { |
| + return err |
| + } else if n == 0 { |
| + return fmt.Errorf("console: this account does not claim %q", from) |
| + } |
| + if _, err := tx.Exec(`UPDATE audit SET app = ? WHERE account_id = ? AND app = ?`, |
| + to, accountID, from); err != nil { |
| + return err |
| + } |
| + return tx.Commit() |
| +} |
| + |
| // HasApp reports whether an app name is claimed in the apps table. |
| func (s *Store) HasApp(app string) (bool, error) { |
| var n int |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index 5463f5085..ef50d2681 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -3,10 +3,39 @@ |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| -{{if and .Instances .Instances.Reporting}} |
| <h2>Instances</h2> |
| -<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p> |
| -{{if .Instances.Instances}} |
| +{{if .InstanceRows}} |
| +<div class="tablewrap"> |
| +<table class="instances"> |
| + <tr><th>Instance</th><th>Channel</th><th>Running</th><th>Status</th><th>CPU</th><th>Memory</th></tr> |
| + {{range .InstanceRows}} |
| + <tr> |
| + <td><a href="https://{{.Host}}">{{.Host}}</a><span class="small"> · {{.Kind}}</span></td> |
| + <td><code>{{.Channel}}</code></td> |
| + <td>{{if .Version}}<code>{{.Version}}</code>{{else}}<span title="the host has not reported an adopted release yet">—</span>{{end}}</td> |
| + <td>{{if and .Hibernate (not .Metrics.Reporting)}}asleep{{else}}{{.Status}}{{end}}</td> |
| + {{if .Metrics.Reporting}} |
| + <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td> |
| + <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td> |
| + {{else}} |
| + <td colspan="2"><span class="small">{{if .Hibernate}}asleep — no recent readings{{else}}no per-instance readings{{end}}</span></td> |
| + {{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Running is the release each host reports it has adopted; |
| +sparklines cover the last 48 hours. The memory line is drawn against that |
| +instance's own peak.</p> |
| +{{else}} |
| +<p class="small">No instances yet — a host appears here when a route wires it |
| +to this app: <code>carlos add -app {{.Detail.App}} <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..affd9aad1 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -7,19 +7,73 @@ |
| <strong>This app is not deploying.</strong> Its hosts read releases from |
| account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to |
| <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches |
| - nothing. Its bucket objects were moved without the console's own record |
| - following. On the console's box, run: |
| - <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code> |
| - <br><span class="small">If it is the routes that are in the wrong account, |
| - move those back instead — whichever of the two is right, they have to |
| - agree.</span> |
| + nothing. Finish the move on this app's |
| + <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page. |
| + <span class="small">If it is the routes that are in the wrong account, move |
| + those back instead — whichever of the two is right, they have to agree.</span> |
| </p> |
| {{end}} |
| -{{template "ladder" .}} |
| -<p class="small"> |
| - <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a> |
| - {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}} |
| - · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a> |
| -</p> |
| +{{with .Overview}} |
| +<section class="tiles" aria-label="Vital statistics"> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases"> |
| + <h2>Live now</h2> |
| + {{if .HasStable}} |
| + <p class="tile-value"><code>{{.LiveVersion}}</code></p> |
| + <p class="tile-detail">on stable · promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</p> |
| + {{else if .LiveErr}} |
| + <p class="tile-value">—</p> |
| + <p class="tile-detail error">stable pointer unreadable</p> |
| + {{else}} |
| + <p class="tile-value">not live</p> |
| + <p class="tile-detail">nothing on stable yet</p> |
| + {{end}} |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| + <h2>Instances</h2> |
| + <p class="tile-value">{{.InstanceTotal}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}no hosts yet{{else}}{{.InstanceReporting}} reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| + <h2>CPU</h2> |
| + <p class="tile-value">{{pct .CPUSum}}</p> |
| + <p class="tile-detail">{{if .CPUSum}}across {{.InstanceReporting}} reporting{{else}}no recent readings{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| + <h2>Memory</h2> |
| + <p class="tile-value">{{bytesOrDash .MemSum}}</p> |
| + <p class="tile-detail">{{if .MemSum}}across {{.InstanceReporting}} reporting{{else}}no recent readings{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings"> |
| + <h2>Domains</h2> |
| + <p class="tile-value">{{.DomainTotal}}</p> |
| + <p class="tile-detail">{{if eq .DomainTotal 0}}the first route brings one{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need attention</span>{{else}}all pointing, TLS ready{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases"> |
| + <h2>Releases</h2> |
| + <p class="tile-value">{{.ReleaseCount}}{{if .ReleasesCapped}}+{{end}}</p> |
| + <p class="tile-detail">{{if .LatestVersion}}latest <code>{{.LatestVersion}}</code>{{else}}nothing shipped yet{{end}}</p> |
| + </a> |
| +</section> |
| +{{end}} |
| +{{with .Detail.Production}} |
| +{{if .Err}} |
| +<p class="small error">Supply-chain bake: flag unreadable — hosts keep baking. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a> page.</p> |
| +{{else if .On}} |
| +<p class="small">Supply-chain bake: <strong>on</strong> — releases hold on beta and stable before hosts adopt. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a>.</p> |
| +{{else if .TurningOff}} |
| +<p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock.</p> |
| +{{else}} |
| +<p class="small">Supply-chain bake: off — hosts adopt promoted releases immediately.{{if .Nudge}} {{icon "triangle-alert"}} This app looks production-wired — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a>.{{end}}</p> |
| +{{end}} |
| +{{end}} |
| +{{if .Events}} |
| +<h2>Recent activity</h2> |
| +<ul class="feed"> |
| + {{range .Events}} |
| + <li><span class="small" title="{{fmtTime .At}}">{{ago .At}}</span> {{.Actor}} — {{.Detail}}</li> |
| + {{end}} |
| +</ul> |
| +<p class="small"><a href="{{.Nav}}/apps/{{.AppName}}/activity">All activity</a></p> |
| +{{end}} |
| {{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 27dd4c509..b84554eb8 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -7,15 +7,13 @@ |
| <strong>This app is not deploying.</strong> Its hosts read releases from |
| account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to |
| <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches |
| - nothing. Its bucket objects were moved without the console's own record |
| - following. On the console's box, run: |
| - <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code> |
| - <br><span class="small">If it is the routes that are in the wrong account, |
| - move those back instead — whichever of the two is right, they have to |
| - agree.</span> |
| + nothing. Finish the move on this app's |
| + <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page. |
| </p> |
| {{end}} |
| +{{template "ladder" .}} |
| + |
| <h2>Channels</h2> |
| {{if .Detail.Channels}} |
| <div class="tablewrap"> |
| @@ -28,7 +26,7 @@ |
| <td colspan="3" class="error">{{.Err}}</td> |
| {{else}} |
| <td><code>{{.Version}}</code></td> |
| - <td>{{fmtTime .PromotedAt}}</td> |
| + <td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td> |
| <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td> |
| {{end}} |
| <td>{{if and $.CanPromote .HasHistory (not .Err)}} |
| @@ -36,14 +34,16 @@ |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="channel" value="{{.Name}}"> |
| <input type="hidden" name="back" value="releases"> |
| - <button type="submit" class="linkish danger">Roll back</button> |
| + <button type="submit" class="btn-sm btn-danger">Roll back</button> |
| </form> |
| {{end}}</td> |
| </tr> |
| {{end}} |
| </table> |
| </div> |
| -<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p> |
| +<p class="small">Bake times are an approximation from each channel's default |
| +hold; the authoritative clock is each box's own first-seen record. Rollback |
| +never bakes.</p> |
| {{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}} |
| <h2>Canaries</h2> |
| @@ -59,7 +59,7 @@ |
| <td colspan="3" class="error">{{.Err}}</td> |
| {{else}} |
| <td><code>{{.Version}}</code></td> |
| - <td>{{fmtTime .PromotedAt}}</td> |
| + <td><span title="{{fmtTime .PromotedAt}}">{{ago .PromotedAt}}</span></td> |
| <td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td> |
| {{end}} |
| <td>{{if $.CanPromote}} |
| @@ -67,7 +67,7 @@ |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="name" value="{{.Name}}"> |
| <input type="hidden" name="back" value="releases"> |
| - <button type="submit" class="linkish danger">Retire</button> |
| + <button type="submit" class="btn-sm btn-danger">Retire</button> |
| </form> |
| {{end}}</td> |
| </tr> |
| @@ -83,7 +83,7 @@ |
| <select name="version"> |
| {{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}} |
| </select> |
| - <button type="submit">Create canary</button> |
| + <button type="submit" class="btn-sm">Create canary</button> |
| </form> |
| <p class="small">Creates channel <code>canary/<name></code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/<name> <host></code>.</p> |
| {{end}} |
| @@ -111,7 +111,7 @@ |
| {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| </select> |
| <input type="text" name="new_channel" placeholder="or a new channel" size="14"> |
| - <button type="submit" class="linkish">Promote</button> |
| + <button type="submit" class="btn-sm">Promote</button> |
| </form> |
| </td>{{end}} |
| </tr> |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 02017bcf5..a299350a2 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -10,6 +10,23 @@ |
| <tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr> |
| </table> |
| </div> |
| +{{if .IsOwner}} |
| +{{if .Settings.CanRename}} |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="rename-to">New name</label> |
| + <input type="text" id="rename-to" name="to" placeholder="new-name" |
| + required pattern="[A-Za-z][A-Za-z0-9-]{0,31}" maxlength="32"> |
| + <button type="submit" class="btn-sm">Rename</button> |
| +</form> |
| +<p class="small">This app has never shipped, so its name is still only a |
| +record here — after the first ship, the name is wired into addresses and |
| +bucket keys and stays.</p> |
| +{{else}} |
| +<p class="small">Renaming: {{.Settings.RenameBlocked}}</p> |
| +{{end}} |
| +{{end}} |
| + |
| <h2>Domains & TLS</h2> |
| {{if .Detail.Domains}} |
| <div class="tablewrap"> |
| @@ -29,4 +46,56 @@ |
| </table> |
| </div> |
| {{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}} |
| +<h3>Add a custom domain</h3> |
| +<p class="small">A custom domain is attached where the app runs — on its box, |
| +by an operator:</p> |
| +<pre><code>carlos add -host www.example.com -app {{.Detail.App}} -account {{.Account.Sqid}} \ |
| + -channel stable ...</code></pre> |
| +<p class="small">Then point the domain's A record at this deployment's edge; |
| +the certificate is minted on the first request and renews on its own. |
| +Attaching a domain from this page, without the box, is on the platform's |
| +roadmap.</p> |
| + |
| +{{if .IsOwner}} |
| +{{if and .Settings .Settings.MismatchTarget}} |
| +<h2>Account</h2> |
| +{{if and .Settings.TargetKnown .Settings.ViewerInTarget}} |
| +<p>This app's hosts read releases from account |
| +<code>{{.Settings.MismatchTarget}}</code>, but the console's record says |
| +{{.Account.Name}} — so promotes from here reach nothing. Move the record to |
| +where the platform already deploys from:</p> |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/move"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit" class="btn-sm">Move to {{.Settings.MismatchTarget}}</button> |
| +</form> |
| +{{else}} |
| +<p class="small">This app's hosts read releases from account |
| +<code>{{.Settings.MismatchTarget}}</code>{{if not .Settings.TargetKnown}}, |
| +which this console does not know{{else}}, which you are not a member of{{end}} |
| +— finishing the move is an operator act: <code>console migrate-app -app |
| +{{.Detail.App}} -to {{.Settings.MismatchTarget}}</code> on the console's box.</p> |
| +{{end}} |
| +{{else}} |
| +<h2>Account</h2> |
| +<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a |
| +different account moves its bucket objects and re-stamps its routes — |
| +<code>carlos accounts migrate</code>, an operator act; once run, this page |
| +offers the final console-record step.</p> |
| +{{end}} |
| + |
| +<h2 class="danger-head">Delete this app</h2> |
| +<p class="small">Deleting puts {{.Detail.App}} in the account's |
| +<a href="{{.Nav}}/trash">trash</a>: hosts stop serving it on their next pass |
| +(custom domains included), and it is restorable for |
| +{{trashCooloffDays}} days before the platform destroys its releases for |
| +good.{{if .Detail.Production.On}} It is flagged production right now, which |
| +refuses deletion — turn the supply-chain bake off first.{{end}}</p> |
| +<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/delete" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="delete-confirm">Type the app's name to confirm</label> |
| + <input type="text" id="delete-confirm" name="confirm" placeholder="type {{.Detail.App}} to confirm" autocomplete="off" required> |
| + <button type="submit" class="btn-sm btn-danger">Delete {{.Detail.App}}</button> |
| +</form> |
| +<p class="small">Confirms with your passkey.</p> |
| +{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/confirm_action.html b/internal/console/templates/confirm_action.html| new file mode 100644 |
| index 000000000..4cd986c2c |
| --- /dev/null |
| +++ b/internal/console/templates/confirm_action.html |
| @@ -0,0 +1,23 @@ |
| +{{define "confirm_action"}}{{template "top" .}} |
| +<div class="pagehead"> |
| + <p class="eyebrow">Confirm · {{.AppName}}</p> |
| + <h1>{{.Confirm.Title}}</h1> |
| +</div> |
| +<div class="confirm-card"> |
| + {{range .Confirm.Lines}}<p>{{.}}</p>{{end}} |
| + {{if .Confirm.Warn}}<p class="error">{{.Confirm.Warn}}</p>{{end}} |
| + <form method="post" action="{{.Confirm.Action}}"> |
| + {{range .Confirm.Fields}}<input type="hidden" name="{{index . 0}}" value="{{index . 1}}">{{end}} |
| + <input type="hidden" name="confirmed" value="1"> |
| + {{if .Confirm.CheckboxName}} |
| + <p><label class="small"><input type="checkbox" name="{{.Confirm.CheckboxName}}" value="1"{{if .Confirm.CheckboxChecked}} checked{{end}}> |
| + {{.Confirm.CheckboxLabel}}</label></p> |
| + {{end}} |
| + <div class="actions"> |
| + <button type="submit"{{if .Confirm.Danger}} class="btn-danger"{{end}}>{{.Confirm.Button}}</button> |
| + <a href="{{.Confirm.CancelURL}}">Cancel</a> |
| + </div> |
| + </form> |
| + {{if .Confirm.Note}}<p class="small confirm-note">{{.Confirm.Note}}</p>{{end}} |
| +</div> |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html| index a89a4b18d..81be54174 100644 |
| --- a/internal/console/templates/ladder.html |
| +++ b/internal/console/templates/ladder.html |
| @@ -1,35 +1,33 @@ |
| {{define "ladder"}} |
| <h2>Ladder</h2> |
| -<p class="small">A channel is maturity, not a place: the same build climbs |
| -canary → edge → beta → stable, and a promotion moves a signed pointer without |
| -touching what any host is wired to.</p> |
| +<p class="small">The same build climbs canary → edge → beta → stable; a |
| +promotion moves a signed pointer, and hosts follow their channel.</p> |
| {{with .Detail.Production}} |
| <div class="bake"> |
| {{if .Err}} |
| - <p class="error">Production flag unreadable: {{.Err}} — hosts treat this app as in production and keep baking. |
| + <p class="small error">Supply-chain bake: flag unreadable — hosts keep baking. |
| {{if $.CanPromote}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="production" value="on"> |
| <button type="submit" class="linkish">Re-declare production</button> |
| </form> |
| - <span class="small">— writes a fresh signed flag over the unreadable one. Without this the app |
| - bakes forever; turning it off afterwards is the owner's, through the ordinary control.</span> |
| + — writes a fresh signed flag over the unreadable one. |
| {{end}} |
| </p> |
| {{else if .On}} |
| - <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h and stable 72h on each host before it adopts. |
| + <p class="small">Supply-chain bake: <strong>on</strong> — beta holds 24h, stable 72h, on each host's own clock. |
| {{if and $.CanPromote $.IsOwner}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="production" value="off"> |
| <button type="submit" class="linkish">Turn off</button> |
| </form> |
| - <span class="small">— confirms with your passkey; each host honors it after the stable window (~72h) on its own clock</span> |
| + <span title="Turning off confirms with your passkey; each host honors it after the stable window (~72h) on its own clock.">— passkey; honored after ~72h</span> |
| {{end}} |
| </p> |
| {{else if .TurningOff}} |
| - <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}, each on its own clock. |
| + <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}. |
| {{if $.CanPromote}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| @@ -39,7 +37,7 @@ touching what any host is wired to.</p> |
| {{end}} |
| </p> |
| {{else}} |
| - <p class="small">Supply-chain bake: <strong>off</strong> (default) — hosts adopt releases immediately. |
| + <p class="small">Supply-chain bake: off — hosts adopt promoted releases immediately. |
| {{if $.CanPromote}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| @@ -65,17 +63,16 @@ touching what any host is wired to.</p> |
| <p class="error"><code>{{.Channel}}</code> — {{.Err}}</p> |
| {{else}} |
| <p><code>{{.Channel}}</code> holds <code>{{.Version}}</code> |
| - <span class="small">promoted {{fmtTime .PromotedAt}} |
| - {{if .Actor}}by {{.Actor}}{{else}}— by whom is not recorded, on a pointer written before the trail existed{{end}} |
| - {{if .Hotfix}}· ladder bypassed (hotfix){{end}} |
| - {{if .FastTrack}}· fast-tracked past the bake window{{end}}</span></p> |
| + <span class="small" title="{{fmtTime .PromotedAt}}">promoted {{ago .PromotedAt}}{{if .Actor}} by {{.Actor}}{{end}}</span> |
| + {{if .Hotfix}}<span class="chip chip-stale">{{icon "zap"}} hotfix — ladder bypassed</span>{{end}} |
| + {{if .FastTrack}}<span class="chip chip-stale">{{icon "fast-forward"}} fast-tracked</span>{{end}}</p> |
| {{if and (not .BakedUntil.IsZero) $.CanPromote $.IsOwner}} |
| <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/fasttrack" class="inline"> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="channel" value="{{.Channel}}"> |
| - <button type="submit" class="linkish">Fast-track {{.Version}}</button> |
| + <button type="submit" class="btn-sm">Fast-track {{.Version}}</button> |
| </form> |
| - <span class="small">— in its bake window until ~{{fmtTime .BakedUntil}}; confirms with your passkey</span> |
| + <span class="small">baking until ~{{fmtTime .BakedUntil}}</span> |
| {{end}} |
| {{end}} |
| {{if .Routes}} |
| @@ -107,13 +104,8 @@ touching what any host is wired to.</p> |
| <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| <input type="hidden" name="channel" value="{{.Up.To}}"> |
| <input type="hidden" name="version" value="{{.Up.Version}}"> |
| - {{if and (eq .Up.To "stable") (not $.Detail.Production.On)}} |
| - <label class="small"><input type="checkbox" name="enable_bake" value="1" checked> |
| - This app is in production — enable the supply-chain bake (recommended)</label> |
| - {{end}} |
| - <button type="submit" class="linkish">Promote {{.Up.Version}} to {{.Up.To}}</button> |
| + <button type="submit" class="btn-sm">Promote {{.Up.Version}} to {{.Up.To}}</button> |
| </form> |
| - {{if .Up.StepUp}}<span class="small">— confirms with your passkey</span>{{end}} |
| {{else if .Up.Note}} |
| <span class="small">{{.Up.Note}}</span> |
| {{else}} |
| @@ -124,8 +116,4 @@ touching what any host is wired to.</p> |
| </li> |
| {{end}} |
| </ol> |
| -<p class="small">A canary is never refused by the ladder and never gates |
| -anything: it is where a change is proven before it is a candidate. Everything |
| -above edge is entered one rung at a time — the only bypass is |
| -<code>carlos promote -hotfix</code>, which says so on the pointer it moves.</p> |
| {{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 21494314e..41ea9a67d 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -113,6 +113,11 @@ |
| main { max-width: 64rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; } |
| h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; } |
| h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; } |
| + h3 { font-size: 1rem; margin: 1.5rem 0 0.5rem; } |
| + /* Settings: an input-and-button row, and the deletion section's heading |
| + in the ink that says be careful. */ |
| + .settings-row { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; margin: 0 0 0.75rem; } |
| + .danger-head { color: var(--bad); } |
| p { max-width: 40rem; margin: 0 0 1rem; } |
| .lede { font-size: 1.15rem; color: var(--muted); } |
| .small { font-size: 0.9rem; color: var(--muted); } |
| @@ -131,6 +136,10 @@ |
| table { border-collapse: collapse; width: 100%; margin: 0 0 1rem; font-size: 0.95rem; } |
| .tiles { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0 0 1.5rem; } |
| .tile { flex: 1 1 10rem; background: var(--accent-soft); border-radius: 0.6rem; padding: 1rem 1.1rem; } |
| + /* A tile that is also a link (the Overview dashboard): the tile itself is |
| + the affordance, so no underline and no accent ink — hover carries it. */ |
| + a.tile { color: var(--ink); text-decoration: none; } |
| + a.tile:hover { outline: 1px solid var(--accent); } |
| .tile h2 { |
| margin: 0 0 0.35rem; font-family: var(--mono); font-size: 0.78rem; text-transform: uppercase; |
| letter-spacing: 0.05em; color: var(--muted); font-weight: 600; |
| @@ -208,6 +217,32 @@ |
| } |
| button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); } |
| button.linkish.danger { color: var(--bad); } |
| + /* Actions that change what serves are BUTTONS, not links — small enough to |
| + sit inside a table row or a ladder rung, unmistakably a button all the |
| + same. Danger is filled red: rollbacks and retirements should not dress |
| + like promotions. */ |
| + .btn-sm, button.btn-sm { |
| + font-size: 0.85rem; padding: 0.35rem 0.8rem; border-radius: 0.4rem; |
| + } |
| + button.btn-danger, .btn-danger { background: var(--bad); color: var(--bg); } |
| + button.btn-danger:hover, .btn-danger:hover { background: var(--bad); filter: brightness(0.9); } |
| + /* The Instances table's in-cell readings: number first, then the 48h |
| + shape beside it, low-contrast so the number stays the headline. */ |
| + td .cell-value { display: inline-block; min-width: 3.5rem; font-weight: 600; } |
| + .cellspark { width: 6rem; height: 1.1rem; vertical-align: -0.2rem; color: var(--muted); } |
| + .cellspark polyline { stroke-width: 2.5; } |
| + /* The Overview's recent-activity strip. */ |
| + .feed { list-style: none; margin: 0 0 0.5rem; padding: 0; max-width: 40rem; } |
| + .feed li { padding: 0.25rem 0; border-bottom: 1px solid var(--rule); } |
| + .feed li:last-child { border-bottom: 0; } |
| + /* The confirm interstitial: one card saying exactly what is about to |
| + happen, one button that does it, one link that does not. */ |
| + .confirm-card { |
| + border: 1px solid var(--rule); border-radius: 0.6rem; |
| + padding: 1.25rem 1.4rem; max-width: 40rem; margin: 0 0 1rem; |
| + } |
| + .confirm-card .actions { display: flex; gap: 0.9rem; align-items: center; margin-top: 1rem; } |
| + .confirm-card .confirm-note { margin: 0.8rem 0 0; } |
| footer { |
| max-width: 64rem; margin: 0 auto; padding: 1.25rem 0 2rem; |
| border-top: 1px solid var(--rule); |