carlos / platform Public

Merge pull request #128 from carlosframework/console-clarity-batch

console: clarity batch — health verdict, safety delay, progressive reveal (P0s+P1s)
Paul Campbell a86de88cdeb5f5376db4cfb98b34af2488f4d62d parent 11670efparent b196007
27 files changed, +1912 −319
  • docs/superpowers/plans/2026-08-09-console-clarity-batch.md +993 −0
  • internal/console/apicli.go +3 −3
  • internal/console/appsettings.go +2 −2
  • internal/console/appsettings_test.go +1 −1
  • internal/console/bakecontrols_test.go +11 −11
  • internal/console/confirm.go +12 −12
  • internal/console/confirm_test.go +2 −2
  • internal/console/ladder.go +32 −24
  • internal/console/ladder_test.go +14 −13
  • internal/console/overview.go +95 −11
  • internal/console/overview_test.go +120 −15
  • internal/console/ownership.go +3 −3
  • internal/console/releasesummary.go +45 −0
  • internal/console/releasesummary_test.go +34 −0
  • internal/console/server.go +135 −64
  • internal/console/server_test.go +215 −29
  • internal/console/templates/app_instances.html +20 −17
  • internal/console/templates/app_overview.html +25 −31
  • internal/console/templates/app_releases.html +78 −50
  • internal/console/templates/app_settings.html +2 −2
  • internal/console/templates/appnav.html +2 −2
  • internal/console/templates/apps.html +5 −7
  • internal/console/templates/ladder.html +16 −16
  • internal/console/templates/layout.html +13 −2
  • internal/console/templates/trash.html +2 −2
  • internal/console/until_test.go +23 −0
  • internal/console/view.go +9 −0
diff --git a/docs/superpowers/plans/2026-08-09-console-clarity-batch.md b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md
new file mode 100644
index 000000000..e0d21fe8c
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md
@@ -0,0 +1,993 @@
+# Console Clarity Batch (P0s + P1s) Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make every console screen answer "what are my apps, what are they doing, is everything healthy" in plain language, with the technical layer (ladder, channels, bake mechanics) behind progressive reveals.
+
+**Architecture:** All copy and layout changes live in `internal/console/` (templates + the Go that assembles their view data — templates render facts, never compute them). Two small new computations: a health verdict derived from data the pages already load, and a release summary derived from the channels the Releases page already reads. Wire shapes, channel names, URL paths, and template `define` names are untouched.
+
+**Tech Stack:** Go server-rendered `html/template`, no JS dependencies, vendored Lucide icons via the existing `icon` funcmap helper.
+
+**Source of truth for the copy:** `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` (the critique this plan implements). Scope is its P0s + P1s only. Paul's decisions 2026-08-09: bake → "safety delay"; health verdict ships in this same batch.
+
+## Global Constraints
+
+- Channel names on the wire (`stable`, `beta`, `edge`, `canary/*`) are NEVER renamed — display copy only. `internal/release/laddercheck_test.go` pins production channel names; do not touch `internal/release/`.
+- URL paths are unchanged (`/apps/<app>/instances` stays even though the tab label becomes "Running") — bookmarks and tests depend on them.
+- Template `define` names are unchanged (`app_instances`, `ladder`, etc.) — `render()` calls them by name.
+- The em-dash discipline stands: never render a guessed zero; unknown is `—` or an explicit "can't tell" word.
+- Chip grammar stands (layout.html comments): bordered = a fact worth noticing; filled = must-not-skim. `chip-prod` stays the only filled red chip. New health chips: `Needs attention` may be filled amber; `All good` is bordered.
+- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, plus `GOOS=darwin go build ./...`, plus `gofmt -l` clean for touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled.
+- Copy strings below are exact — implement them verbatim. Where a test asserts an old string, update the test in the same task (grep `internal/console/*_test.go` for the old string before assuming there isn't one).
+- Work happens in this worktree on branch `console-clarity-batch`.
+
+---
+
+### Task 1: Health verdict computation (overview.go)
+
+**Files:**
+- Modify: `internal/console/overview.go`
+- Test: `internal/console/overview_test.go`
+
+**Interfaces:**
+- Consumes: `OverviewStats` fields already assembled by `assembleOverview` (`internal/console/overview.go:15-54`).
+- Produces: `OverviewStats.InstanceExpected int` (count of account routes with `Kind == "instance"`), `OverviewStats.Verdict string` (one of `"ok" | "attention" | "notdeployed" | "unknown"`), `OverviewStats.VerdictLabel string` (one of `"All good" | "Needs attention" | "Not deployed yet" | "Can't tell right now"`). Tasks 3 and 2 render these.
+
+- [ ] **Step 1: Write the failing tests**
+
+Append to `internal/console/overview_test.go`:
+
+```go
+func TestOverviewVerdict(t *testing.T) {
+ cases := []struct {
+ name string
+ st OverviewStats
+ verdict string
+ label string
+ }{
+ {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"},
+ {"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"},
+ {"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
+ {"static-only app has nothing expected", OverviewStats{HasStable: true, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"},
+ }
+ for _, c := range cases {
+ v, l := healthVerdict(c.st)
+ if v != c.verdict || l != c.label {
+ t.Errorf("%s: got %q/%q, want %q/%q", c.name, v, l, c.verdict, c.label)
+ }
+ }
+}
+
+func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "static"},
+ {Host: "c", Account: "other", Kind: "instance"},
+ }}
+ st := assembleOverview(view, "bab", []InstanceMetrics{}, nil)
+ if st.InstanceExpected != 1 {
+ t.Errorf("InstanceExpected = %d, want 1 (instance-kind, this account only)", st.InstanceExpected)
+ }
+ if st.Verdict == "" || st.VerdictLabel == "" {
+ t.Error("assembleOverview must fill Verdict and VerdictLabel")
+ }
+}
+```
+
+Note: check `RouteView` in `view.go` for the exact field names (`Account`, `Kind`, `Host`) before writing — mirror whatever `TestInstanceRowsSkipOtherAccountsRoutes` (overview_test.go:258) constructs.
+
+- [ ] **Step 2: Run tests to verify they fail**
+
+Run: `go test ./internal/console/ -run 'TestOverviewVerdict|TestOverviewExpected' -count=1`
+Expected: FAIL — `healthVerdict` undefined, `InstanceExpected` undefined.
+
+- [ ] **Step 3: Implement**
+
+In `internal/console/overview.go`, add to `OverviewStats` (after `MetricsUnavailable`):
+
+```go
+ // InstanceExpected is how many of this account's routes are lifecycle-
+ // managed processes (kind "instance") — the only kind the metrics sweep
+ // can ever hear from. Static and service routes are served without a
+ // tenant process, so they are never "silent": expecting a reading from
+ // them would flag every static site as unhealthy forever.
+ InstanceExpected int
+
+ // Verdict is the dashboard's one-word answer to "is this app healthy",
+ // computed here so templates render it as a fact. Verdict is the CSS
+ // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the
+ // words. The rule errs toward "can't tell" over a guessed "all good" —
+ // same honesty contract as the em-dash.
+ Verdict string
+ VerdictLabel string
+```
+
+In `assembleOverview`, inside the existing routes loop (`overview.go:100-110`), add after `st.InstanceTotal++`:
+
+```go
+ if rt.Kind == "instance" {
+ st.InstanceExpected++
+ }
+```
+
+At the end of `assembleOverview`, before `return st`:
+
+```go
+ st.Verdict, st.VerdictLabel = healthVerdict(st)
+```
+
+Add the function:
+
+```go
+// healthVerdict folds the dashboard's numbers into one word. Order matters:
+// an unreadable stable pointer means we don't know what's live, which
+// outranks everything; an app never promoted isn't unhealthy, it's not
+// deployed; and a failed sweep with real instances is "can't tell", never
+// "all good" — a verdict this page can't back is the lie the old Status
+// column told.
+func healthVerdict(st OverviewStats) (string, string) {
+ switch {
+ case st.LiveErr != "":
+ return "unknown", "Can't tell right now"
+ case !st.HasStable:
+ return "notdeployed", "Not deployed yet"
+ case st.DomainAttention > 0:
+ return "attention", "Needs attention"
+ case st.MetricsUnavailable && st.InstanceExpected > 0:
+ return "unknown", "Can't tell right now"
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected:
+ return "attention", "Needs attention"
+ default:
+ return "ok", "All good"
+ }
+}
+```
+
+- [ ] **Step 4: Run tests to verify they pass**
+
+Run: `go test ./internal/console/ -count=1`
+Expected: PASS (all existing tests too — `assembleOverview`'s signature is unchanged).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go
+git commit -m "console: compute a health verdict on the app overview"
+```
+
+---
+
+### Task 2: Apps list — verdict chip, plain columns (server.go, apps.html, layout.html)
+
+**Files:**
+- Modify: `internal/console/server.go:520-528` (appRow), `internal/console/server.go:2404-2438` (summarize)
+- Modify: `internal/console/templates/apps.html`
+- Modify: `internal/console/templates/layout.html` (chip CSS, after the `.chip-env` rule ~line 179)
+- Test: `internal/console/server_test.go` (grep `summarize` in tests first; if untested, add `TestSummarizeVerdictAndURL` to server_test.go)
+
+**Interfaces:**
+- Consumes: `AppView` (view.go), `humanAge` (view.go), the verdict vocabulary from Task 1 (labels only — the list computes its own registry-scope verdict; it must NOT claim "All good", see Step 3 comment).
+- Produces: new `appRow` shape rendered by apps.html: `App, URL, Verdict, VerdictLabel, Stable, StableAge string; CanaryN int`. Shared helper `apexHost(routes []RouteView) string` in ladder.go. CSS classes `chip-health-ok`, `chip-health-attention`, `chip-health-notdeployed`, `chip-health-unknown` used again in Task 3.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestSummarizeVerdictAndURL(t *testing.T) {
+ now := time.Now()
+ v := AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"},
+ {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready"},
+ },
+ Channels: []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }}
+ row := summarize(v)
+ if row.URL != "kass.example.com" {
+ t.Errorf("URL = %q, want the apex host kass.example.com", row.URL)
+ }
+ if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
+ t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
+ }
+ if row.CanaryN != 1 {
+ t.Errorf("CanaryN = %d, want 1", row.CanaryN)
+ }
+
+ v.Routes[0].Status = "failed"
+ row = summarize(v)
+ if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" {
+ t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel)
+ }
+
+ empty := summarize(AppView{App: "new"})
+ if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" {
+ t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel)
+ }
+
+ bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}})
+ if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" {
+ t.Errorf("stable err: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel)
+ }
+}
+```
+
+(Adjust `RouteView`/`ChannelView` field literals to match view.go — same rule as Task 1. Check whether `RouteView` carries `LastError`; if it does, a route with non-empty `LastError` is also `attention` — add a case. If it exists only on `registry.Route` and not on the view, do NOT add it to the view in this task.)
+
+- [ ] **Step 2: Run test to verify it fails**
+
+Run: `go test ./internal/console/ -run TestSummarizeVerdictAndURL -count=1`
+Expected: FAIL — `appRow` has no `URL`/`Verdict` fields.
+
+- [ ] **Step 3: Implement summarize + appRow + apexHost**
+
+Replace `appRow` (server.go:521-528):
+
+```go
+// appRow is one line of the "your apps" table: the app, where it's running,
+// whether anything the REGISTRY tracks needs attention, and what's live.
+// The verdict here is registry-scope by design — process liveness only
+// exists in the per-app metrics sweep, which is too heavy to run across a
+// whole account's list. So the healthy word is "Deployed", never "All
+// good": the list claims exactly what it knows, and the app's Overview
+// (which has the sweep) gives the full verdict. Publishing per-app liveness
+// into the aggregate feed so this list can say more is a filed follow-up.
+type appRow struct {
+ App string
+ URL string // apex host, "" when no routes
+ Verdict string // ok | attention | notdeployed | unknown
+ VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now
+ Stable string // live version, "" when none
+ StableAge string // humanAge of its promotion, "" when unknown
+ CanaryN int // count of canary/* channels
+}
+```
+
+Replace `summarize` (server.go:2404-2438):
+
+```go
+// summarize folds one AppView into its "your apps" table row. See appRow for
+// why its verdict is registry-scope.
+func summarize(v AppView) appRow {
+ row := appRow{App: v.App, URL: apexHost(v.Routes)}
+ attention := false
+ for _, rt := range v.Routes {
+ if rt.Status == "failed" {
+ attention = true
+ }
+ }
+ stableErr := false
+ for _, ch := range v.Channels {
+ if ch.Name == "stable" {
+ if ch.Err != "" {
+ stableErr = true
+ continue
+ }
+ row.Stable = ch.Version
+ if !ch.PromotedAt.IsZero() {
+ row.StableAge = humanAge(time.Since(ch.PromotedAt))
+ }
+ }
+ if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") {
+ row.CanaryN++
+ }
+ }
+ switch {
+ case stableErr:
+ row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
+ case attention:
+ row.Verdict, row.VerdictLabel = "attention", "Needs attention"
+ case row.Stable == "":
+ row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
+ default:
+ row.Verdict, row.VerdictLabel = "ok", "Deployed"
+ }
+ return row
+}
+```
+
+Extract the apex ordering that `prodWiredCanaryHosts` (ladder.go:251-269) already implements, so the two never drift. In `ladder.go`, pull the inline logic into a package function and call it from both places:
+
+```go
+// apexHost picks the app's primary host — fewest labels, then shortest,
+// then alphabetical — the same "which host is the apex" answer
+// prodWiredCanaryHosts uses. "" when there are no named routes.
+func apexHost(routes []RouteView) string {
+ apex := ""
+ better := func(a, b string) bool {
+ la, lb := strings.Count(a, "."), strings.Count(b, ".")
+ if la != lb {
+ return la < lb
+ }
+ if len(a) != len(b) {
+ return len(a) < len(b)
+ }
+ return a < b
+ }
+ for _, rt := range routes {
+ if rt.Host == "" {
+ continue
+ }
+ if apex == "" || better(rt.Host, apex) {
+ apex = rt.Host
+ }
+ }
+ return apex
+}
+```
+
+and in `prodWiredCanaryHosts` replace the inline apex block with `apex := apexHost(routes)` (delete the now-duplicated `better` closure there).
+
+- [ ] **Step 4: Rewrite apps.html**
+
+Replace the table block (apps.html:6-21):
+
+```html
+<h1>{{.Account.Name}}</h1>
+<div class="tablewrap">
+<table>
+ <tr><th scope="col">App</th><th scope="col">Running at</th><th scope="col">Health</th><th scope="col">Live version</th></tr>
+ {{range .Apps}}
+ <tr>
+ <td><a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a></td>
+ <td>{{if .URL}}<a href="https://{{.URL}}">{{.URL}}</a>{{else}}—{{end}}</td>
+ <td><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></td>
+ <td>{{if .Stable}}<code>{{.Stable}}</code>{{if .StableAge}} <span class="small">· {{.StableAge}}</span>{{end}}{{else}}—{{end}}{{if .CanaryN}} <span class="chip chip-canary">{{.CanaryN}} test version{{if ne .CanaryN 1}}s{{end}}</span>{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+```
+
+Change the trash line (apps.html:26-27) to:
+
+```html
+{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
+ restorable for 30 days.</p>{{end}}
+```
+
+- [ ] **Step 5: Add the health chip CSS**
+
+In layout.html, directly after the `.chip-env` rule (~line 179), add:
+
+```css
+ /* Health verdict chips. "attention" is the one filled variant here — the
+ row a reader must not skim past — in the warn tone, not .chip-prod's
+ red: a silent instance is a problem, not the unreviewed-build outage.
+ ok/notdeployed/unknown are bordered facts. */
+ .chip-health-ok { border: 1px solid #2f7a4f; color: #2f7a4f; }
+ .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
+ .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
+```
+
+(Check whether layout.html defines a green token — grep `--good` / `2f7a4f`; `chip-up` at line 181 uses `#2f7a4f`, reuse whatever it uses.)
+
+- [ ] **Step 6: Run the full gate**
+
+Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/`
+Expected: all clean, no files listed by gofmt. Grep first for tests asserting the old columns: `grep -rn '"Hosts"\|Instances</th>\|Statuses' internal/console/*_test.go` — update any hits.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add internal/console/server.go internal/console/ladder.go internal/console/templates/apps.html internal/console/templates/layout.html internal/console/server_test.go
+git commit -m "console: apps list answers app/where/health/version, drops provisioning jargon"
+```
+
+---
+
+### Task 3: Overview — headline verdict, four tiles, plain tile copy (app_overview.html, overview.go)
+
+**Files:**
+- Modify: `internal/console/overview.go` (add `PrimaryHost`)
+- Modify: `internal/console/templates/app_overview.html`
+- Test: `internal/console/overview_test.go` (extend `TestOverviewPageRendersTiles` at line 274)
+
+**Interfaces:**
+- Consumes: Task 1's `Verdict`/`VerdictLabel`/`InstanceExpected`, Task 2's `apexHost` and `chip-health-*` CSS.
+- Produces: `OverviewStats.PrimaryHost string`. Final Overview layout other tasks link to.
+
+- [ ] **Step 1: Failing test**
+
+In `overview_test.go`, extend the assemble tests: construct a view with routes and assert `st.PrimaryHost` equals the apex host (same fixture shape as Task 2's test). Extend `TestOverviewPageRendersTiles` to assert the rendered page contains the fixture's verdict label and `Machines`, and does NOT contain `stable pointer unreadable` or `Supply-chain bake`.
+
+- [ ] **Step 2: Run to verify failure**
+
+Run: `go test ./internal/console/ -run 'TestOverview' -count=1` — FAIL.
+
+- [ ] **Step 3: Implement `PrimaryHost`**
+
+In `OverviewStats` add `PrimaryHost string` (comment: "the apex host, for the headline's 'live at' link; empty when no routes"). In `assembleOverview`, before `return st`:
+
+```go
+ var mine []RouteView
+ for _, rt := range view.Routes {
+ if rt.Account == acct {
+ mine = append(mine, rt)
+ }
+ }
+ st.PrimaryHost = apexHost(mine)
+```
+
+- [ ] **Step 4: Rewrite the Overview template**
+
+Replace app_overview.html:16-68 (the whole `{{with .Overview}}` section and the bake block) with:
+
+```html
+{{with .Overview}}
+<section class="pagehead" aria-label="App status">
+ <p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p>
+ {{if .HasStable}}
+ <p>{{$.AppName}} is live{{if .PrimaryHost}} at <a href="https://{{.PrimaryHost}}">{{.PrimaryHost}}</a>{{end}} —
+ running <code>{{.LiveVersion}}</code>, published {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}.</p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — the record of what's live didn't read. It usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet. Publish a first version from your terminal — the command is on the
+ <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
+ {{end}}
+</section>
+<section class="tiles stat-tiles" aria-label="Vital statistics">
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Machines</h2>
+ <p class="tile-value">{{.InstanceTotal}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
+ <h2>Resources</h2>
+ <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
+ <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
+ <h2>Web addresses</h2>
+ <p class="tile-value">{{.DomainTotal}}</p>
+ <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p>
+ </a>
+ <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
+ <h2>Versions</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">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
+{{else if .On}}
+<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.</p>
+{{else if .TurningOff}}
+<p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.</p>
+{{else}}
+<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p>
+{{end}}
+{{end}}
+```
+
+And change the footer line (app_overview.html:78):
+
+```html
+{{if not .CanPromote}}<p class="small">Deploys are temporarily unavailable on this console.</p>{{end}}
+```
+
+Note the section reuses the `pagehead` class (layout.html:262-270). Verify the chip renders sanely inside it; if `pagehead p` styling fights the chip, wrap the chip line in `<p class="eyebrow">` instead and note it in the commit.
+
+- [ ] **Step 5: Gate and commit**
+
+Run the full gate (Global Constraints). Grep for tests asserting the old tile copy: `grep -rn 'Live now\|on stable ·\|readings unavailable\|reporting' internal/console/*_test.go` — update hits (especially `TestOverviewPageRendersTiles`).
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/app_overview.html
+git commit -m "console: overview leads with URL + health verdict, four plain tiles"
+```
+
+---
+
+### Task 4: Safety-delay rename — every bake string (templates, server.go, confirm.go)
+
+**Files:**
+- Modify: `internal/console/templates/ladder.html:5-53` (bake block), `internal/console/templates/app_releases.html:21,30,44-47,77,88-94`, `internal/console/templates/app_settings.html:91-92`, `internal/console/templates/trash.html:35-39`
+- Modify: `internal/console/server.go` flash/audit strings (lines 1900, 1906, 1908-1909, 1920, 2062, 2070-2074, 2132, 2158, 2162)
+- Modify: `internal/console/confirm.go:137,142,181-183,188,192-196,201`
+- Modify: `internal/console/server.go` funcmap — add `until` helper
+- Test: `internal/console/bakecontrols_test.go`, `internal/console/confirm_test.go` (update assertions), plus a new funcmap test
+
+**Interfaces:**
+- Consumes: existing `humanAge` (view.go:681-693).
+- Produces: funcmap helper `until` (Go func `untilText(t time.Time) string` → `"~4h"`-style horizon via `humanAge(time.Until(t))`, `"now"` for past/zero) used by Task 6's summary strip too.
+
+- [ ] **Step 1: Inventory + failing test**
+
+Run `grep -rn -i 'bake\|fast.track\|production-wired\|Re-declare' internal/console/ --include='*.go' --include='*.html' | grep -v _test.go` and keep the list — every hit must be visited. Write the funcmap test first:
+
+```go
+func TestUntilHelper(t *testing.T) {
+ if got := untilText(time.Now().Add(4*time.Hour + time.Minute)); got != "~4h1m" && got != "~4h" {
+ t.Errorf("until ~4h = %q", got)
+ }
+ if got := untilText(time.Time{}); got != "now" {
+ t.Errorf("until zero = %q", got)
+ }
+ if got := untilText(time.Now().Add(-time.Hour)); got != "now" {
+ t.Errorf("until past = %q", got)
+ }
+}
+```
+
+(Check `humanAge`'s exact formatting at view.go:681-693 first and pin the assertion to what it actually emits for ~4h — the test must not encode a guess.)
+
+- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestUntilHelper -count=1` FAILS.
+
+- [ ] **Step 3: Implement `untilText` + register `until`**
+
+Following the funcmap pattern at server.go:61-123:
+
+```go
+// untilText renders a future horizon the way the cards speak: "~4h", never
+// a timestamp. Past or zero is "now" — the wait is over, not unknown.
+func untilText(t time.Time) string {
+ if t.IsZero() || !t.After(time.Now()) {
+ return "now"
+ }
+ return "~" + humanAge(time.Until(t))
+}
+```
+
+Register as `"until": untilText` in the template FuncMap.
+
+- [ ] **Step 4: The template strings**
+
+ladder.html bake block (lines 5-53) — replace the four states' copy (forms/CSRF fields unchanged, only words; the one `title=` tooltip becomes visible text):
+
+- Err: `Safety delay: this app's setting can't be read right now, so the delay stays on.` — button text `Re-declare production` → `Set it again`; trailing clause `— writes a fresh signed flag over the unreadable one.` stays.
+- On: `Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up.` Replace line 26's tooltip-span entirely with visible text after the Turn off button: `<span class="small">Turning it off asks for your passkey and takes effect after ~72h.</span>` (no `title=` attribute).
+- TurningOff: `Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.`
+- Off: `Safety delay: off — new versions go live as soon as they're promoted.`
+- Nudge (line 51): `{{icon "triangle-alert"}} This app looks like it's serving real users but new versions go live immediately — consider turning the safety delay on.`
+
+ladder.html rung chips and control (lines 67-75):
+- Hotfix chip: `{{icon "zap"}} published directly — skipped the pipeline`
+- FastTrack chip: `{{icon "fast-forward"}} delay skipped`
+- Fast-track button: `<button type="submit" class="btn-sm btn-danger">Skip the delay for {{.Version}}</button>`
+- The `baking until` span: `<span class="small">goes live {{until .BakedUntil}}</span>`
+
+app_releases.html:
+- Line 21 header `<th>Bake</th>` → `<th>Goes live</th>`
+- Line 30 cell → `{{if .BakedUntil.IsZero}}live now{{else}}in {{until .BakedUntil}}{{end}}{{if .FastTrack}} · delay skipped{{end}}`
+- Lines 44-46 footnote → `<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p>`
+- Line 77 canary empty state → `No test versions — a test version is a private copy serving one chosen build immediately, with no safety delay.`
+- Line 88 → `Creates <code>canary/&lt;name&gt;</code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:`
+- Lines 91/93 → `This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.` / `The address comes live within a couple of minutes.`
+
+app_settings.html:91-92 → `It is marked as production right now, which blocks deletion — turn the safety delay off on the Versions tab first.`
+
+trash.html:35-39 → adjust the "flagged production" sentence to: `An app still marked as production can't be permanently deleted, whatever you type — turn its safety delay off first.` (Keep the surrounding warning structure.)
+
+- [ ] **Step 5: The Go strings**
+
+server.go (exact replacements at the listed lines):
+- 1900: `" The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab."`
+- 1906: `" Turning the safety delay on failed — use the toggle on the app's Versions tab."`
+- 1909: `" Safety delay enabled."`
+- 1920: `" It goes live on each machine after the safety delay — rollbacks skip it."`
+- 2062: `"Could not change the safety delay: "+err.Error()`
+- 2071: `"Safety delay enabled — new versions now wait on beta and stable before going live."`
+- 2074: `"Safety delay turning off — takes effect on each machine after ~72h."`
+- 2132: `"Channel "+channel+" has no safety delay running — there is nothing to skip."`
+- 2162: `"Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes."`
+- Audit details 1908/2070/2073/2158: keep the action keys (`"production"`, `"fast-track"`) unchanged; details become `"enabled the safety delay (production: on, at stable promote)"`, `"enabled the safety delay (production: on)"`, `"disabled the safety delay (production: off)"`, `"skipped the safety delay for "+ver+" on "+channel`.
+
+confirm.go:
+- 137: `cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".")`
+- 142: `cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)"`
+- 181 Title: `"Skip the safety delay on " + channel + "?"`
+- 182 Warn: `"The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users."`
+- 183 Note: `"You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge."`
+- 188 Button: `"Skip the delay"`
+- 192 Title: `"Skip the safety delay for " + cur.Version + " on " + channel + "?"`
+- 193-195 Lines: `channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes."`
+- 196 Button: `"Skip the delay for " + cur.Version`
+- 201: `"Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes."`
+
+- [ ] **Step 6: Update asserting tests, run gate**
+
+`grep -rn -i 'bake\|fast.track' internal/console/*_test.go` — update every assertion to the new strings (bakecontrols_test.go and confirm_test.go will have several). Then the full gate. Re-run the Step 1 inventory grep: the only remaining `bake`/`fasttrack` hits must be identifiers (form field `enable_bake`, path `/fasttrack`, `BakedUntil`, `FastTrack` field refs) — wire/code names, correctly untouched.
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add -A internal/console/
+git commit -m "console: rename the supply-chain bake to 'safety delay' in every user-facing string"
+```
+
+---
+
+### Task 5: Instances disambiguation — Running tab, honest columns (appnav.html, app_instances.html, overview.go)
+
+**Files:**
+- Modify: `internal/console/templates/appnav.html:9`
+- Modify: `internal/console/templates/app_instances.html`
+- Modify: `internal/console/overview.go` (InstanceRow gains `HealthWord`)
+- Modify: `internal/console/server.go` (pass attach-hint fields to the instances page if not already on its page struct — check `attachhint.go` and how app_releases gets `.AttachEnable`/`.AttachCmd`; reuse, do not duplicate)
+- Test: `internal/console/overview_test.go`, plus the page-render test `TestOverviewInstancesPageRendersTable` (overview_test.go:299)
+
+**Interfaces:**
+- Consumes: `InstanceRow` (overview.go:153-161), attach-hint builder in `attachhint.go`.
+- Produces: `InstanceRow.HealthWord string` — one of `Running | Asleep | Starting up | Setting up | Not responding | Failed | Serving` — via `healthWord(kind, status string, hibernate, reporting bool) string`.
+
+- [ ] **Step 1: Failing test**
+
+```go
+func TestInstanceRowHealthWord(t *testing.T) {
+ cases := []struct {
+ kind, status string
+ hibernate bool
+ reporting bool
+ want string
+ }{
+ {"instance", "ready", false, true, "Running"},
+ {"instance", "ready", true, false, "Asleep"},
+ {"instance", "ready", false, false, "Not responding"},
+ {"instance", "starting", false, false, "Setting up"},
+ {"instance", "pending", false, false, "Setting up"},
+ {"instance", "creating", false, false, "Setting up"},
+ {"instance", "issuing", false, false, "Setting up"},
+ {"instance", "failed", false, false, "Failed"},
+ {"static", "ready", false, false, "Serving"},
+ {"service", "ready", false, false, "Serving"},
+ }
+ for _, c := range cases {
+ got := healthWord(c.kind, c.status, c.hibernate, c.reporting)
+ if got != c.want {
+ t.Errorf("healthWord(%q,%q,hib=%v,rep=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, got, c.want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — FAIL, `healthWord` undefined.
+
+- [ ] **Step 3: Implement**
+
+In overview.go add to `InstanceRow`: `HealthWord string` (comment: "the Status enum + hibernate + reporting folded into one plain word — the column's whole job"). In `assembleInstanceRows`, set `HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting())` in the row literal. Add:
+
+```go
+// healthWord folds provisioning state, hibernation and liveness into the one
+// word the table shows. Provisioning words win while provisioning is real
+// (a cert mid-issue IS the situation); once the platform says ready, the
+// question becomes "is the process alive", which only instance-kind routes
+// can answer — static and service routes are served without a tenant
+// process, so ready means Serving, full stop.
+func healthWord(kind, status string, hibernate, reporting bool) string {
+ switch status {
+ case "pending", "creating", "starting", "issuing":
+ return "Setting up"
+ case "failed":
+ return "Failed"
+ }
+ if kind != "instance" {
+ return "Serving"
+ }
+ switch {
+ case reporting:
+ return "Running"
+ case hibernate:
+ return "Asleep"
+ default:
+ return "Not responding"
+ }
+}
+```
+
+- [ ] **Step 4: Templates**
+
+appnav.html:9 — label only: `>Running</a>` (href unchanged).
+
+app_instances.html — replace lines 6-33 with:
+
+```html
+<h2>Running</h2>
+{{if .InstanceRows}}
+<div class="tablewrap">
+<table class="instances">
+ <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr>
+ {{range .InstanceRows}}
+ <tr>
+ <td><a href="https://{{.Host}}">{{.Host}}</a></td>
+ <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}</td>
+ <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td>
+ <td><code>{{.Channel}}</code></td>
+ {{if .Metrics.Reporting}}
+ <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" 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" aria-hidden="true" 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">no data</span></td>
+ {{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Version is what each machine reports it is running — a dash means
+it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn
+against that machine's own peak.</p>
+{{else}}
+<p>Nothing running yet. Publish a version, then give it a web address:</p>
+{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
+{{.AttachCmd}}</code></pre>{{else if .AttachCmd}}<pre><code>{{.AttachCmd}}</code></pre>{{end}}
+<p class="small">The address comes live within a couple of minutes.</p>
+{{end}}
+```
+
+(The `Kind` suffix on the host is dropped: `HealthWord` already folds what mattered. The old box-only `carlos add` command is gone; the attach hint is the same one Releases shows — check whether the instances handler fills `AttachEnable`/`AttachCmd`; if only the releases handler does, lift that fill into the shared app-page assembly so both tabs get it. If the hint genuinely can't be built there without new I/O, fall back to a literal: `<p class="small">Set one up from your terminal: <code>carlos instances create -app {{.Detail.App}} -host &lt;name&gt;</code></p>` — never `carlos add`.)
+
+And the second section (lines 35-52) becomes a disclosure:
+
+```html
+{{if and .Instances .Instances.Reporting .Instances.Instances}}
+<details>
+<summary>Reported by the app itself ({{len .Instances.Instances}})</summary>
+<p class="small">This app tracks its own internal workers and reports them to the platform.</p>
+<div class="tablewrap">
+<table>
+ <tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Region</th><th scope="col">Detail</th></tr>
+ {{range .Instances.Instances}}
+ <tr>
+ <td><code>{{.Slug}}</code></td>
+ <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
+ <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
+ <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
+ </tr>
+ {{end}}
+</table>
+</div>
+</details>
+{{end}}
+```
+
+- [ ] **Step 5: Gate + commit**
+
+Update `TestOverviewInstancesPageRendersTable` assertions (old headers → new). Full gate.
+
+```bash
+git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/appnav.html internal/console/templates/app_instances.html internal/console/server.go
+git commit -m "console: Instances tab becomes Running with honest one-word health"
+```
+
+---
+
+### Task 6: Releases progressive reveal — summary strip, two disclosures (releasesummary.go, app_releases.html, ladder.html, ladder.go)
+
+**Files:**
+- Create: `internal/console/releasesummary.go`
+- Create: `internal/console/releasesummary_test.go`
+- Modify: `internal/console/templates/app_releases.html`, `internal/console/templates/ladder.html:2-4,91,112`, `internal/console/ladder.go:119-124,173`
+- Modify: `internal/console/server.go` (add `ReleaseSummary ReleaseSummary` to the `page` struct near `Detail`, fill it in the releases handler where `p.Detail` is set: `p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)`)
+- Modify: `internal/console/templates/layout.html` (one `.reveal summary` rule)
+- Test: update `internal/console/ladder_test.go` gate-string assertions if any
+
+**Interfaces:**
+- Consumes: `ChannelView` (view.go — fields `Name, Version, PromotedAt, Actor, Err, FastTrack, BakedUntil` per ladder.go:156-159), Task 4's `until` funcmap helper.
+- Produces: `type ReleaseSummary struct { HasLive bool; LiveVersion string; LivePromotedAt time.Time; LiveActor string; LiveErr string; NextVersion string; NextBakedUntil time.Time }` and `func assembleReleaseSummary(channels []ChannelView) ReleaseSummary`.
+
+- [ ] **Step 1: Failing tests**
+
+`internal/console/releasesummary_test.go`:
+
+```go
+package console
+
+import (
+ "testing"
+ "time"
+)
+
+func TestReleaseSummary(t *testing.T) {
+ now := time.Now()
+ chans := []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-48 * time.Hour), Actor: "paul@keymail.dev"},
+ {Name: "beta", Version: "e91ac22", PromotedAt: now.Add(-20 * time.Hour), BakedUntil: now.Add(4 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }
+ s := assembleReleaseSummary(chans)
+ if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" {
+ t.Errorf("live = %+v", s)
+ }
+ if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) {
+ t.Errorf("next = %+v", s)
+ }
+
+ same := assembleReleaseSummary([]ChannelView{
+ {Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"},
+ })
+ if same.NextVersion != "" {
+ t.Errorf("same version on beta must not be Next, got %q", same.NextVersion)
+ }
+
+ bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}})
+ if bad.HasLive || bad.LiveErr == "" || bad.NextVersion != "" {
+ t.Errorf("unreadable stable = %+v", bad)
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify failure** — FAIL.
+
+- [ ] **Step 3: Implement releasesummary.go**
+
+```go
+// The Versions tab's first-glance strip: what is live, and what is next in
+// line — the two facts a visitor came for, computed here so the template
+// renders them without touching the ladder. "Next" is beta's version when it
+// differs from stable's: the ladder's one real candidate step. Everything
+// deeper (rungs, gates, canaries) stays in the pipeline disclosure.
+package console
+
+import "time"
+
+type ReleaseSummary struct {
+ HasLive bool
+ LiveVersion string
+ LivePromotedAt time.Time
+ LiveActor string
+ LiveErr string
+ // NextVersion is beta's candidate when it differs from live; "" when
+ // there is nothing next. NextBakedUntil zero means it can go live now.
+ NextVersion string
+ NextBakedUntil time.Time
+}
+
+func assembleReleaseSummary(channels []ChannelView) ReleaseSummary {
+ var s ReleaseSummary
+ var beta ChannelView
+ for _, ch := range channels {
+ switch ch.Name {
+ case "stable":
+ if ch.Err != "" {
+ s.LiveErr = ch.Err
+ continue
+ }
+ s.HasLive = true
+ s.LiveVersion = ch.Version
+ s.LivePromotedAt = ch.PromotedAt
+ s.LiveActor = ch.Actor
+ case "beta":
+ beta = ch
+ }
+ }
+ if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion {
+ s.NextVersion = beta.Version
+ s.NextBakedUntil = beta.BakedUntil
+ }
+ return s
+}
+```
+
+- [ ] **Step 4: Restructure app_releases.html**
+
+Replace line 15 (`{{template "ladder" .}}`) with the summary strip:
+
+```html
+{{with .ReleaseSummary}}
+<section class="pagehead" aria-label="Release status">
+ {{if .HasLive}}
+ <p>Live: <code>{{.LiveVersion}}</code> <span class="small">— promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</span></p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — it usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet.</p>
+ {{end}}
+ {{if .NextVersion}}
+ <p>Next up: <code>{{.NextVersion}}</code> on beta — {{if .NextBakedUntil.IsZero}}ready to go live now{{else}}goes live {{until .NextBakedUntil}}{{end}}.</p>
+ {{if and $.CanPromote $.IsOwner}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="channel" value="stable">
+ <input type="hidden" name="version" value="{{.NextVersion}}">
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="btn">Make {{.NextVersion}} live</button>
+ </form>
+ {{else}}
+ <p class="small">Only an account owner can make it live.</p>
+ {{end}}
+ {{end}}
+</section>
+{{end}}
+```
+
+(Check the funcmap's `ago` output — if it emits `2d ago`, the strip's first line reads "promoted 2d ago by …", which is correct as written. The Make-live button posts the existing promote endpoint, which renders the existing confirm card + passkey step — no handler change.)
+
+Reorder the rest of the page: the Versions table (current "Releases" section, lines 97-135) moves UP to sit directly under the strip, wrapped:
+
+```html
+{{if .Versions}}
+<details class="reveal" open>
+<summary>All versions ({{len .Versions}})</summary>
+... existing Releases table, with two edits below ...
+</details>
+{{else}}
+... existing empty state (Nothing shipped yet + carlos ship pre-block), unchanged ...
+{{end}}
+```
+
+Two edits inside the table: delete the free-text input line
+
+```html
+ <input type="text" name="new_channel" placeholder="or a new channel" size="14">
+```
+
+and give the select an accessible name: `<select name="channel" aria-label="Channel to promote {{.Version}} to">`. (The server keeps accepting `new_channel` — no handler change; new-channel creation now happens via the canary form or CLI. Grep tests for `new_channel` posts through this page first.)
+
+Then the expert layer — the ladder plus the whole Channels, Canaries (+create form and attach hint), and Tags sections move inside one closed disclosure at the bottom:
+
+```html
+<details class="reveal">
+<summary>Release pipeline — channels, test versions, and the promotion ladder</summary>
+{{template "ladder" .}}
+... Channels section (existing, with Task 4's strings) ...
+... Canaries section (existing, with Task 4's strings) ...
+... Tags section (existing) ...
+</details>
+```
+
+In layout.html add one rule near the existing `details` styling (grep `summary` first; add if absent):
+
+```css
+ .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; }
+```
+
+- [ ] **Step 5: Ladder copy (ladder.html, ladder.go)**
+
+ladder.html header block (lines 2-4) becomes:
+
+```html
+<h2>The ladder</h2>
+<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live).
+Promoting moves it one step up; the machines following that step pick it up.</p>
+```
+
+ladder.go `gates` map (lines 119-124) becomes:
+
+```go
+var gates = map[string]string{
+ release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.",
+ release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.",
+ release.RungBeta: "Any account member can promote here; it's recorded.",
+ release.RungStable: "An account owner promotes here with their passkey, after the safety delay.",
+}
+```
+
+ladder.go:173 → `Err: "nothing has been published here yet — these machines are waiting for a version"`.
+ladder.html:91 → `No machine follows this channel.`
+ladder.html:112 → `Only an account owner can promote to {{.Up.To}}.`
+(Rung names `canary/edge/beta/stable` stay verbatim inside the pipeline layer — it is the expert layer; the lede's parentheticals do the teaching.)
+
+- [ ] **Step 6: Gate + commit**
+
+Update `ladder_test.go` if it asserts gate strings (`grep -n 'canary is proven\|Sign-off\|pointer that does not exist' internal/console/ladder_test.go`). Full gate.
+
+```bash
+git add internal/console/releasesummary.go internal/console/releasesummary_test.go internal/console/server.go internal/console/templates/app_releases.html internal/console/templates/ladder.html internal/console/ladder.go internal/console/ladder_test.go internal/console/templates/layout.html
+git commit -m "console: releases page leads with live/next, ladder and tables move behind reveals"
+```
+
+---
+
+### Task 7: Whole-batch verification + review
+
+**Files:** none new.
+
+- [ ] **Step 1: Full gate, all packages** — `go build ./... && go vet ./... && go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l` on every touched file. All clean.
+
+- [ ] **Step 2: Jargon regression grep** — `grep -rn -i 'supply-chain\|bake\|fast-track\|adopt\|pointer\|manifest' internal/console/templates/` must return only: identifiers/paths (`/fasttrack`, `enable_bake` form field, `.BakedUntil`, `.FastTrack` field refs) and operator-only pages (fleet.html, metrics.html — out of this batch's scope). Anything else is a miss — fix it.
+
+- [ ] **Step 3: Render smoke** — the page-render tests execute the real templates and are the closest thing to a screenshot in CI: `go test ./internal/console/ -run 'Render|Page' -v -count=1`.
+
+- [ ] **Step 4: Reviewer dispatch (per CLAUDE.md delegation policy)** — this batch touches production console paths: request an **Opus** review of the whole branch diff (superpowers:requesting-code-review), attaching the critique file as the spec. Fix findings, re-run the gate.
+
+- [ ] **Step 5: Push and open a draft PR; do NOT merge** — Paul's human review is the explicit next gate. Draft PR title: `console: clarity batch — health verdict, safety delay, progressive reveal (P0s+P1s)`. Body links `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` and names the two deliberate scope-outs: (1) apps-list verdict is registry-scope ("Deployed", not "All good") pending a per-app liveness aggregate — file that gap as an issue; (2) P2s/P3s (box-only command copy on Settings, raw err two-parting, footer disclaimer, full a11y sweep) are a follow-up batch.
+
+**Deploy note (not part of this plan's execution):** rolling this needs the standard console artifact rules — console = `cmd/console` build, NOT the root binary; both boxes arm64; promote → adopt → console restart (until #110's unit-stamped route is rolled everywhere). Deploy only on Paul's go.
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 90b54edb6..32defc6c5 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -620,9 +620,9 @@ func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
// other bucket failure in this console gets.
log.Printf("console: api trash %s/%s: production flag: %v", acct.Sqid, app, err)
writeJSON(w, http.StatusConflict, map[string]string{
- "error": "could not read the production flag for " + app +
- " — an unreadable flag is treated as production, so deleting is refused." +
- " Re-declare it with the production switch on the app page, then try again.",
+ "error": "could not read the production setting for " + app +
+ " — when in doubt it's treated as production, so deleting is refused." +
+ " Set it again with the production switch on the app page, then try again.",
"console_url": appURL,
})
return
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 3320245fe..6291114b3 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -290,11 +290,11 @@ func (s *Server) handleAppDelete(w http.ResponseWriter, r *http.Request) {
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.")
+ back("err", "Could not read this app's production setting — when in doubt it's treated as production, so deleting is refused. Set it again 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.")
+ back("err", app+" is marked as production, so deleting is refused. Turn the safety delay off first (owner + passkey, on the Releases page; it takes effect after ~72h), then try again.")
return
}
now := time.Now()
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index aeea374d9..2cb6a3f94 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -423,7 +423,7 @@ func TestAppDeleteRefusesAProductionApp(t *testing.T) {
if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
t.Fatalf("delete of a production app = %d -> %q:\n%s", status, loc, body)
}
- if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is flagged production, so deleting is refused") {
+ if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "is marked as production, so deleting is refused") {
t.Fatalf("no production refusal flash:\n%s", page)
}
if tomb := appTombstone(t, fx, "kass"); tomb != nil {
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go
index 7eaf8d748..cab5d35d7 100644
--- a/internal/console/bakecontrols_test.go
+++ b/internal/console/bakecontrols_test.go
@@ -187,7 +187,7 @@ func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) {
if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" {
t.Fatalf("audit[0] = %+v, want the waiver recorded with its actor", p.Audit)
}
- auditedBy(t, fx, "kass", "fast-track", "paul@keymail.dev", "waived the bake window for v1 on stable")
+ auditedBy(t, fx, "kass", "fast-track", "paul@keymail.dev", "skipped the safety delay for v1 on stable")
}
// Fast-tracking a channel that never bakes is refused. It looks inert —
@@ -209,7 +209,7 @@ func TestFastTrackRefusesAChannelWithNoBakeWindow(t *testing.T) {
t.Fatalf("fast-track of a bake-free channel = %d, want the flash redirect", status)
}
if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body,
- "Channel canary/x has no bake window — there is nothing to fast-track.") {
+ "Channel canary/x has no safety delay running — there is nothing to skip.") {
t.Fatalf("no refusal flash:\n%s", body)
}
after := fx.channelVersion(t, "kass", "canary/x")
@@ -276,9 +276,9 @@ func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) {
t.Fatalf("flag = %+v after enable_bake promote, want production on", f)
}
// The flash tells the truth about both halves of what just happened: the
- // flag went on in this action, so the bake note is now accurate.
- if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Supply-chain bake enabled.") ||
- !strings.Contains(body, "Adopts after the bake window") {
+ // flag went on in this action, so the safety-delay note is now accurate.
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Safety delay enabled.") ||
+ !strings.Contains(body, "It goes live on each machine after the safety delay") {
t.Fatalf("enable_bake flash missing its two halves:\n%s", body)
}
}
@@ -303,11 +303,11 @@ func TestPromoteEnableBakeSaysWhenItCouldNotRead(t *testing.T) {
t.Fatalf("stable = %s, want v3 — the promote itself must not depend on the flag", p.Version)
}
_, _, body := get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "could not be read, so the bake was not enabled") {
+ if !strings.Contains(body, "could not be read, so the safety delay was not enabled") {
t.Fatalf("the flash is silent about the unhonored checkbox:\n%s", body)
}
- if strings.Contains(body, "Supply-chain bake enabled.") {
- t.Fatal("the flash claims the bake was enabled over an unreadable flag")
+ if strings.Contains(body, "Safety delay enabled.") {
+ t.Fatal("the flash claims the safety delay was enabled over an unreadable flag")
}
}
@@ -333,8 +333,8 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" {
t.Fatalf("beta = %+v, want v3", p)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Adopts after the bake window") {
- t.Fatalf("a flagless app was promised a bake window:\n%s", body)
+ if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "It goes live on each machine after the safety delay") {
+ t.Fatalf("a flagless app was promised a safety delay:\n%s", body)
}
// Flag on: the same rung now says so.
@@ -348,7 +348,7 @@ func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) {
url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}, "confirmed": {"1"}}); status != http.StatusSeeOther {
t.Fatalf("beta promote of v4 = %d:\n%s", status, body)
}
- if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Adopts after the bake window") {
+ if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "It goes live on each machine after the safety delay") {
t.Fatalf("a production app's held promotion did not mention the window:\n%s", body)
}
}
diff --git a/internal/console/confirm.go b/internal/console/confirm.go
index c088c7b76..a165103f4 100644
--- a/internal/console/confirm.go
+++ b/internal/console/confirm.go
@@ -134,12 +134,12 @@ func (s *Server) confirmPromote(w http.ResponseWriter, r *http.Request, addr, ra
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.")
+ cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".")
}
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)"
+ cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)"
}
if stable {
cv.Note = "You'll confirm it's really you with your passkey."
@@ -157,7 +157,7 @@ func (s *Server) confirmRollback(w http.ResponseWriter, r *http.Request, addr, r
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.",
+ Note: "Rollbacks skip the safety delay — machines pick it up within a couple of minutes.",
Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/rollback",
// target pins what the card promised: the confirmed POST refuses if
// the channel's history has moved on since this was rendered.
@@ -178,27 +178,27 @@ func (s *Server) confirmFastTrack(w http.ResponseWriter, r *http.Request, addr,
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.",
+ Title: "Skip the safety delay on " + channel + "?",
+ Warn: "The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users.",
+ Note: "You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge.",
Action: "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app) + "/fasttrack",
Fields: [][2]string{
{"csrf", raw}, {"channel", channel},
},
- Button: "Fast-track",
+ Button: "Skip the delay",
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.Title = "Skip the safety delay for " + 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
+ channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.",
+ "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes.")
+ cv.Button = "Skip the delay for " + cur.Version
// Pin the version the card names — the waiver must not land on a
// build promoted after this was rendered.
cv.Fields = append(cv.Fields, [2]string{"version", cur.Version})
} else {
- cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s bake window: hosts adopt its current version on their next pass.")
+ cv.Lines = append(cv.Lines, "Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes.")
}
s.renderConfirm(w, r, addr, raw, acct, role, app, cv)
}
diff --git a/internal/console/confirm_test.go b/internal/console/confirm_test.go
index e14ec9e6f..4a53ab2e8 100644
--- a/internal/console/confirm_test.go
+++ b/internal/console/confirm_test.go
@@ -223,8 +223,8 @@ func TestConfirmFastTrackCardComesBeforeStepUp(t *testing.T) {
if strings.HasPrefix(loc, fx.fk.srv.URL) {
t.Fatalf("the owner met the passkey before the card (-> %q) — confirm asks \"do you mean it?\" first", loc)
}
- card := confirmCard(t, status, body, "Fast-track v1 on stable?", csrf)
- if !strings.Contains(card, "The bake window is the supply-chain guard") {
+ card := confirmCard(t, status, body, "Skip the safety delay for v1 on stable?", csrf)
+ if !strings.Contains(card, "The safety delay is the window where a new build proves itself") {
t.Fatalf("the fast-track card carries no warning:\n%s", card)
}
if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack {
diff --git a/internal/console/ladder.go b/internal/console/ladder.go
index 4b91fd02b..67eff601d 100644
--- a/internal/console/ladder.go
+++ b/internal/console/ladder.go
@@ -117,10 +117,10 @@ type LadderStep struct {
// gates are spec §3's gate table, in the words a person reading the page
// needs rather than the words the table uses.
var gates = map[string]string{
- release.RungCanary: "No gate — a canary is proven before it is a candidate, and may be wired to anything, including production.",
- release.RungEdge: "Merged to main. Nothing is promoted onto edge.",
- release.RungBeta: "Sign-off by an account member, recorded.",
- release.RungStable: "Sign-off by an account owner with a passkey, recorded, after the bake window.",
+ release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.",
+ release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.",
+ release.RungBeta: "Any account member can promote here; it's recorded.",
+ release.RungStable: "An account owner promotes here with their passkey, after the safety delay.",
}
// assembleLadder folds an app's channels and routes into the four rungs.
@@ -170,7 +170,7 @@ func assembleLadder(channels []ChannelView, routes []RouteView, now time.Time) [
}
entries[rung] = append(entries[rung], LadderChannel{
Channel: ch, Routes: rts,
- Err: "nothing is published on this channel — these hosts are following a pointer that does not exist",
+ Err: "nothing has been published here yet — these machines are waiting for a version",
})
}
out := make([]LadderRung, 0, 4)
@@ -213,6 +213,32 @@ func stepUp(from, to string, chans []LadderChannel) *LadderStep {
return s
}
+// apexHost picks the app's primary host — fewest labels, then shortest,
+// then alphabetical — the same "which host is the apex" answer
+// prodWiredCanaryHosts uses. "" when there are no named routes.
+func apexHost(routes []RouteView) string {
+ apex := ""
+ better := func(a, b string) bool {
+ la, lb := strings.Count(a, "."), strings.Count(b, ".")
+ if la != lb {
+ return la < lb
+ }
+ if len(a) != len(b) {
+ return len(a) < len(b)
+ }
+ return a < b
+ }
+ for _, rt := range routes {
+ if rt.Host == "" {
+ continue
+ }
+ if apex == "" || better(rt.Host, apex) {
+ apex = rt.Host
+ }
+ }
+ return apex
+}
+
// prodWiredCanaryHosts is spec §4a's honest definition of "prod-wired",
// replacing the apex-host guess this function used before named
// environments existed (2026-08-02-pipelines-design.md §4a, last
@@ -248,25 +274,7 @@ func prodWiredCanaryHosts(routes []RouteView) map[string]bool {
stableEnvs[rt.Environment] = true
}
}
- apex := ""
- better := func(a, b string) bool {
- la, lb := strings.Count(a, "."), strings.Count(b, ".")
- if la != lb {
- return la < lb
- }
- if len(a) != len(b) {
- return len(a) < len(b)
- }
- return a < b
- }
- for _, rt := range routes {
- if rt.Host == "" {
- continue
- }
- if apex == "" || better(rt.Host, apex) {
- apex = rt.Host
- }
- }
+ apex := apexHost(routes)
out := map[string]bool{}
for _, rt := range routes {
rung, ok := release.Rung(rt.Channel)
diff --git a/internal/console/ladder_test.go b/internal/console/ladder_test.go
index 818be79a8..3d4ccc712 100644
--- a/internal/console/ladder_test.go
+++ b/internal/console/ladder_test.go
@@ -635,8 +635,8 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
csrf := sessionToken(t, fx, c)
_, _, body := get(t, c, fx.u("/apps/kass/releases"))
- if !strings.Contains(body, "Supply-chain bake") {
- t.Fatalf("no bake status line:\n%s", body)
+ if !strings.Contains(body, "Safety delay") {
+ t.Fatalf("no safety-delay status line:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) {
t.Fatal("owner is offered no production toggle")
@@ -678,8 +678,8 @@ func TestLadderShowsBakeStateAndControls(t *testing.T) {
}
_, _, body = get(t, c, fx.u("/apps/kass/releases"))
b := rungBlock(t, body, "beta")
- if !strings.Contains(b, "fast-tracked") {
- t.Errorf("no permanent fast-tracked badge:\n%s", b)
+ if !strings.Contains(b, "delay skipped") {
+ t.Errorf("no permanent delay-skipped badge:\n%s", b)
}
if strings.Contains(b, `action="/a/bab/apps/kass/fasttrack"`) {
t.Error("a waived head still offers the fast-track control")
@@ -709,8 +709,8 @@ func TestLadderBakeControlsMemberView(t *testing.T) {
signUp(t, fx, cm, "code-sam-baketpl")
_, _, mbody := get(t, cm, fx.u("/apps/kass/releases"))
- if !strings.Contains(mbody, "Supply-chain bake") {
- t.Fatal("member sees no bake status")
+ if !strings.Contains(mbody, "Safety delay") {
+ t.Fatal("member sees no safety-delay status")
}
if !strings.Contains(mbody, `name="production" value="on"`) {
t.Error("member is not offered the turn-on control")
@@ -760,14 +760,14 @@ func TestLadderUnreadableFlagOffersRepair(t *testing.T) {
t.Fatal(err)
}
_, _, body := get(t, c, fx.u("/apps/kass/releases"))
- if !strings.Contains(body, "Supply-chain bake: flag unreadable") {
+ if !strings.Contains(body, "Safety delay: this app's setting can't be read right now, so the delay stays on.") {
t.Fatalf("no error line for an unverifiable flag:\n%s", body)
}
if !strings.Contains(body, `action="/a/bab/apps/kass/production"`) ||
!strings.Contains(body, `name="production" value="on"`) {
t.Fatalf("an unreadable flag offers no repair form — the app bakes forever:\n%s", body)
}
- if !strings.Contains(body, "Re-declare production") {
+ if !strings.Contains(body, "Set it again") {
t.Fatalf("the repair control is not named as a re-declaration:\n%s", body)
}
// The safe direction only: turning OFF over an unverifiable flag is still
@@ -812,9 +812,10 @@ func TestLadderTurningOffOffersTurnBackOn(t *testing.T) {
t.Fatalf("the turning-off state offers no way back on:\n%s", body)
}
// Not the default-off line: this app has an off-flag mid-ratchet, and
- // telling the operator hosts adopt immediately would be false for ~72h.
- if strings.Contains(body, "hosts adopt releases immediately") {
- t.Error("mid-ratchet, the page claims hosts already adopt immediately")
+ // telling the operator new versions go live immediately would be false
+ // for ~72h.
+ if strings.Contains(body, "new versions go live as soon as they're promoted") {
+ t.Error("mid-ratchet, the page claims new versions already go live immediately")
}
}
@@ -826,14 +827,14 @@ func TestLadderNudgesProdWiredBakeOff(t *testing.T) {
// The fixture's kass.carlosapp.dev is on stable and the flag is off (the
// default): that IS the nudge condition.
_, _, body := get(t, c, fx.u("/apps/kass/releases"))
- if !strings.Contains(body, "adopts releases immediately") {
+ if !strings.Contains(body, "new versions go live immediately") {
t.Fatalf("no prod-wiring nudge:\n%s", body)
}
if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
release.OpsAccount, "kass", true, "test"); err != nil {
t.Fatal(err)
}
- if _, _, body := get(t, c, fx.u("/apps/kass/releases")); strings.Contains(body, "adopts releases immediately") {
+ if _, _, body := get(t, c, fx.u("/apps/kass/releases")); strings.Contains(body, "new versions go live immediately") {
t.Fatal("the nudge survived the flag turning on")
}
}
diff --git a/internal/console/overview.go b/internal/console/overview.go
index caa504880..d7738e9be 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -36,6 +36,25 @@ type OverviewStats struct {
// "readings unavailable", not "zero instances reporting".
MetricsUnavailable bool
+ // InstanceExpected is how many of this account's routes are lifecycle-
+ // managed processes (kind "instance") — the only kind the metrics sweep
+ // can ever hear from. Static and service routes are served without a
+ // tenant process, so they are never "silent": expecting a reading from
+ // them would flag every static site as unhealthy forever.
+ InstanceExpected int
+
+ // Verdict is the dashboard's one-word answer to "is this app healthy",
+ // computed here so templates render it as a fact. Verdict is the CSS
+ // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the
+ // words. The rule errs toward "can't tell" over a guessed "all good" —
+ // same honesty contract as the em-dash.
+ Verdict string
+ VerdictLabel string
+
+ // PrimaryHost is the apex host, for the headline's "live at" link; empty
+ // when no routes.
+ PrimaryHost string
+
// Current resource use summed across every reporting instance. Nil when
// nothing reports — the tiles render the em-dash, never zeros.
CPUSum *float64
@@ -102,10 +121,13 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
continue
}
st.InstanceTotal++
- if reporting[rt.Host] {
- st.InstanceReporting++
- } else if rt.Hibernate {
- st.InstanceAsleep++
+ if rt.Kind == "instance" {
+ st.InstanceExpected++
+ if reporting[rt.Host] {
+ st.InstanceReporting++
+ } else if rt.Hibernate {
+ st.InstanceAsleep++
+ }
}
}
// Routes and Domains are parallel slices — Assemble appends one of each
@@ -130,9 +152,40 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
if len(versions) > 0 {
st.LatestVersion = versions[0].Version
}
+ st.Verdict, st.VerdictLabel = healthVerdict(st)
+ var mine []RouteView
+ for _, rt := range view.Routes {
+ if rt.Account == acct {
+ mine = append(mine, rt)
+ }
+ }
+ st.PrimaryHost = apexHost(mine)
return st
}
+// healthVerdict folds the dashboard's numbers into one word. Order matters:
+// an unreadable stable pointer means we don't know what's live, which
+// outranks everything; an app never promoted isn't unhealthy, it's not
+// deployed; and a failed sweep with real instances is "can't tell", never
+// "all good" — a verdict this page can't back is the lie the old Status
+// column told.
+func healthVerdict(st OverviewStats) (string, string) {
+ switch {
+ case st.LiveErr != "":
+ return "unknown", "Can't tell right now"
+ case !st.HasStable:
+ return "notdeployed", "Not deployed yet"
+ case st.DomainAttention > 0:
+ return "attention", "Needs attention"
+ case st.MetricsUnavailable && st.InstanceExpected > 0:
+ return "unknown", "Can't tell right now"
+ case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected:
+ return "attention", "Needs attention"
+ default:
+ return "ok", "All good"
+ }
+}
+
// 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.
@@ -158,6 +211,10 @@ type InstanceRow struct {
Status string // registry status; "" means ready
Hibernate bool
Metrics InstanceMetrics // zero-valued (Reporting false) when unswept
+
+ // HealthWord is the Status enum + hibernate + reporting folded into one
+ // plain word — the column's whole job.
+ HealthWord string
}
// assembleInstanceRows joins the app's routes with the sweep's readings, in
@@ -179,14 +236,41 @@ func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetric
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],
+ Host: rt.Host,
+ Channel: rt.Channel,
+ Version: rt.Version,
+ Kind: rt.Kind,
+ Status: status,
+ Hibernate: rt.Hibernate,
+ Metrics: byHost[rt.Host],
+ HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting()),
})
}
return rows
}
+
+// healthWord folds provisioning state, hibernation and liveness into the one
+// word the table shows. Provisioning words win while provisioning is real
+// (a cert mid-issue IS the situation); once the platform says ready, the
+// question becomes "is the process alive", which only instance-kind routes
+// can answer — static and service routes are served without a tenant
+// process, so ready means Serving, full stop.
+func healthWord(kind, status string, hibernate, reporting bool) string {
+ switch status {
+ case "pending", "creating", "starting", "issuing":
+ return "Setting up"
+ case "failed":
+ return "Failed"
+ }
+ if kind != "instance" {
+ return "Serving"
+ }
+ switch {
+ case reporting:
+ return "Running"
+ case hibernate:
+ return "Asleep"
+ default:
+ return "Not responding"
+ }
+}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index cab8018de..4ada7994e 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -79,13 +79,13 @@ func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
// and another account's same-named routes count for nothing at all.
func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
view := &AppView{App: "kass", Routes: []RouteView{
- {Host: "a", Account: "bab"},
- {Host: "b", Account: "bab"},
- {Host: "c", Account: "bab"},
- {Host: "d", Account: "bab", Hibernate: true},
- {Host: "e", Account: "bab"},
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "instance"},
+ {Host: "c", Account: "bab", Kind: "instance"},
+ {Host: "d", Account: "bab", Kind: "instance", Hibernate: true},
+ {Host: "e", Account: "bab", Kind: "instance"},
// Another account's same-named app: not this dashboard's instance.
- {Host: "theirs", Account: "cdef"},
+ {Host: "theirs", Account: "cdef", Kind: "instance"},
}}
metrics := []InstanceMetrics{
{Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)},
@@ -118,7 +118,7 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
// Nobody reported: nil, not zero. A zero here would render as "0%" and
// claim an idle app where there is simply no reading.
- quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab"}, {Host: "b", Account: "bab"}}}
+ quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab", Kind: "instance"}, {Host: "b", Account: "bab", Kind: "instance"}}}
quiet := assembleOverview(quietView, "bab", []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil)
if quiet.CPUSum != nil || quiet.MemSum != nil {
t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash",
@@ -139,6 +139,28 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) {
}
}
+// PrimaryHost is the apex of this account's OWN routes — Task 2's apexHost
+// reused here, so the headline's "live at" link names the same host
+// summarize's account table does (same fixture shape as
+// TestSummarizeVerdictAndURL in server_test.go).
+func TestOverviewPrimaryHost(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance"},
+ {Host: "kass.example.com", Account: "bab", Kind: "instance"},
+ // Another account's same-named app: not this dashboard's host.
+ {Host: "kass.theirs.oncarlos.com", Account: "cdef", Kind: "instance"},
+ }}
+ st := assembleOverview(view, "bab", nil, nil)
+ if st.PrimaryHost != "kass.example.com" {
+ t.Fatalf("PrimaryHost = %q, want the apex host kass.example.com", st.PrimaryHost)
+ }
+
+ empty := assembleOverview(&AppView{App: "kass"}, "bab", nil, nil)
+ if empty.PrimaryHost != "" {
+ t.Fatalf("PrimaryHost = %q, want empty with no routes", empty.PrimaryHost)
+ }
+}
+
// Attention means someone has to do something: DNS that is not pointing here,
// or issuance that failed. A cert mid-issuance is patience, not attention —
// badging it would make every new domain look broken for its first minute.
@@ -268,6 +290,35 @@ func TestInstanceRowsSkipOtherAccountsRoutes(t *testing.T) {
}
}
+// healthWord is the Instances table's whole Health column: provisioning
+// state and hibernation and liveness folded into the one word an owner
+// reads across the table.
+func TestInstanceRowHealthWord(t *testing.T) {
+ cases := []struct {
+ kind, status string
+ hibernate bool
+ reporting bool
+ want string
+ }{
+ {"instance", "ready", false, true, "Running"},
+ {"instance", "ready", true, false, "Asleep"},
+ {"instance", "ready", false, false, "Not responding"},
+ {"instance", "starting", false, false, "Setting up"},
+ {"instance", "pending", false, false, "Setting up"},
+ {"instance", "creating", false, false, "Setting up"},
+ {"instance", "issuing", false, false, "Setting up"},
+ {"instance", "failed", false, false, "Failed"},
+ {"static", "ready", false, false, "Serving"},
+ {"service", "ready", false, false, "Serving"},
+ }
+ for _, c := range cases {
+ got := healthWord(c.kind, c.status, c.hibernate, c.reporting)
+ if got != c.want {
+ t.Errorf("healthWord(%q,%q,hib=%v,rep=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, got, c.want)
+ }
+ }
+}
+
// The Overview page is the tiles, and only the tiles: the ladder moved to the
// Releases tab, and a copy left behind here would be two places to read the
// same state from.
@@ -282,13 +333,28 @@ func TestOverviewPageRendersTiles(t *testing.T) {
if !strings.Contains(body, `class="tile"`) {
t.Fatalf("no tiles on the Overview:\n%s", body)
}
- // The fixture's stable holds v1 — the number the "Live now" tile exists
- // to show.
- if !strings.Contains(body, "Live now") || !strings.Contains(body, "<code>v1</code>") {
- t.Fatalf("the Live now tile does not show what stable serves:\n%s", body)
+ // The fixture's stable holds v1 — the number the headline exists to show.
+ if !strings.Contains(body, "<code>v1</code>") {
+ t.Fatalf("the headline does not show what stable serves:\n%s", body)
+ }
+ // The headline also carries provenance — when it went live, restored here
+ // after Task 3's rewrite dropped the assertion.
+ if !strings.Contains(body, "published") {
+ t.Fatalf("the headline is missing its provenance (published ...):\n%s", body)
}
- if !strings.Contains(body, "on stable · promoted") {
- t.Fatalf("the Live now tile does not say when it was promoted:\n%s", body)
+ // The fixture's two domains need attention, which outranks everything
+ // else in healthVerdict — the headline verdict this page exists to show.
+ if !strings.Contains(body, "Needs attention") {
+ t.Fatalf("the headline verdict is missing:\n%s", body)
+ }
+ if !strings.Contains(body, "Machines") {
+ t.Fatalf("no Machines tile on the Overview:\n%s", body)
+ }
+ if strings.Contains(body, "stable pointer unreadable") {
+ t.Fatalf("stale tile copy still rendered:\n%s", body)
+ }
+ if strings.Contains(body, "Supply-chain bake") {
+ t.Fatalf("the Overview still says Supply-chain bake:\n%s", body)
}
if strings.Contains(body, `id="rung-`) {
t.Fatalf("the ladder is still rendered on the Overview:\n%s", body)
@@ -304,8 +370,8 @@ func TestOverviewInstancesPageRendersTable(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("instances page = %d:\n%s", status, body)
}
- for _, th := range []string{"Instance", "Channel", "Running", "Status", "CPU", "Memory"} {
- if !strings.Contains(body, "<th>"+th+"</th>") {
+ for _, th := range []string{"Address", "Health", "Version", "Channel", "CPU", "Memory"} {
+ if !strings.Contains(body, `<th scope="col">`+th+"</th>") {
t.Fatalf("the instances table has no %s column:\n%s", th, body)
}
}
@@ -315,3 +381,42 @@ func TestOverviewInstancesPageRendersTable(t *testing.T) {
t.Fatalf("the app's routes are not listed in the table:\n%s", body)
}
}
+
+func TestOverviewVerdict(t *testing.T) {
+ cases := []struct {
+ name string
+ st OverviewStats
+ verdict string
+ label string
+ }{
+ {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"},
+ {"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"},
+ {"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
+ {"static-only app has nothing expected", OverviewStats{HasStable: true, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"},
+ }
+ for _, c := range cases {
+ v, l := healthVerdict(c.st)
+ if v != c.verdict || l != c.label {
+ t.Errorf("%s: got %q/%q, want %q/%q", c.name, v, l, c.verdict, c.label)
+ }
+ }
+}
+
+func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
+ view := &AppView{App: "kass", Routes: []RouteView{
+ {Host: "a", Account: "bab", Kind: "instance"},
+ {Host: "b", Account: "bab", Kind: "static"},
+ {Host: "c", Account: "other", Kind: "instance"},
+ }}
+ st := assembleOverview(view, "bab", []InstanceMetrics{}, nil)
+ if st.InstanceExpected != 1 {
+ t.Errorf("InstanceExpected = %d, want 1 (instance-kind, this account only)", st.InstanceExpected)
+ }
+ if st.Verdict == "" || st.VerdictLabel == "" {
+ t.Error("assembleOverview must fill Verdict and VerdictLabel")
+ }
+}
diff --git a/internal/console/ownership.go b/internal/console/ownership.go
index 4e91b8bf6..7c045c132 100644
--- a/internal/console/ownership.go
+++ b/internal/console/ownership.go
@@ -359,11 +359,11 @@ func purgeAdvice(err error, app string) string {
return "“" + app + "” is not in the trash, so nothing was deleted. It may have been " +
"restored, or already deleted, since this page was loaded."
case strings.Contains(err.Error(), "cannot read the production flag"):
- return "Could not read the production flag for “" + app + "” — an unreadable flag is treated " +
- "as production, so deleting is refused. Re-declare it with the production switch on the " +
+ return "Could not read the production setting for “" + app + "” — an unreadable flag is treated " +
+ "as production, so deleting is refused. Set it again with the production switch on the " +
"app page, then try again."
case strings.Contains(err.Error(), "flagged production"):
- return "“" + app + "” is flagged production, so deleting is refused. Turn production off " +
+ return "“" + app + "” is marked as production, so deleting is refused. Turn production off " +
"first — the off-switch on the app page (account owner, passkey, and a hold on every box) " +
"is the only way through — then try again."
case strings.Contains(err.Error(), "no release public key"):
diff --git a/internal/console/releasesummary.go b/internal/console/releasesummary.go
new file mode 100644
index 000000000..2fd743a0a
--- /dev/null
+++ b/internal/console/releasesummary.go
@@ -0,0 +1,45 @@
+// The Versions tab's first-glance strip: what is live, and what is next in
+// line — the two facts a visitor came for, computed here so the template
+// renders them without touching the ladder. "Next" is beta's version when it
+// differs from stable's: the ladder's one real candidate step. Everything
+// deeper (rungs, gates, canaries) stays in the pipeline disclosure.
+package console
+
+import "time"
+
+type ReleaseSummary struct {
+ HasLive bool
+ LiveVersion string
+ LivePromotedAt time.Time
+ LiveActor string
+ LiveErr string
+ // NextVersion is beta's candidate when it differs from live; "" when
+ // there is nothing next. NextBakedUntil zero means it can go live now.
+ NextVersion string
+ NextBakedUntil time.Time
+}
+
+func assembleReleaseSummary(channels []ChannelView) ReleaseSummary {
+ var s ReleaseSummary
+ var beta ChannelView
+ for _, ch := range channels {
+ switch ch.Name {
+ case "stable":
+ if ch.Err != "" {
+ s.LiveErr = ch.Err
+ continue
+ }
+ s.HasLive = true
+ s.LiveVersion = ch.Version
+ s.LivePromotedAt = ch.PromotedAt
+ s.LiveActor = ch.Actor
+ case "beta":
+ beta = ch
+ }
+ }
+ if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion {
+ s.NextVersion = beta.Version
+ s.NextBakedUntil = beta.BakedUntil
+ }
+ return s
+}
diff --git a/internal/console/releasesummary_test.go b/internal/console/releasesummary_test.go
new file mode 100644
index 000000000..96b94f426
--- /dev/null
+++ b/internal/console/releasesummary_test.go
@@ -0,0 +1,34 @@
+package console
+
+import (
+ "testing"
+ "time"
+)
+
+func TestReleaseSummary(t *testing.T) {
+ now := time.Now()
+ chans := []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-48 * time.Hour), Actor: "paul@keymail.dev"},
+ {Name: "beta", Version: "e91ac22", PromotedAt: now.Add(-20 * time.Hour), BakedUntil: now.Add(4 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }
+ s := assembleReleaseSummary(chans)
+ if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" {
+ t.Errorf("live = %+v", s)
+ }
+ if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) {
+ t.Errorf("next = %+v", s)
+ }
+
+ same := assembleReleaseSummary([]ChannelView{
+ {Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"},
+ })
+ if same.NextVersion != "" {
+ t.Errorf("same version on beta must not be Next, got %q", same.NextVersion)
+ }
+
+ bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}})
+ if bad.HasLive || bad.LiveErr == "" || bad.NextVersion != "" {
+ t.Errorf("unreadable stable = %+v", bad)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 121523cbb..4a9f9226a 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -19,7 +19,6 @@ import (
"net/url"
"os"
"regexp"
- "sort"
"strings"
"time"
@@ -34,6 +33,10 @@ 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") },
+ // until renders a future horizon the way the cards speak: "~4h", never a
+ // timestamp — the safety-delay strip's "goes live ~4h" and Task 6's
+ // summary strip both read it this way.
+ "until": untilText,
// 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,
@@ -443,13 +446,21 @@ type page struct {
Flash string // one-shot success notice (cookie-carried across a redirect)
FlashErr string // one-shot error notice
- Apps []appRow // account home
- Detail *AppView // /a/{sqid}/apps/{app}
- Versions []VersionView // app page: shipped releases, newest first
- Tags []release.Tag // releases tab: every tag this app has cut, changelog and all
- Instances *InstanceReport // app page: the app's self-report, nil when unpolled
- Events []Event // app page: recent console actions, newest first
- CanPromote bool // a signing key is configured on this host
+ Apps []appRow // account home
+ Detail *AppView // /a/{sqid}/apps/{app}
+ Versions []VersionView // app page: shipped releases, newest first
+ Tags []release.Tag // releases tab: every tag this app has cut, changelog and all
+ // ReleaseSummary is the Releases tab's lede strip: what is live and
+ // what is next, folded from Detail.Channels once the tab has them.
+ ReleaseSummary ReleaseSummary
+ // PromoteChannels is the per-row promote select's option list: the
+ // ladder rungs (edge, beta, stable) so a fresh app with zero channels
+ // still has somewhere to promote its first release, unioned with any
+ // channels (including canary/*) the app already has.
+ PromoteChannels []string
+ Instances *InstanceReport // app page: the app's self-report, nil when unpolled
+ Events []Event // app page: recent console actions, newest first
+ CanPromote bool // a signing key is configured on this host
// App page: how a canary channel gets a URL, rendered as commands the
// reader can actually run from where they are reading. Computed
@@ -519,14 +530,22 @@ type page struct {
// IsOwner is the template's one permission question.
func (p page) IsOwner() bool { return p.Role == RoleOwner }
-// appRow is one line of the "your apps" table.
+// appRow is one line of the "your apps" table: the app, where it's running,
+// whether anything the REGISTRY tracks needs attention, and what's live.
+// The verdict here is registry-scope by design — process liveness only
+// exists in the per-app metrics sweep, which is too heavy to run across a
+// whole account's list. So the healthy word is "Deployed", never "All
+// good": the list claims exactly what it knows, and the app's Overview
+// (which has the sweep) gives the full verdict. Publishing per-app liveness
+// into the aggregate feed so this list can say more is a filed follow-up.
type appRow struct {
- App string
- Hosts int
- Instances int
- Statuses string
- Stable string
- Canaries string
+ App string
+ URL string // apex host, "" when no routes
+ Verdict string // ok | attention | notdeployed | unknown
+ VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now
+ Stable string // live version, "" when none
+ StableAge string // humanAge of its promotion, "" when unknown
+ CanaryN int // count of canary/* channels
}
func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
@@ -1286,7 +1305,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) {
return
}
for _, v := range views {
- p.Apps = append(p.Apps, summarize(v))
+ p.Apps = append(p.Apps, summarize(v, acct.Sqid))
}
}
// The Trash link, and only when there is something in it. Best-effort: a
@@ -1495,6 +1514,8 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)
+ p.PromoteChannels = promoteChannelOptions(p.Detail.Channels)
versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
if err != nil {
log.Printf("console: versions %s: %v", p.AppName, err)
@@ -1529,17 +1550,27 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
p.Tags = tags
}
p.Versions = versions
- // The canary form's "how do I give this a URL" copy — this tab's data, not
- // shared chrome: the canary form lives here and nowhere else. Bounded and
- // best-effort like every other store read behind an app page: an unreadable
- // manifest costs the concrete host shape, never the page.
- mctx, mcancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
- domain, absent := s.instanceDomainFor(mctx, acct.Sqid, p.AppName)
- mcancel()
- p.AttachCmd, p.AttachEnable = attachCommands(p.AppName, "canary/<name>", domain, s.cfg.AppsDomain, acct.Sqid, absent)
+ // The "how do I give this a URL" copy — not shared chrome (it's computed
+ // per app page, not carried by appPage), but the same fill the Instances
+ // tab's empty state shows: attachHint below.
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "canary/<name>")
s.render(w, http.StatusOK, "app_releases", p)
}
+// attachHint is the "give this channel a URL" advice shown on both the
+// Releases tab (the canary form's copy, channel "canary/<name>") and the
+// Instances tab's empty state (channel "stable" — the Running tab's empty
+// state should not be steering members onto a canary) — one bucket read and
+// one command shape per caller. Bounded and best-effort like every other
+// store read behind an app page: an unreadable instances manifest costs the
+// concrete host shape, never the page.
+func (s *Server) attachHint(ctx context.Context, sqid, app, channel string) (cmd, enable string) {
+ mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout)
+ defer mcancel()
+ domain, absent := s.instanceDomainFor(mctx, sqid, app)
+ return attachCommands(app, channel, domain, s.cfg.AppsDomain, sqid, absent)
+}
+
// 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
@@ -1552,6 +1583,9 @@ 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)
+ if len(p.InstanceRows) == 0 {
+ p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable")
+ }
s.render(w, http.StatusOK, "app_instances", p)
}
@@ -1899,16 +1933,16 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
// re-declare control), not a promote's side effect. baking is true
// here only because an unreadable flag fails TOWARD baking — which
// settles nothing about what this app has declared.
- msg += " The production flag could not be read, so the bake was not enabled — use the toggle on the app page."
+ msg += " The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab."
case baking:
// Already on: a stray checkbox on a repeat is a no-op, as before.
default:
if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr); err != nil {
log.Printf("console: enable_bake %s: %v", app, err)
- msg += " Enabling the supply-chain bake failed — use the toggle on the app page."
+ msg += " Turning the safety delay on failed — use the toggle on the app's Versions tab."
} else {
- s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on, at stable promote)")
- msg += " Supply-chain bake enabled."
+ s.audit(acct, app, addr, "production", "enabled the safety delay (production: on, at stable promote)")
+ msg += " Safety delay enabled."
baking = true
}
}
@@ -1919,7 +1953,7 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) {
// checkbox was ticked, the production flag — in a single bump.
s.bumpSerial(ctx)
if baking && release.HoldFor(release.DefaultHolds, channel) > 0 {
- msg += " Adopts after the bake window on each host — rollback never bakes."
+ msg += " It goes live on each machine after the safety delay — rollbacks skip it."
}
back("ok", msg)
}
@@ -2061,7 +2095,7 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) {
defer cancel()
if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, on, addr); err != nil {
log.Printf("console: production %s -> %v: %v", app, on, err)
- back("err", "Could not update the production flag: "+err.Error())
+ back("err", "Could not change the safety delay: "+err.Error())
return
}
// The flag is read on every box's adopt path (it decides whether a channel
@@ -2069,11 +2103,11 @@ func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) {
// releases held versions, and that is the direction people wait on.
s.bumpSerial(ctx)
if on {
- s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on)")
- back("ok", "Supply-chain bake enabled — beta and stable now hold before hosts adopt.")
+ s.audit(acct, app, addr, "production", "enabled the safety delay (production: on)")
+ back("ok", "Safety delay enabled — new versions now wait on beta and stable before going live.")
} else {
- s.audit(acct, app, addr, "production", "disabled the supply-chain bake (production: off)")
- back("ok", "Supply-chain bake turning off — each host honors it after the stable window (~72h) on its own clock.")
+ s.audit(acct, app, addr, "production", "disabled the safety delay (production: off)")
+ back("ok", "Safety delay turning off — takes effect on each machine after ~72h.")
}
}
@@ -2131,7 +2165,7 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
// → owner 404 → requireFreshAuth LAST) then leaves only this spot. The
// cost is a passkey tap spent on a refusal; the alternative is an oracle.
if release.HoldFor(release.DefaultHolds, channel) == 0 {
- back("err", "Channel "+channel+" has no bake window — there is nothing to fast-track.")
+ back("err", "Channel "+channel+" has no safety delay running — there is nothing to skip.")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
@@ -2147,21 +2181,21 @@ func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) {
return
}
if cur.Version != pinned {
- back("err", "Channel "+channel+" changed while you were confirming — review the fast-track again.")
+ back("err", "Channel "+channel+" changed while you were confirming — review skipping the delay again.")
return
}
}
ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr)
if err != nil {
log.Printf("console: fast-track %s %s: %v", app, channel, err)
- back("err", "Fast-track failed: "+err.Error())
+ back("err", "Skipping the delay failed: "+err.Error())
return
}
- s.audit(acct, app, addr, "fast-track", "waived the bake window for "+ver+" on "+channel)
+ s.audit(acct, app, addr, "fast-track", "skipped the safety delay for "+ver+" on "+channel)
// A waiver re-signs the pointer to say "adopt this now" — the one write
// whose entire point is that boxes act on it immediately.
s.bumpSerial(ctx)
- back("ok", "Fast-tracked "+ver+" on "+channel+" — hosts adopt on their next pass.")
+ back("ok", "Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes.")
}
// ---- canaries ---------------------------------------------------------------
@@ -2269,7 +2303,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
// so a registry hiccup just falls back to the generic "attach one" copy
// rather than failing the create that already succeeded.
if hosts, _ := s.channelHosts(app, channel); len(hosts) > 0 {
- back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (canaries never bake).")
+ back("ok", "Created canary "+name+" — live at https://"+hosts[0]+" serving "+version+" (test versions have no safety delay).")
return
}
domain, absent := s.instanceDomainFor(ctx, acct.Sqid, app)
@@ -2277,7 +2311,7 @@ func (s *Server) handleNewCanary(w http.ResponseWriter, r *http.Request) {
if enable != "" {
create = enable + " && " + create
}
- back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a host follows it, no bake. Attach one: "+create)
+ back("ok", "Created canary "+name+" — "+channel+" serves "+version+" the moment a machine follows it, no safety delay. Attach one: "+create)
}
// instanceDomainFor answers "where do this app's instance hosts live?" — the
@@ -2403,38 +2437,75 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) {
back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.")
}
-// summarize folds one AppView into its "your apps" table row.
-func summarize(v AppView) appRow {
- row := appRow{App: v.App, Hosts: len(v.Routes)}
- counts := map[string]int{}
- for _, rt := range v.Routes {
- if rt.Kind == "instance" {
- row.Instances++
+// promoteChannelOptions is the per-row promote select's option list: the
+// ladder rungs first (so a fresh app with zero channels — nothing in
+// existing — still has somewhere to promote its first release), then any of
+// the app's existing channels not already a rung (canary/* and the like),
+// in their existing order. Ladder rungs are included whether or not the app
+// has them yet — promoting onto a rung that doesn't exist is exactly how it
+// comes to exist.
+func promoteChannelOptions(existing []ChannelView) []string {
+ rungs := []string{release.RungEdge, release.RungBeta, release.RungStable}
+ seen := make(map[string]bool, len(rungs))
+ options := make([]string, 0, len(rungs)+len(existing))
+ for _, r := range rungs {
+ options = append(options, r)
+ seen[r] = true
+ }
+ for _, ch := range existing {
+ if seen[ch.Name] {
+ continue
}
- counts[rt.Status]++
+ seen[ch.Name] = true
+ options = append(options, ch.Name)
}
- statuses := make([]string, 0, len(counts))
- for st := range counts {
- statuses = append(statuses, st)
+ return options
+}
+
+// summarize folds one AppView into its "your apps" table row. Routes can
+// legitimately belong to another account (RouteView.Account's doc comment)
+// so both the apex-host pick and the failed-route scan are filtered to the
+// viewing account first — otherwise a foreign route can pick this app's URL
+// or trip its "needs attention" verdict.
+func summarize(v AppView, acct string) appRow {
+ var mine []RouteView
+ for _, rt := range v.Routes {
+ if rt.Account == acct {
+ mine = append(mine, rt)
+ }
}
- sort.Strings(statuses)
- parts := make([]string, 0, len(statuses))
- for _, st := range statuses {
- parts = append(parts, fmt.Sprintf("%d %s", counts[st], st))
+ row := appRow{App: v.App, URL: apexHost(mine)}
+ attention := false
+ for _, rt := range mine {
+ if rt.Status == "failed" {
+ attention = true
+ }
}
- row.Statuses = strings.Join(parts, ", ")
- var canaries []string
+ stableErr := false
for _, ch := range v.Channels {
- if ch.Err != "" {
- continue
- }
if ch.Name == "stable" {
+ if ch.Err != "" {
+ stableErr = true
+ continue
+ }
row.Stable = ch.Version
+ if !ch.PromotedAt.IsZero() {
+ row.StableAge = humanAge(time.Since(ch.PromotedAt))
+ }
}
- if strings.HasPrefix(ch.Name, "canary/") {
- canaries = append(canaries, ch.Name+" "+ch.Version)
+ if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") {
+ row.CanaryN++
}
}
- row.Canaries = strings.Join(canaries, ", ")
+ switch {
+ case stableErr:
+ row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
+ case attention:
+ row.Verdict, row.VerdictLabel = "attention", "Needs attention"
+ case row.Stable == "":
+ row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
+ default:
+ row.Verdict, row.VerdictLabel = "ok", "Deployed"
+ }
return row
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 226e4a1ee..71ad872ed 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -223,7 +223,10 @@ func TestConsoleE2E(t *testing.T) {
// First view: kass listed (and only kass), with the account-created flash.
_, _, body := get(t, c, fx.u("/"))
- for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "canary/x v2"} {
+ // The account home list shows the app, its apex host, a health verdict
+ // and the live version — not per-channel names/versions (that detail
+ // moved to the app's own Releases tab, asserted below).
+ for _, want := range []string{"Account created", "paul@keymail.dev", "kass", "v1", "Deployed", "1 test version"} {
if !strings.Contains(body, want) {
t.Fatalf("/apps missing %q in:\n%s", want, body)
}
@@ -256,12 +259,12 @@ func TestConsoleE2E(t *testing.T) {
}
}
- // Releases tab: the Channels table, with the bake approximation labeled.
+ // Releases tab: the Channels table, with the go-live estimate labeled.
status, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
if status != 200 {
t.Fatalf("/apps/kass/releases = %d", status)
}
- for _, want := range []string{"canary/x", "v2", "bakes until ~", "<td>live</td>", "approximation"} {
+ for _, want := range []string{"canary/x", "v2", "in ~", "<td>live now</td>", "estimates"} {
if !strings.Contains(rbody, want) {
t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody)
}
@@ -531,7 +534,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
t.Fatalf("overview still renders %q", gone)
}
}
- for _, want := range []string{`class="tiles stat-tiles"`, "Releases", "Domains", "Instances"} {
+ for _, want := range []string{`class="tiles stat-tiles"`, "Machines", "Web addresses", "Versions"} {
if !strings.Contains(body, want) {
t.Fatalf("overview missing %q", want)
}
@@ -547,8 +550,8 @@ func TestReleasesTab(t *testing.T) {
t.Fatalf("GET releases tab = %d", status)
}
for _, want := range []string{
- `aria-current="page">Releases</a>`,
- "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
+ `aria-current="page">Versions</a>`,
+ "<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (",
} {
if !strings.Contains(body, want) {
t.Fatalf("releases tab missing %q", want)
@@ -565,6 +568,92 @@ func TestReleasesTab(t *testing.T) {
assertNonMember404(t, fx, "/apps/kass/releases")
}
+// TestReleaseSummaryStripRenders pins the Make-live strip's form (M6, final
+// review): an owner with beta ahead of stable gets a real promote form
+// (channel=stable, version=beta's), a non-owner member sees no form at all —
+// just the "Only an account owner can make it live." note, since the fixture
+// is keyed and CanPromote is true either way.
+func TestReleaseSummaryStripRenders(t *testing.T) {
+ fx := newConsoleFixture(t)
+ shipVersion(t, fx, "kass", "v3")
+ seedChannel(t, fx, "kass", "beta", "v3")
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ // The hidden channel=stable input is unique to the make-live strip's form
+ // (the per-row promote forms elsewhere on this tab use a <select>, not a
+ // hidden "stable" input), so its presence pins that specific form.
+ if !strings.Contains(body, `<input type="hidden" name="channel" value="stable">`) {
+ t.Fatal("owner view missing the make-live promote form's channel=stable input")
+ }
+ if !strings.Contains(body, `<input type="hidden" name="version" value="v3">`) {
+ t.Fatalf("make-live form missing its version=v3 hidden input:\n%s", body)
+ }
+ if !strings.Contains(body, `<input type="hidden" name="csrf" value="`) {
+ t.Fatal("make-live form missing its csrf input")
+ }
+ if strings.Contains(body, "Only an account owner can make it live.") {
+ t.Fatal("owner view should not show the owner-only note")
+ }
+
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("kim@keymail.dev")
+ member := newBrowser(t)
+ signUp(t, fx, member, "code-kim-release-strip")
+ _, _, mBody := get(t, member, fx.u("/apps/kass/releases"))
+ if strings.Contains(mBody, `<input type="hidden" name="channel" value="stable">`) {
+ t.Fatal("member view rendered the owner-only make-live form")
+ }
+ if !strings.Contains(mBody, "Only an account owner can make it live.") {
+ t.Fatal("member view missing the owner-only note")
+ }
+}
+
+// TestFirstPromoteChannelOptions (I4, final review): a fresh app has shipped
+// a release but has zero channels yet — the per-row promote select must
+// still offer the ladder rungs, so the first promotion has somewhere to go.
+func TestFirstPromoteChannelOptions(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "freshapp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ shipVersion(t, fx, "freshapp", "v1")
+
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/freshapp/releases"))
+ want := `<option value="edge">edge</option><option value="beta">beta</option><option value="stable">stable</option>`
+ if !strings.Contains(body, want) {
+ t.Fatalf("promote select missing the ladder-rung options in order:\n%s", body)
+ }
+ // The now-true copy this union restores: still says "ship a release, then
+ // promote it below" even though there IS a release, because the promote
+ // control the reader needs is right there in the versions table.
+ if !strings.Contains(body, "No channels yet") {
+ t.Fatal("zero-channel app missing the empty Channels-table copy")
+ }
+}
+
+// TestInstancesEmptyStateHintsStable (I2, final review): the Running tab's
+// empty state must steer a member at "give this a URL" onto stable, not
+// canary/<name> — a canary is a private test version, the wrong first door
+// for "how do I run this at all".
+func TestInstancesEmptyStateHintsStable(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "routeless", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/routeless/instances"))
+ if !strings.Contains(body, "-channel stable") {
+ t.Fatalf("routeless app's Running empty state missing -channel stable:\n%s", body)
+ }
+ if strings.Contains(body, "canary/") {
+ t.Fatalf("routeless app's Running empty state still points at a canary channel:\n%s", body)
+ }
+}
+
func TestInstancesTab(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -572,7 +661,7 @@ func TestInstancesTab(t *testing.T) {
if status != 200 {
t.Fatalf("GET instances tab = %d", status)
}
- if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ if !strings.Contains(body, `aria-current="page">Running</a>`) {
t.Fatal("tab not active")
}
assertNonMember404(t, fx, "/apps/kass/instances")
@@ -914,7 +1003,7 @@ func TestCanaryFlow(t *testing.T) {
// the box's registry on local disk), so a dashboard must never send its
// reader there — the whole point of the instances API path.
for _, want := range []string{
- "Created canary rc", "no bake", "canary/rc",
+ "Created canary rc", "no safety delay", "canary/rc",
"carlos instances enable -app kass", "carlos instances create -app kass", "-channel canary/rc",
} {
if !strings.Contains(body, want) {
@@ -1070,11 +1159,11 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before the app reports its own internal instances: no "Inside the app"
- // section — the tab is exactly the route-level view (which always
- // renders its own "Instances" header, fixture routes and all).
- if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Inside the app</h2>") {
- t.Fatal("\"Inside the app\" section rendered with nothing reporting")
+ // Before the app reports its own internal instances: no "Reported by the
+ // app itself" disclosure — the tab is exactly the route-level view (which
+ // always renders its own "Running" header, fixture routes and all).
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "Reported by the app itself") {
+ t.Fatal("\"Reported by the app itself\" section rendered with nothing reporting")
}
// Kass starts reporting its three training instances.
@@ -1083,7 +1172,7 @@ func TestAppPageInstances(t *testing.T) {
Status: "ready", App: "kass"})
_, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
- "<h2>Instances</h2>", "<h2>Inside the app</h2>", "manages its own internal instances",
+ "<h2>Running</h2>", "Reported by the app itself", "tracks its own internal workers",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
@@ -1204,7 +1293,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
csrf := sessionToken(t, fx, c)
_, _, body := get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "Promotions disabled: no signing key on this host.") {
+ if !strings.Contains(body, "Deploys are unavailable on this console — it has no signing key.") {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
@@ -1752,18 +1841,27 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body)
}
// The per-host CPU/Mem readings now live as columns on the Instances
- // table itself (no separate Resources section) — each phrase below is
- // unique to the one fixture route it describes, so checking presence
- // page-wide still pins it to that route.
+ // table itself (no separate Resources section) — "11%"/"1.0 KB" are
+ // unique to owned.example.com's own row, so checking presence page-wide
+ // still pins them to that route. Asleep vs Not responding is the Health
+ // column's job, not the metrics cell's, and the fixture always carries
+ // other instance-kind rows (its own bystanders), so those two words are
+ // scoped to their own row below rather than checked page-wide — a
+ // page-wide Contains would pass even if the wrong row said it.
for _, want := range []string{
"owned.example.com", "11%", "1.0 KB",
- "asleep.example.com", "asleep — no recent readings",
- "unmetered.example.com", "no per-instance readings",
+ "asleep.example.com", "unmetered.example.com",
} {
if !strings.Contains(body, want) {
t.Fatalf("app page missing %q in:\n%s", want, body)
}
}
+ if row := instanceRow(t, body, "asleep.example.com"); !strings.Contains(row, "Asleep") {
+ t.Fatalf("asleep.example.com's own row did not read Asleep:\n%s", row)
+ }
+ if row := instanceRow(t, body, "unmetered.example.com"); !strings.Contains(row, "Not responding") || !strings.Contains(row, "no data") {
+ t.Fatalf("unmetered.example.com's own row did not read Not responding / no data:\n%s", row)
+ }
// The boundary: nothing about the other tenant, the host, or the box.
for _, forbidden := range []string{
"other-tenant", "box-secret-a", // other tenant's unit, the box's name
@@ -1789,14 +1887,14 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body)
}
- if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ if !strings.Contains(body, `aria-current="page">Running</a>`) {
t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
// Content, not just chrome: CPU/Mem readings now live as columns on the
// route's own row, so a failed metrics read fails closed there — the
- // owned route still renders, with "no per-instance readings" in place
- // of numbers, not a partially-rendered page.
- if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no per-instance readings") {
+ // owned route still renders, with "no data" in place of numbers, not a
+ // partially-rendered page.
+ if !strings.Contains(body, "owned.example.com") || !strings.Contains(body, "no data") {
t.Fatalf("instances tab did not render its route row with the metrics-failed state: %s", body)
}
}
@@ -1879,17 +1977,38 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
// the same "just the per-route metrics chrome" scope, at its new address.
func resourcesSection(t *testing.T, body string) string {
t.Helper()
- i := strings.Index(body, "<h2>Instances</h2>")
+ i := strings.Index(body, "<h2>Running</h2>")
if i < 0 {
- t.Fatalf("no Instances section in:\n%s", body)
+ t.Fatalf("no Running section in:\n%s", body)
}
j := strings.Index(body[i:], "</main>")
if j < 0 {
- t.Fatalf("Instances section never ended:\n%s", body[i:])
+ t.Fatalf("Running section never ended:\n%s", body[i:])
}
return body[i : i+j]
}
+// instanceRow returns the Running table's one <tr> naming the given host —
+// sliced from the host's own anchor through the row's closing tag. The
+// fixture always carries more than one route (its own bystanders plus
+// whatever a test adds), so a page-wide strings.Contains on a Health word
+// like "Not responding" or "Asleep" can be satisfied by SOME row without
+// pinning it to the row under test; scoping to the row is what actually
+// proves it.
+func instanceRow(t *testing.T, body, host string) string {
+ t.Helper()
+ i := strings.Index(body, host)
+ if i < 0 {
+ t.Fatalf("no row for host %q in:\n%s", host, body)
+ }
+ row := body[i:]
+ j := strings.Index(row, "</tr>")
+ if j < 0 {
+ t.Fatalf("row for host %q never closed in:\n%s", host, row)
+ }
+ return row[:j]
+}
+
// TestAppPageOwnerMetricsStaleBoxIsNotCurrent: a box that stopped publishing
// keeps its last sample inside the 48h sweep window forever. Rendering that
// as the instance's CURRENT figure would be a lie with no expiry, so a box
@@ -1918,8 +2037,18 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) {
if status != http.StatusOK {
t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
- if !strings.Contains(body, "asleep — no recent readings") {
- t.Fatalf("stale box did not render as no-recent-readings:\n%s", resourcesSection(t, body))
+ // The CPU/Mem cell reads the same "no data" a hibernating instance gets
+ // (staleness is not distinguishable from any other non-reporting cause at
+ // that cell); the route isn't hibernating, so the Health column must say
+ // so plainly rather than claim Asleep. Scoped to owned.example.com's own
+ // row: the fixture also carries kass.carlosapp.dev, an instance-kind
+ // route that is never metered and never hibernating, which renders "Not
+ // responding" on every run of this page regardless of what this test
+ // does — a page-wide Contains would pass even with the wrong route
+ // hibernating.
+ row := instanceRow(t, body, "owned.example.com")
+ if !strings.Contains(row, "Not responding") || !strings.Contains(row, "no data") {
+ t.Fatalf("stale box did not render as not responding with no data:\n%s", row)
}
for _, forbidden := range []string{"23%", "2.0 KB"} {
if strings.Contains(body, forbidden) {
@@ -2279,6 +2408,63 @@ func TestRedirectHostsRootlessTargetStaysOnTheOrigin(t *testing.T) {
}
}
+func TestSummarizeVerdictAndURL(t *testing.T) {
+ now := time.Now()
+ v := AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"},
+ {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready"},
+ },
+ Channels: []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
+ {Name: "canary/rc-1", Version: "aaa1111"},
+ }}
+ row := summarize(v, "bab")
+ if row.URL != "kass.example.com" {
+ t.Errorf("URL = %q, want the apex host kass.example.com", row.URL)
+ }
+ if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
+ t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
+ }
+ if row.CanaryN != 1 {
+ t.Errorf("CanaryN = %d, want 1", row.CanaryN)
+ }
+
+ v.Routes[0].Status = "failed"
+ row = summarize(v, "bab")
+ if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" {
+ t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel)
+ }
+
+ empty := summarize(AppView{App: "new"}, "bab")
+ if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" {
+ t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel)
+ }
+
+ bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}, "bab")
+ if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" {
+ t.Errorf("stable err: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel)
+ }
+
+ // A foreign account's route must not draw this row's URL or trip its
+ // verdict — draws the tenant boundary summarize is supposed to enforce.
+ foreign := AppView{App: "kass",
+ Routes: []RouteView{
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"},
+ {Host: "a.a", Account: "other", Kind: "instance", Status: "failed"},
+ },
+ Channels: []ChannelView{
+ {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
+ }}
+ row = summarize(foreign, "bab")
+ if row.Verdict != "ok" || row.VerdictLabel != "Deployed" {
+ t.Errorf("foreign failed route leaked into verdict: %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel)
+ }
+ if row.URL != "kass.bab.oncarlos.com" {
+ t.Errorf("URL = %q, want the own-account host kass.bab.oncarlos.com, not the foreign apex-ier a.a", row.URL)
+ }
+}
+
func TestNormalizeHost(t *testing.T) {
for _, tc := range []struct{ in, want string }{
{"dashboard.example.test", "dashboard.example.test"},
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html
index ef50d2681..62f390e41 100644
--- a/internal/console/templates/app_instances.html
+++ b/internal/console/templates/app_instances.html
@@ -3,42 +3,44 @@
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-<h2>Instances</h2>
+<h2>Running</h2>
{{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>
+ <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr>
{{range .InstanceRows}}
<tr>
- <td><a href="https://{{.Host}}">{{.Host}}</a><span class="small"> · {{.Kind}}</span></td>
+ <td><a href="https://{{.Host}}">{{.Host}}</a></td>
+ <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}</td>
+ <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</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>
+ <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" 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" aria-hidden="true" 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>
+ <td colspan="2"><span class="small">no data</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>
+<p class="small">Version is what each machine reports it is running — a dash means
+it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn
+against that machine's own peak.</p>
{{else}}
-<p class="small">No instances yet — a host appears here when a route wires it
-to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
+<p>Nothing running yet. Publish a version, then give it a web address:</p>
+{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
+{{.AttachCmd}}</code></pre>{{else if .AttachCmd}}<pre><code>{{.AttachCmd}}</code></pre>{{end}}
+<p class="small">The address comes live within a couple of minutes.</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>
+<details>
+<summary>Reported by the app itself ({{len .Instances.Instances}})</summary>
+<p class="small">This app tracks its own internal workers and reports them to the platform.</p>
<div class="tablewrap">
<table>
- <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
+ <tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Region</th><th scope="col">Detail</th></tr>
{{range .Instances.Instances}}
<tr>
<td><code>{{.Slug}}</code></td>
@@ -49,5 +51,6 @@ to this app: <code>carlos add -app {{.Detail.App}} &lt;host&gt;</code>.</p>
{{end}}
</table>
</div>
+</details>
{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 2afa9ea83..de5a45ea1 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -14,42 +14,36 @@
</p>
{{end}}
{{with .Overview}}
+<section class="pagehead" aria-label="App status">
+ <p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p>
+ {{if .HasStable}}
+ <p>{{$.AppName}} is live{{if .PrimaryHost}} at <a href="https://{{.PrimaryHost}}">{{.PrimaryHost}}</a>{{end}} —
+ running <code>{{.LiveVersion}}</code>, published {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}.</p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — the record of what's live didn't read. It usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet. Publish a first version from your terminal — the command is on the
+ <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p>
+ {{end}}
+</section>
<section class="tiles stat-tiles" aria-label="Vital statistics">
- <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
- <h2>Live now</h2>
- {{if .HasStable}}
- <p class="tile-value"><code>{{.LiveVersion}}</code></p>
- <p class="tile-detail">on stable · promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</p>
- {{else if .LiveErr}}
- <p class="tile-value">—</p>
- <p class="tile-detail error">stable pointer unreadable</p>
- {{else}}
- <p class="tile-value">not live</p>
- <p class="tile-detail">nothing on stable yet</p>
- {{end}}
- </a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
- <h2>Instances</h2>
+ <h2>Machines</h2>
<p class="tile-value">{{.InstanceTotal}}</p>
- <p class="tile-detail">{{if eq .InstanceTotal 0}}no hosts yet{{else if .MetricsUnavailable}}readings unavailable{{else}}{{.InstanceReporting}} reporting{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
- </a>
- <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
- <h2>CPU</h2>
- <p class="tile-value">{{pct .CPUSum}}</p>
- <p class="tile-detail">{{if .CPUSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
+ <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances">
- <h2>Memory</h2>
- <p class="tile-value">{{bytesOrDash .MemSum}}</p>
- <p class="tile-detail">{{if .MemSum}}across {{.InstanceReporting}} reporting{{else if .MetricsUnavailable}}readings unavailable{{else}}no recent readings{{end}}</p>
+ <h2>Resources</h2>
+ <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p>
+ <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings">
- <h2>Domains</h2>
+ <h2>Web addresses</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>
+ <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p>
</a>
<a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases">
- <h2>Releases</h2>
+ <h2>Versions</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>
@@ -57,13 +51,13 @@
{{end}}
{{with .Detail.Production}}
{{if .Err}}
-<p class="small error">Supply-chain bake: flag unreadable ({{.Err}}) — hosts keep baking. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Releases</a> page.</p>
+<p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</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>
+<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</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>
+<p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.</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>
+<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p>
{{end}}
{{end}}
{{if .Events}}
@@ -75,5 +69,5 @@
</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}}
+{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 1d4d9e65f..589f324fa 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -12,13 +12,82 @@
</p>
{{end}}
+{{with .ReleaseSummary}}
+<section class="pagehead" aria-label="Release status">
+ {{if .HasLive}}
+ <p>Live: <code>{{.LiveVersion}}</code> <span class="small">— promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</span></p>
+ {{else if .LiveErr}}
+ <p>We can't tell what's live right now — it usually recovers on its own; check again shortly.</p>
+ {{else}}
+ <p>Nothing is live yet.</p>
+ {{end}}
+ {{if .NextVersion}}
+ <p>Next up: <code>{{.NextVersion}}</code> on beta — {{if .NextBakedUntil.IsZero}}ready to go live now{{else}}goes live {{until .NextBakedUntil}}{{end}}.</p>
+ {{if and $.CanPromote $.IsOwner}}
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="channel" value="stable">
+ <input type="hidden" name="version" value="{{.NextVersion}}">
+ <input type="hidden" name="back" value="releases">
+ <button type="submit" class="btn">Make {{.NextVersion}} live</button>
+ </form>
+ {{else if $.CanPromote}}
+ <p class="small">Only an account owner can make it live.</p>
+ {{end}}
+ {{end}}
+</section>
+{{end}}
+
+{{if .Versions}}
+<details class="reveal" open>
+<summary>All versions ({{len .Versions}})</summary>
+<div class="tablewrap">
+<table>
+ <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
+ {{range .Versions}}
+ <tr>
+ <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
+ {{if .Err}}
+ <td colspan="3" class="error">{{.Err}}</td>
+ {{else}}
+ <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}}
+ <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details>
+ {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td>
+ <td>{{.Kind}}</td>
+ <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
+ {{end}}
+ {{if $.CanPromote}}<td>
+ <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="version" value="{{.Version}}">
+ <input type="hidden" name="back" value="releases">
+ <select name="channel" aria-label="Channel to promote {{.Version}} to">
+ {{range $.PromoteChannels}}<option value="{{.}}">{{.}}</option>{{end}}
+ </select>
+ <button type="submit" class="btn-sm">Promote</button>
+ </form>
+ </td>{{end}}
+ </tr>
+ {{end}}
+</table>
+</div>
+</details>
+{{else}}
+<p>Nothing shipped yet. Ship your first release from your repo:</p>
+<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 &lt;binary&gt;
+carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
+<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
+{{end}}
+
+<details class="reveal">
+<summary>Release pipeline — channels, test versions, and the promotion ladder</summary>
{{template "ladder" .}}
<h2>Channels</h2>
{{if .Detail.Channels}}
<div class="tablewrap">
<table>
- <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr>
+ <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Goes live</th><th></th></tr>
{{range .Detail.Channels}}
<tr>
<td>{{.Name}}</td>
@@ -27,7 +96,7 @@
{{else}}
<td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</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>
+ <td>{{if .BakedUntil.IsZero}}live now{{else}}in {{until .BakedUntil}}{{end}}{{if .FastTrack}} · delay skipped{{end}}</td>
{{end}}
<td>{{if and $.CanPromote .HasHistory (not .Err)}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback">
@@ -41,9 +110,7 @@
{{end}}
</table>
</div>
-<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>
+<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</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>
@@ -74,7 +141,7 @@ never bakes.</p>
{{end}}
</table>
</div>
-{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}}
+{{else}}<p class="small">No test versions — a test version is a private copy serving one chosen build immediately, with no safety delay.</p>{{end}}
{{if and .CanPromote .Versions}}
<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary">
<input type="hidden" name="csrf" value="{{.CSRF}}">
@@ -85,55 +152,15 @@ never bakes.</p>
</select>
<button type="submit" class="btn-sm">Create canary</button>
</form>
-<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel:</p>
+<p class="small">Creates <code>canary/&lt;name&gt;</code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:</p>
{{if .AttachEnable}}<pre><code>{{.AttachEnable}}
{{.AttachCmd}}</code></pre>
-<p class="small">This app has no instances manifest yet, so the first line enables one under this account&#39;s platform subdomain. The host comes live on its box&#39;s next provision pass.</p>
+<p class="small">This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.</p>
{{else}}<pre><code>{{.AttachCmd}}</code></pre>
-<p class="small">The host comes live on its box&#39;s next provision pass.</p>
+<p class="small">The address comes live within a couple of minutes.</p>
{{end}}
{{end}}
-<h2>Releases</h2>
-{{if .Versions}}
-<div class="tablewrap">
-<table>
- <tr><th>Version</th><th>Label</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
- {{range .Versions}}
- <tr>
- <td><code>{{.Version}}</code>{{if .Tag}} <span class="chip chip-env">{{.Tag}}</span>{{end}}</td>
- {{if .Err}}
- <td colspan="3" class="error">{{.Err}}</td>
- {{else}}
- <td>{{if .Label}}{{.Label}}{{end}}{{if .Notes}}
- <details><summary>notes</summary><pre class="changelog">{{.Notes}}</pre></details>
- {{end}}{{if and (not .Label) (not .Notes)}}—{{end}}</td>
- <td>{{.Kind}}</td>
- <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
- {{end}}
- {{if $.CanPromote}}<td>
- <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="version" value="{{.Version}}">
- <input type="hidden" name="back" value="releases">
- <select name="channel">
- {{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="btn-sm">Promote</button>
- </form>
- </td>{{end}}
- </tr>
- {{end}}
-</table>
-</div>
-{{else}}
-<p>Nothing shipped yet. Ship your first release from your repo:</p>
-<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 &lt;binary&gt;
-carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
-<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
-{{end}}
-
{{if .Tags}}
<h2>Tags</h2>
{{range .Tags}}
@@ -143,5 +170,6 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code
</details>
{{end}}
{{end}}
-{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+</details>
+{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}}
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 4e24ef77b..cc672ce26 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -88,8 +88,8 @@ offers the final console-record step.</p>
<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>
+good.{{if .Detail.Production.On}} It is marked as production right now, which
+blocks deletion — turn the safety delay off on the Versions tab 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>
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html
index 4f3b07c7f..d6696818c 100644
--- a/internal/console/templates/appnav.html
+++ b/internal/console/templates/appnav.html
@@ -5,8 +5,8 @@
</div>
<nav class="tabs" aria-label="App sections">
<a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
- <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
- <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a>
<a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
<a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
</nav>
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index ed3a97f5c..2189f45a4 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -6,15 +6,13 @@
<h1>{{.Account.Name}}</h1>
<div class="tablewrap">
<table>
- <tr><th>App</th><th>Hosts</th><th>Instances</th><th>Status</th><th>Stable</th><th>Canary</th></tr>
+ <tr><th scope="col">App</th><th scope="col">Running at</th><th scope="col">Health</th><th scope="col">Live version</th></tr>
{{range .Apps}}
<tr>
<td><a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a></td>
- <td>{{.Hosts}}</td>
- <td>{{.Instances}}</td>
- <td>{{if .Statuses}}{{.Statuses}}{{else}}—{{end}}</td>
- <td>{{if .Stable}}<code>{{.Stable}}</code>{{else}}—{{end}}</td>
- <td>{{if .Canaries}}{{.Canaries}}{{else}}—{{end}}</td>
+ <td>{{if .URL}}<a href="https://{{.URL}}">{{.URL}}</a>{{else}}—{{end}}</td>
+ <td><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></td>
+ <td>{{if .Stable}}<code>{{.Stable}}</code>{{if .StableAge}} <span class="small">· {{.StableAge}}</span>{{end}}{{else}}—{{end}}{{if .CanaryN}} <span class="chip chip-canary">{{.CanaryN}} test version{{if ne .CanaryN 1}}s{{end}}</span>{{end}}</td>
</tr>
{{end}}
</table>
@@ -24,7 +22,7 @@
<p>The account <strong>{{.Account.Name}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p>
{{end}}
{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
- restorable until the platform destroys them.</p>{{end}}
+ restorable for {{trashCooloffDays}} days.</p>{{end}}
<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/ladder.html b/internal/console/templates/ladder.html
index 26e69c263..4a57c86be 100644
--- a/internal/console/templates/ladder.html
+++ b/internal/console/templates/ladder.html
@@ -1,33 +1,33 @@
{{define "ladder"}}
-<h2>Ladder</h2>
-<p class="small">The same build climbs canary → edge → beta → stable; a
-promotion moves a signed pointer, and hosts follow their channel.</p>
+<h2>The ladder</h2>
+<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live).
+Promoting moves it one step up; the machines following that step pick it up.</p>
{{with .Detail.Production}}
<div class="bake">
{{if .Err}}
- <p class="small error">Supply-chain bake: flag unreadable: {{.Err}} — hosts keep baking.
+ <p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on.
{{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>
+ <button type="submit" class="linkish">Set it again</button>
</form>
— 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, stable 72h, on each host's own clock.
+ <p class="small">Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up.
{{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 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>
+ <span class="small">Turning it off asks for your passkey and takes effect after ~72h.</span>
{{end}}
</p>
{{else if .TurningOff}}
- <p class="small">Supply-chain bake: <strong>turning off</strong> — hosts honor it from ~{{fmtTime .EffectiveAt}}.
+ <p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -37,7 +37,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
{{end}}
</p>
{{else}}
- <p class="small">Supply-chain bake: off — hosts adopt promoted releases immediately.
+ <p class="small">Safety delay: off — new versions go live as soon as they're promoted.
{{if $.CanPromote}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/production" class="inline">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
@@ -48,7 +48,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
</p>
{{end}}
{{if .Nudge}}
- <p class="small nudge">{{icon "triangle-alert"}} This app looks production-wired but adopts releases immediately — consider enabling the supply-chain bake.</p>
+ <p class="small nudge">{{icon "triangle-alert"}} This app looks like it's serving real users but new versions go live immediately — consider turning the safety delay on.</p>
{{end}}
</div>
{{end}}
@@ -64,15 +64,15 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
{{else}}
<p><code>{{.Channel}}</code> holds <code>{{.Version}}</code>
<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 .Hotfix}}<span class="chip chip-stale">{{icon "zap"}} published directly — skipped the pipeline</span>{{end}}
+ {{if .FastTrack}}<span class="chip chip-stale">{{icon "fast-forward"}} delay skipped</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="btn-sm">Fast-track {{.Version}}</button>
+ <button type="submit" class="btn-sm btn-danger">Skip the delay for {{.Version}}</button>
</form>
- <span class="small">baking until ~{{fmtTime .BakedUntil}}</span>
+ <span class="small">goes live {{until .BakedUntil}}</span>
{{end}}
{{end}}
{{if .Routes}}
@@ -88,7 +88,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
{{end}}
</ul>
{{else}}
- <p class="small">No host follows this channel.</p>
+ <p class="small">No machine follows this channel.</p>
{{end}}
</div>
{{end}}
@@ -109,7 +109,7 @@ promotion moves a signed pointer, and hosts follow their channel.</p>
{{else if .Up.Note}}
<span class="small">{{.Up.Note}}</span>
{{else}}
- <span class="small">Promoting to {{.Up.To}} is the account owner's to do.</span>
+ <span class="small">Only an account owner can promote to {{.Up.To}}.</span>
{{end}}
</div>
{{end}}
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 55235fdab..820d2c96a 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -9,7 +9,7 @@
--bg: #ffffff; --ink: #201d29; --muted: #6f6480;
--accent: #5b3e96; --accent-strong: #4c3380;
--accent-soft: rgba(91, 62, 150, 0.09);
- --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f;
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; --good: #2f7a4f;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
}
@@ -18,7 +18,7 @@
--bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
--accent: #b394f2; --accent-strong: #c9b2f7;
--accent-soft: rgba(179, 148, 242, 0.12);
- --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f;
}
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -177,6 +177,14 @@
like .chip-canary but in a neutral tone: it sits on every route, not
just the ones worth a second look. */
.chip-env { border: 1px solid var(--rule); color: var(--muted); }
+ /* Health verdict chips. "attention" is the one filled variant here — the
+ row a reader must not skim past — in the warn tone, not .chip-prod's
+ red: a silent instance is a problem, not the unreviewed-build outage.
+ ok/notdeployed/unknown are bordered facts. */
+ .chip-health-ok { border: 1px solid var(--good); color: var(--good); }
+ .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); }
+ .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); }
+ .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); }
.chip .icon { vertical-align: -0.1em; margin-right: 0.2em; }
.chip-up { background: var(--accent-soft); color: #2f7a4f; }
.chip-down { background: var(--accent-soft); color: var(--bad); }
@@ -268,6 +276,9 @@
}
.pagehead h1 { margin: 0 0 0.35rem; }
.pagehead .lede { margin: 0; font-size: 1.05rem; }
+ /* Progressive-reveal sections: a page leads with the summary strip, and
+ the deeper tables/ladder sit behind an opt-in disclosure. */
+ .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; }
/* App tab bar (Heroku-style), and the account nav's active marker.
aria-current carries the state; the accent underline draws it. */
.tabs {
diff --git a/internal/console/templates/trash.html b/internal/console/templates/trash.html
index 5e3f9be48..997c149aa 100644
--- a/internal/console/templates/trash.html
+++ b/internal/console/templates/trash.html
@@ -35,8 +35,8 @@
<p class="small">Deleting now does what the 30-day wait would eventually do, immediately and
permanently: every object the app owns is destroyed and its name is freed. There is no undo
and no copy anywhere. Type the app's name to confirm, then confirm with your passkey — a
- signed-in browser on its own isn't enough. An app that is still flagged production is
- refused whatever you type.</p>
+ signed-in browser on its own isn't enough. An app still marked as production can't be
+ permanently deleted, whatever you type — turn its safety delay off first.</p>
{{range .Trashed}}
<form class="signin" method="post" action="{{$.Base}}/trash/delete">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
diff --git a/internal/console/until_test.go b/internal/console/until_test.go
new file mode 100644
index 000000000..16501cf25
--- /dev/null
+++ b/internal/console/until_test.go
@@ -0,0 +1,23 @@
+package console
+
+import (
+ "testing"
+ "time"
+)
+
+func TestUntilHelper(t *testing.T) {
+ // humanAge always renders both components in the 1-24h bucket
+ // ("4h0m", never bare "4h"), and untilText's own time.Until call runs a
+ // few microseconds after this Add — just enough for the floored minute
+ // to round down once. "~4h1m" covers the (vanishingly unlikely) case
+ // where the call lands before that rollover.
+ if got := untilText(time.Now().Add(4*time.Hour + time.Minute)); got != "~4h0m" && got != "~4h1m" {
+ t.Errorf("until ~4h = %q", got)
+ }
+ if got := untilText(time.Time{}); got != "now" {
+ t.Errorf("until zero = %q", got)
+ }
+ if got := untilText(time.Now().Add(-time.Hour)); got != "now" {
+ t.Errorf("until past = %q", got)
+ }
+}
diff --git a/internal/console/view.go b/internal/console/view.go
index 3bc79e6ca..58075ce25 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -692,6 +692,15 @@ func humanAge(d time.Duration) string {
}
}
+// untilText renders a future horizon the way the cards speak: "~4h", never
+// a timestamp. Past or zero is "now" — the wait is over, not unknown.
+func untilText(t time.Time) string {
+ if t.IsZero() || !t.After(time.Now()) {
+ return "now"
+ }
+ return "~" + humanAge(time.Until(t))
+}
+
// versionsCap bounds the app page's release list.
const versionsCap = 20