carlos / platform Public

console: apps list answers app/where/health/version, drops provisioning jargon

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 1737fb513cc4dd64c5aa2c62d5c957743da52850 parent 02a57f2
5 files changed, +122 −57
  • internal/console/ladder.go +27 −19
  • internal/console/server.go +39 −30
  • internal/console/server_test.go +43 −1
  • internal/console/templates/apps.html +5 −7
  • internal/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); }