console: apps list answers app/where/health/version, drops provisioning jargon
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+122
−57
internal/console/ladder.go+27 −19internal/console/server.go+39 −30internal/console/server_test.go+43 −1internal/console/templates/apps.html+5 −7internal/console/templates/layout.html+8 −0
diff --git a/internal/console/ladder.go b/internal/console/ladder.go| index 4b91fd02b..40fa1c813 100644 |
| --- a/internal/console/ladder.go |
| +++ b/internal/console/ladder.go |
| @@ -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/server.go b/internal/console/server.go| index 121523cbb..44a3c6734 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -19,7 +19,6 @@ import ( |
| "net/url" |
| "os" |
| "regexp" |
| - "sort" |
| "strings" |
| "time" |
| @@ -519,14 +518,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) { |
| @@ -2405,36 +2412,38 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| // 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{} |
| + row := appRow{App: v.App, URL: apexHost(v.Routes)} |
| + attention := false |
| for _, rt := range v.Routes { |
| - if rt.Kind == "instance" { |
| - row.Instances++ |
| + if rt.Status == "failed" { |
| + attention = true |
| } |
| - counts[rt.Status]++ |
| } |
| - statuses := make([]string, 0, len(counts)) |
| - for st := range counts { |
| - statuses = append(statuses, st) |
| - } |
| - sort.Strings(statuses) |
| - parts := make([]string, 0, len(statuses)) |
| - for _, st := range statuses { |
| - parts = append(parts, fmt.Sprintf("%d %s", counts[st], st)) |
| - } |
| - 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..15427667b 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) |
| } |
| @@ -2279,6 +2282,45 @@ 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) |
| + 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) |
| + } |
| +} |
| + |
| func TestNormalizeHost(t *testing.T) { |
| for _, tc := range []struct{ in, want string }{ |
| {"dashboard.example.test", "dashboard.example.test"}, |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index ed3a97f5c..ee13be58e 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 30 days.</p>{{end}} |
| <p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index 55235fdab..44e7f76eb 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -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 #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); } |
| .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); } |