Merge pull request #128 from carlosframework/console-clarity-batch
console: clarity batch — health verdict, safety delay, progressive reveal (P0s+P1s)
27 files changed,
+1912
−319
docs/superpowers/plans/2026-08-09-console-clarity-batch.md+993 −0internal/console/apicli.go+3 −3internal/console/appsettings.go+2 −2internal/console/appsettings_test.go+1 −1internal/console/bakecontrols_test.go+11 −11internal/console/confirm.go+12 −12internal/console/confirm_test.go+2 −2internal/console/ladder.go+32 −24internal/console/ladder_test.go+14 −13internal/console/overview.go+95 −11internal/console/overview_test.go+120 −15internal/console/ownership.go+3 −3internal/console/releasesummary.go+45 −0internal/console/releasesummary_test.go+34 −0internal/console/server.go+135 −64internal/console/server_test.go+215 −29internal/console/templates/app_instances.html+20 −17internal/console/templates/app_overview.html+25 −31internal/console/templates/app_releases.html+78 −50internal/console/templates/app_settings.html+2 −2internal/console/templates/appnav.html+2 −2internal/console/templates/apps.html+5 −7internal/console/templates/ladder.html+16 −16internal/console/templates/layout.html+13 −2internal/console/templates/trash.html+2 −2internal/console/until_test.go+23 −0internal/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/<name></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 <name></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}} <host></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}} <host></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 <binary> |
| +carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></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/<name></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/<name></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's platform subdomain. The host comes live on its box'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'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 <binary> |
| -carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></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 <site-dir></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 |