carlos / platform Public

Merge branch 'console-ux-01-status' into main

paul@keymail.dev 64972fd9c0aff9050e13db6137dc6544935482b8 parent 08f2789parent 7c18daf
18 files changed, +451 −198
  • internal/console/accountreview_test.go +2 −2
  • internal/console/deploymentstatus.go +70 −0
  • internal/console/deploymentstatus_test.go +49 −0
  • internal/console/economicspage_test.go +2 −2
  • internal/console/overview.go +19 −47
  • internal/console/overview_test.go +93 −61
  • internal/console/pipelinepage_test.go +4 −4
  • internal/console/releasesummary.go +12 −27
  • internal/console/releasesummary_test.go +10 −11
  • internal/console/server.go +18 −22
  • internal/console/server_test.go +36 −5
  • internal/console/statuscopy.go +40 −0
  • internal/console/statuscopy_test.go +70 −0
  • internal/console/templates/app_overview.html +10 −7
  • internal/console/templates/app_releases.html +11 −6
  • internal/console/templates/apps.html +2 −2
  • internal/console/templates/economics_accounts.html +1 −2
  • internal/console/view.go +2 −0

CI passed — run details

diff --git a/internal/console/accountreview_test.go b/internal/console/accountreview_test.go
index 6dc05deca..67e5493e0 100644
--- a/internal/console/accountreview_test.go
+++ b/internal/console/accountreview_test.go
@@ -214,7 +214,7 @@ func TestPublishedAppWithoutOwnedInstancesWarnsFromOwnedStateOnly(t *testing.T)
c := signInPaul(t, fx)
_, _, empty := get(t, c, fx.u("/apps/empty"))
- if strings.Contains(empty, approvedMismatchCopy.NoInstances) {
+ if strings.Contains(empty, `<p class="banner">`+approvedMismatchCopy.NoInstances) {
t.Fatalf("an unshipped empty app shows the published-app warning:\n%s", empty)
}
@@ -228,7 +228,7 @@ func TestPublishedAppWithoutOwnedInstancesWarnsFromOwnedStateOnly(t *testing.T)
}
seedChannel(t, fx, "empty", "stable", "v1")
_, _, absentForeign := get(t, c, fx.u("/apps/empty"))
- if !strings.Contains(absentForeign, approvedMismatchCopy.NoInstances) ||
+ if !strings.Contains(absentForeign, `<p class="banner">`+approvedMismatchCopy.NoInstances) ||
!strings.Contains(absentForeign, `/apps/empty/instances">Instances</a>`) {
t.Fatalf("published app with no owned instances lacks the warning and Instances link:\n%s", absentForeign)
}
diff --git a/internal/console/deploymentstatus.go b/internal/console/deploymentstatus.go
new file mode 100644
index 000000000..15837f8fb
--- /dev/null
+++ b/internal/console/deploymentstatus.go
@@ -0,0 +1,70 @@
+package console
+
+import "sort"
+
+// DeploymentVersion is one version reported by an app's owned routes.
+type DeploymentVersion struct {
+ Version string
+ Count int
+}
+
+// DeploymentStatus describes observed adoption independently of channel
+// pointers. A pointer says what a route should adopt; Route.Version says what
+// that address actually reports serving.
+type DeploymentStatus struct {
+ RouteCount int
+ ReportableCount int
+ ReportedCount int
+ UnknownCount int
+ Versions []DeploymentVersion
+}
+
+func (d DeploymentStatus) HasRoutes() bool { return d.RouteCount > 0 }
+
+func (d DeploymentStatus) HasReportable() bool { return d.ReportableCount > 0 }
+
+func (d DeploymentStatus) HasReportedVersion() bool { return d.ReportedCount > 0 }
+
+func (d DeploymentStatus) Mixed() bool { return len(d.Versions) > 1 }
+
+// UniformVersion is non-empty only when every reported route agrees. Unknown
+// routes do not manufacture disagreement: callers use UnknownCount to expose
+// that the observation is partial.
+func (d DeploymentStatus) UniformVersion() string {
+ if len(d.Versions) == 1 {
+ return d.Versions[0].Version
+ }
+ return ""
+}
+
+// observedDeployment folds only routes owned by the viewed account. Assemble
+// groups routes by app name across accounts, so this is a tenant boundary.
+func observedDeployment(routes []RouteView, acct string) DeploymentStatus {
+ counts := make(map[string]int)
+ var d DeploymentStatus
+ for _, rt := range routes {
+ if rt.Account != acct || rt.Platform {
+ continue
+ }
+ d.RouteCount++
+ if rt.Kind != "" && rt.Kind != "instance" {
+ continue
+ }
+ d.ReportableCount++
+ if rt.Version == "" {
+ d.UnknownCount++
+ continue
+ }
+ d.ReportedCount++
+ counts[rt.Version]++
+ }
+ versions := make([]string, 0, len(counts))
+ for version := range counts {
+ versions = append(versions, version)
+ }
+ sort.Strings(versions)
+ for _, version := range versions {
+ d.Versions = append(d.Versions, DeploymentVersion{Version: version, Count: counts[version]})
+ }
+ return d
+}
diff --git a/internal/console/deploymentstatus_test.go b/internal/console/deploymentstatus_test.go
new file mode 100644
index 000000000..48a836311
--- /dev/null
+++ b/internal/console/deploymentstatus_test.go
@@ -0,0 +1,49 @@
+package console
+
+import "testing"
+
+func TestObservedDeploymentUsesOwnedRouteReports(t *testing.T) {
+ routes := []RouteView{
+ {Host: "edge.example", Account: "bab", Channel: "edge", Version: "v2"},
+ {Host: "custom.example", Account: "bab", Channel: "production", Version: "v2"},
+ {Host: "canary.example", Account: "bab", Channel: "canary/live", Version: "v3"},
+ {Host: "unknown.example", Account: "bab", Channel: "edge"},
+ {Host: "foreign.example", Account: "other", Channel: "stable", Version: "secret"},
+ }
+ d := observedDeployment(routes, "bab")
+ if d.RouteCount != 4 || d.ReportableCount != 4 || d.ReportedCount != 3 || d.UnknownCount != 1 {
+ t.Fatalf("counts = %+v, want 4 routes, 3 reported, 1 unknown", d)
+ }
+ if !d.HasRoutes() || !d.HasReportedVersion() || !d.Mixed() || d.UniformVersion() != "" {
+ t.Fatalf("shape = %+v, want a mixed observed deployment", d)
+ }
+ if len(d.Versions) != 2 || d.Versions[0] != (DeploymentVersion{Version: "v2", Count: 2}) ||
+ d.Versions[1] != (DeploymentVersion{Version: "v3", Count: 1}) {
+ t.Fatalf("versions = %+v, want deterministic v2:2, v3:1", d.Versions)
+ }
+}
+
+func TestObservedDeploymentSeparatesNoRoutesFromUnknownReports(t *testing.T) {
+ empty := observedDeployment(nil, "bab")
+ if empty.HasRoutes() || empty.HasReportedVersion() {
+ t.Fatalf("no routes = %+v", empty)
+ }
+ unknown := observedDeployment([]RouteView{
+ {Host: "sleeping.example", Account: "bab", Kind: "instance", Hibernate: true},
+ {Host: "service.example", Account: "bab", Kind: "service"},
+ {Host: "alias.example", Account: "bab", Platform: true, Version: "alias-version"},
+ }, "bab")
+ if !unknown.HasRoutes() || unknown.HasReportedVersion() || unknown.RouteCount != 2 || unknown.ReportableCount != 1 || unknown.UnknownCount != 1 {
+ t.Fatalf("unknown route reports = %+v", unknown)
+ }
+}
+
+func TestObservedDeploymentDoesNotCallStaticTargetObserved(t *testing.T) {
+ d := observedDeployment([]RouteView{
+ {Host: "site.example", Account: "bab", Kind: "static", Channel: "edge", Version: "stale-registry-value"},
+ {Host: "site.bab.oncarlos.com", Account: "bab", Platform: true},
+ }, "bab")
+ if d.RouteCount != 1 || d.ReportableCount != 0 || d.ReportedCount != 0 || d.UnknownCount != 0 || len(d.Versions) != 0 {
+ t.Fatalf("static deployment = %+v", d)
+ }
+}
diff --git a/internal/console/economicspage_test.go b/internal/console/economicspage_test.go
index f2cd2a16a..520990836 100644
--- a/internal/console/economicspage_test.go
+++ b/internal/console/economicspage_test.go
@@ -509,8 +509,8 @@ func TestEconomicsAccountsPageThreadsPeriod(t *testing.T) {
// TestEconomicsAccountsPageShowsAccountName (account-names batch): a row for
// an account with a known console name renders the NAME as primary text
// with the sqid muted+small beside it (class="small", the existing
-// secondary-text treatment — apps.html's StableAge span is the precedent;
-// there is no separate ".muted" CSS class, only the ".small" rule, which
+// secondary-text treatment; there is no separate ".muted" CSS class, only
+// the ".small" rule, which
// already carries var(--muted)). A usage-only ghost row — real traffic under
// a sqid with no accounts-table row at all, same shape as
// TestEconomicsAccountsPageTierFormOnlyForConsoleRows — renders the bare
diff --git a/internal/console/overview.go b/internal/console/overview.go
index 7cacc9c11..d5ba9e4f3 100644
--- a/internal/console/overview.go
+++ b/internal/console/overview.go
@@ -6,7 +6,6 @@ package console
import (
"strings"
- "time"
"github.com/carlosframework/platform/internal/registry"
)
@@ -15,15 +14,9 @@ import (
// checks before anything else. Every field is derived from platform state the
// page was already reading — this struct adds no bucket or registry I/O.
type OverviewStats struct {
- // What stable serves. HasStable false means the app has never been
- // promoted to stable — the dashboard's honest "not live yet" state.
- // LiveErr carries an unreadable stable pointer's error text: unknown,
- // which is not the same as not live.
- HasStable bool
- LiveVersion string
- LivePromotedAt time.Time
- LiveActor string
- LiveErr string
+ // Deployment is what this account's routes report serving. Channel
+ // pointers are desired targets, not proof that a route adopted them.
+ Deployment DeploymentStatus
// The app's instances (routes owned by the viewed account), and how many
// of them the last metrics sweep actually saw. The TOTAL comes from the
@@ -61,10 +54,6 @@ type OverviewStats struct {
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
@@ -93,20 +82,7 @@ type OverviewStats struct {
// wave its failed cert on it.
func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, versions []VersionView) OverviewStats {
st := OverviewStats{MetricsUnavailable: metrics == nil}
- for _, ch := range view.Channels {
- if ch.Name != "stable" {
- continue
- }
- if ch.Err != "" {
- st.LiveErr = ch.Err
- break
- }
- st.HasStable = true
- st.LiveVersion = ch.Version
- st.LivePromotedAt = ch.PromotedAt
- st.LiveActor = ch.Actor
- break
- }
+ st.Deployment = observedDeployment(view.Routes, acct)
reporting := make(map[string]bool, len(metrics))
for _, m := range metrics {
if !m.Reporting() {
@@ -127,7 +103,7 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
}
}
for _, rt := range view.Routes {
- if rt.Account != acct {
+ if rt.Account != acct || rt.Platform {
continue
}
st.InstanceTotal++
@@ -151,7 +127,7 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
// includes another tenant's failure does not.
if len(view.Domains) == len(view.Routes) {
for i, rt := range view.Routes {
- if rt.Account != acct {
+ if rt.Account != acct || rt.Platform {
continue
}
st.DomainTotal++
@@ -166,34 +142,30 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver
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.
+// no owned routes means not deployed; a route with no observed version is
+// unknown, never "all good"; and a failed sweep with real instances is
+// "can't tell" too. Channel pointer failures do not enter this verdict:
+// they obscure a target, not what the routes already report serving.
func healthVerdict(st OverviewStats) (string, string) {
switch {
- case st.LiveErr != "":
- return "unknown", "Can't tell right now"
- case !st.HasStable:
+ case !st.Deployment.HasRoutes():
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.InstanceCounted:
return "attention", "Needs attention"
+ case st.Deployment.ReportableCount == 0:
+ return "ok", "All good"
+ case !st.Deployment.HasReportedVersion():
+ return "unknown", "Can't tell right now"
+ case st.Deployment.UnknownCount > 0:
+ return "unknown", "Can't tell right now"
+ case st.MetricsUnavailable && st.InstanceExpected > 0:
+ return "unknown", "Can't tell right now"
default:
return "ok", "All good"
}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index 8e7ec1e3d..9a53152dd 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -25,52 +25,42 @@ import (
func f64(v float64) *float64 { return &v }
func i64(v int64) *int64 { return &v }
-func TestOverviewLiveVersionFromStable(t *testing.T) {
- promoted := time.Now().Add(-3 * time.Hour)
+func TestOverviewLiveVersionFromOwnedRoutes(t *testing.T) {
view := &AppView{App: "kass", Channels: []ChannelView{
{Name: "beta", Version: "v9"},
- {Name: "stable", Version: "v7", PromotedAt: promoted, Actor: "kim@keymail.dev"},
- }}
+ {Name: "stable", Version: "v7", Err: "obsolete unreadable pointer"},
+ }, Routes: []RouteView{{Host: "kass.example", Account: "bab", Channel: "edge", Version: "v9"}}}
st := assembleOverview(view, "bab", nil, nil)
- if !st.HasStable {
- t.Fatal("an app with a stable pointer is not reported live")
- }
- if st.LiveVersion != "v7" {
- t.Fatalf("LiveVersion = %q, want v7 (beta's v9 is not what users get)", st.LiveVersion)
- }
- if !st.LivePromotedAt.Equal(promoted) || st.LiveActor != "kim@keymail.dev" {
- t.Fatalf("promoted %v by %q, want %v by kim@keymail.dev", st.LivePromotedAt, st.LiveActor, promoted)
+ if !st.Deployment.HasReportedVersion() {
+ t.Fatal("an app with a reported route version is not reported deployed")
}
- if st.LiveErr != "" {
- t.Fatalf("LiveErr = %q on a readable pointer", st.LiveErr)
+ if st.Deployment.UniformVersion() != "v9" {
+ t.Fatalf("observed version = %q, want v9", st.Deployment.UniformVersion())
}
- // No stable at all: the honest "not live yet" state, and NOT an error.
- st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "edge", Version: "v9"}}}, "bab", nil, nil)
- if st.HasStable || st.LiveVersion != "" {
+ // A pointer with no owned route is a target, not a deployment.
+ st = assembleOverview(&AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Version: "v9"}}}, "bab", nil, nil)
+ if st.Deployment.HasReportedVersion() || st.Deployment.UniformVersion() != "" {
t.Fatalf("a never-promoted app reads as live: %+v", st)
}
- if st.LiveErr != "" {
- t.Fatalf("LiveErr = %q, want no error for an app that simply has no stable", st.LiveErr)
- }
}
// Unknown is not the same as not live. A stable pointer that will not verify
// says so — claiming "not live" would tell an owner their app is down when the
// only thing broken is this console's read of it.
-func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) {
+func TestOverviewUnknownRouteVersionIsUnknownNotOffline(t *testing.T) {
view := &AppView{App: "kass", Channels: []ChannelView{
{Name: "stable", Err: "signature does not verify"},
- }}
+ }, Routes: []RouteView{{Host: "kass.example", Account: "bab", Channel: "stable"}}}
st := assembleOverview(view, "bab", nil, nil)
- if st.HasStable {
+ if st.Deployment.HasReportedVersion() {
t.Fatal("an unreadable pointer was reported as a known live version")
}
- if st.LiveErr != "signature does not verify" {
- t.Fatalf("LiveErr = %q, want the pointer's own error text", st.LiveErr)
+ if st.Deployment.UniformVersion() != "" {
+ t.Fatalf("observed version = %q, want nothing claimed from an unreadable pointer", st.Deployment.UniformVersion())
}
- if st.LiveVersion != "" {
- t.Fatalf("LiveVersion = %q, want nothing claimed from an unreadable pointer", st.LiveVersion)
+ if st.Verdict != "unknown" {
+ t.Fatalf("Verdict = %q, want unknown for an address with no version report", st.Verdict)
}
}
@@ -167,35 +157,13 @@ func TestVerdictIgnoresNoReadings(t *testing.T) {
// 1 reporting + 1 asleep + 1 no-readings out of 3 counted... construct an
// OverviewStats where Reporting+Asleep == InstanceCounted and assert the
// verdict is NOT "attention" on account of the no-readings row.
- st := OverviewStats{HasStable: true, InstanceExpected: 3, InstanceCounted: 2,
+ st := OverviewStats{Deployment: DeploymentStatus{RouteCount: 3, ReportableCount: 3, ReportedCount: 3}, InstanceExpected: 3, InstanceCounted: 2,
InstanceReporting: 1, InstanceAsleep: 1, InstanceNoReadings: 1}
if v, _ := healthVerdict(st); v != "ok" {
t.Fatalf("no-readings rows must not trip the verdict: %s", v)
}
}
-// 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.
@@ -402,10 +370,11 @@ func TestOverviewPageRendersTiles(t *testing.T) {
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)
+ // A channel pointer's promotion time does not say when each route adopted
+ // it, so the observed deployment summary must not present that timestamp
+ // as adoption provenance.
+ if strings.Contains(body, "published") {
+ t.Fatalf("the observed deployment invents provenance from a channel pointer:\n%s", body)
}
// The fixture's two domains need attention, which outranks everything
// else in healthVerdict — the headline verdict this page exists to show.
@@ -533,14 +502,16 @@ func TestOverviewVerdict(t *testing.T) {
verdict string
label string
}{
- {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 2}, "ok", "All good"},
- {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
- {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1}, "attention", "Needs attention"},
- {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceCounted: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"},
+ {"all good", OverviewStats{Deployment: DeploymentStatus{RouteCount: 2, ReportableCount: 2, ReportedCount: 2}, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 2}, "ok", "All good"},
+ {"asleep counts as accounted for", OverviewStats{Deployment: DeploymentStatus{RouteCount: 2, ReportableCount: 2, ReportedCount: 2}, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"},
+ {"silent instance", OverviewStats{Deployment: DeploymentStatus{RouteCount: 2, ReportableCount: 2, ReportedCount: 2}, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"domain attention", OverviewStats{Deployment: DeploymentStatus{RouteCount: 1, ReportableCount: 1, ReportedCount: 1}, InstanceExpected: 1, InstanceCounted: 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"},
+ {"route version unknown", OverviewStats{Deployment: DeploymentStatus{RouteCount: 1, ReportableCount: 1, UnknownCount: 1}}, "unknown", "Can't tell right now"},
+ {"partial route versions", OverviewStats{Deployment: DeploymentStatus{RouteCount: 2, ReportableCount: 2, ReportedCount: 1, UnknownCount: 1}, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 2}, "unknown", "Can't tell right now"},
+ {"attention outranks partial versions", OverviewStats{Deployment: DeploymentStatus{RouteCount: 2, ReportableCount: 2, ReportedCount: 1, UnknownCount: 1}, InstanceExpected: 2, InstanceCounted: 2, InstanceReporting: 1}, "attention", "Needs attention"},
+ {"sweep failed with instances expected", OverviewStats{Deployment: DeploymentStatus{RouteCount: 1, ReportableCount: 1, ReportedCount: 1}, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"},
+ {"static-only app has nothing expected", OverviewStats{Deployment: DeploymentStatus{RouteCount: 1}, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"},
}
for _, c := range cases {
v, l := healthVerdict(c.st)
@@ -550,6 +521,67 @@ func TestOverviewVerdict(t *testing.T) {
}
}
+func TestOverviewAndAppListAgreeWhenFailedRouteHasNoVersion(t *testing.T) {
+ view := AppView{App: "kass", Routes: []RouteView{{
+ Host: "kass.example", Account: "bab", Kind: "instance", Status: "failed", Provisioned: true,
+ }}}
+ row := summarize(view, "bab")
+ overview := assembleOverview(&view, "bab", []InstanceMetrics{}, nil)
+ if row.Verdict != "attention" || overview.Verdict != "attention" {
+ t.Fatalf("list=%q overview=%q, want attention on both pages", row.Verdict, overview.Verdict)
+ }
+}
+
+func TestOverviewIgnoresPlatformAliasWithoutInventingStaticVersion(t *testing.T) {
+ view := AppView{
+ App: "site",
+ Routes: []RouteView{
+ {Host: "site.example", Account: "bab", Kind: "static", Channel: "edge"},
+ {Host: "site.bab.oncarlos.com", Account: "bab", Platform: true},
+ },
+ Channels: []ChannelView{{Name: "edge", Version: "site-v3"}},
+ }
+ st := assembleOverview(&view, "bab", []InstanceMetrics{}, nil)
+ if st.Verdict != "ok" || st.Deployment.RouteCount != 1 || st.Deployment.ReportableCount != 0 || st.Deployment.HasReportedVersion() {
+ t.Fatalf("overview = %+v, want a healthy static deployment with no invented observation", st)
+ }
+}
+
+func TestServiceOnlyAppIsPresentAndHealthyOnBothPages(t *testing.T) {
+ view := AppView{App: "proxy", Routes: []RouteView{{Host: "proxy.example", Account: "bab", Kind: "service", Status: "ready"}}}
+ row := summarize(view, "bab")
+ overview := assembleOverview(&view, "bab", []InstanceMetrics{}, nil)
+ if row.Verdict != "ok" || overview.Verdict != "ok" || !overview.Deployment.HasRoutes() || overview.Deployment.ReportableCount != 0 {
+ t.Fatalf("list=%+v overview=%+v, want a present healthy service with no version report", row, overview)
+ }
+}
+
+func TestStaticPlusSilentInstanceIsUnknownOnBothPages(t *testing.T) {
+ view := AppView{App: "mixed-kind", Routes: []RouteView{
+ {Host: "site.example", Account: "bab", Kind: "static"},
+ {Host: "app.example", Account: "bab", Kind: "instance"},
+ }}
+ row := summarize(view, "bab")
+ overview := assembleOverview(&view, "bab", []InstanceMetrics{}, nil)
+ if row.Verdict != "unknown" || overview.Verdict != "unknown" {
+ t.Fatalf("list=%q overview=%q, want unknown on both pages", row.Verdict, overview.Verdict)
+ }
+ if got := overview.Deployment.ReportingText(); got != "0 of 1 instances report a version." {
+ t.Fatalf("reporting text = %q", got)
+ }
+}
+
+func TestPlatformOnlyFailureDoesNotCreateCrossPageAttention(t *testing.T) {
+ view := AppView{App: "alias", Routes: []RouteView{{
+ Host: "alias.bab.oncarlos.com", Account: "bab", Platform: true, Status: "failed",
+ }}, Domains: []DomainView{{Guidance: "ignored platform guidance"}}}
+ row := summarize(view, "bab")
+ overview := assembleOverview(&view, "bab", []InstanceMetrics{}, nil)
+ if row.Verdict != "notdeployed" || overview.Verdict != "notdeployed" {
+ t.Fatalf("list=%q overview=%q, want notdeployed on both pages", row.Verdict, overview.Verdict)
+ }
+}
+
func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) {
view := &AppView{App: "kass", Routes: []RouteView{
{Host: "a", Account: "bab", Kind: "instance"},
diff --git a/internal/console/pipelinepage_test.go b/internal/console/pipelinepage_test.go
index d97816abe..f6d27c264 100644
--- a/internal/console/pipelinepage_test.go
+++ b/internal/console/pipelinepage_test.go
@@ -34,13 +34,13 @@ func TestOneChannelAppHidesChannelVocabulary(t *testing.T) {
t.Fatalf("app page = %d", status)
}
assertNoChannelVocabulary(t, body)
- // The releases list and the live marker are still there — hidden means
- // no CHANNEL vocabulary, not no page.
+ // The releases list and observed-deployment summary are still there —
+ // hidden means no CHANNEL vocabulary, not no page.
if !strings.Contains(body, "Nothing shipped yet") {
t.Errorf("one-channel app page does not show the releases list:\n%s", body)
}
- if !strings.Contains(body, "Nothing is live yet.") {
- t.Errorf("one-channel app page does not show the live marker:\n%s", body)
+ if !strings.Contains(body, `aria-label="Release status"`) {
+ t.Errorf("one-channel app page does not show the deployment summary:\n%s", body)
}
// Ship TWO builds and land both on the entry channel — still no
diff --git a/internal/console/releasesummary.go b/internal/console/releasesummary.go
index 2fd743a0a..91fb8850e 100644
--- a/internal/console/releasesummary.go
+++ b/internal/console/releasesummary.go
@@ -1,45 +1,30 @@
-// 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.
+// The Versions tab's first-glance strip separates what routes report serving
+// from channel targets. "Next" remains the legacy ladder's beta candidate
+// when it differs from stable; app-defined pipelines explain promotion in
+// their channel view instead.
package console
-import "time"
-
type ReleaseSummary struct {
- HasLive bool
- LiveVersion string
- LivePromotedAt time.Time
- LiveActor string
- LiveErr string
+ Deployment DeploymentStatus
// 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
+ // there is nothing next.
+ NextVersion string
}
-func assembleReleaseSummary(channels []ChannelView) ReleaseSummary {
- var s ReleaseSummary
+func assembleReleaseSummary(channels []ChannelView, routes []RouteView, acct string) ReleaseSummary {
+ s := ReleaseSummary{Deployment: observedDeployment(routes, acct)}
var beta ChannelView
+ var stable 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
+ stable = ch
case "beta":
beta = ch
}
}
- if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion {
+ if stable.Err == "" && beta.Err == "" && beta.Version != "" && beta.Version != stable.Version {
s.NextVersion = beta.Version
- s.NextBakedUntil = beta.BakedUntil
}
return s
}
diff --git a/internal/console/releasesummary_test.go b/internal/console/releasesummary_test.go
index 96b94f426..6371aade6 100644
--- a/internal/console/releasesummary_test.go
+++ b/internal/console/releasesummary_test.go
@@ -2,33 +2,32 @@ 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: "stable", Version: "d388035", Actor: "paul@keymail.dev"},
+ {Name: "beta", Version: "e91ac22"},
{Name: "canary/rc-1", Version: "aaa1111"},
}
- s := assembleReleaseSummary(chans)
- if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" {
+ routes := []RouteView{{Host: "edge.example", Account: "bab", Channel: "edge", Version: "route-v"}}
+ s := assembleReleaseSummary(chans, routes, "bab")
+ if !s.Deployment.HasReportedVersion() || s.Deployment.UniformVersion() != "route-v" {
t.Errorf("live = %+v", s)
}
- if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) {
+ if s.NextVersion != "e91ac22" {
t.Errorf("next = %+v", s)
}
same := assembleReleaseSummary([]ChannelView{
{Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"},
- })
+ }, []RouteView{{Host: "x", Account: "bab", Version: "v1"}}, "bab")
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)
+ bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}}, routes, "bab")
+ if !bad.Deployment.HasReportedVersion() || bad.Deployment.UniformVersion() != "route-v" || bad.NextVersion != "" {
+ t.Errorf("unreadable target changed observed deployment = %+v", bad)
}
}
diff --git a/internal/console/server.go b/internal/console/server.go
index ece9fcf7b..1b91ff9ed 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1316,6 +1316,7 @@ type page struct {
Error string
Welcome bool // one-shot "account created" banner
BrowserRestartCopy BrowserRestartCopy
+ StatusCopy StatusCopy
AccountReview AccountReview
MismatchCopy MismatchCopy
@@ -1616,7 +1617,8 @@ type appLogsCopy struct {
func (p page) IsOwner() bool { return p.Role == RoleOwner }
// 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.
+// whether anything the REGISTRY tracks needs attention, and what its owned
+// routes report serving.
// 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
@@ -1628,12 +1630,12 @@ type appRow struct {
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
+ Deployment DeploymentStatus
+ CanaryN int // count of canary/* channels
}
func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
+ p.StatusCopy = approvedStatusCopy
p.BrowserRestartCopy = approvedBrowserRestartCopy
p.MismatchCopy = approvedMismatchCopy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -2796,7 +2798,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "app_deployments", p)
return
}
- p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)
+ p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels, p.Detail.Routes, acct.Sqid)
p.PromoteChannels = promoteChannelOptions(p.Detail.Channels, p.Detail.Pipeline)
versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
if err != nil {
@@ -4427,36 +4429,30 @@ func summarize(v AppView, acct string) appRow {
mine = append(mine, rt)
}
}
- row := appRow{App: v.App, URL: apexHost(mine)}
+ deployment := observedDeployment(v.Routes, acct)
+ row := appRow{App: v.App, URL: apexHost(mine), Deployment: deployment}
attention := false
for _, rt := range mine {
- if rt.Status == "failed" {
+ if !rt.Platform && 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 !deployment.HasRoutes():
+ row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
case attention:
row.Verdict, row.VerdictLabel = "attention", "Needs attention"
- case row.Stable == "":
- row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet"
+ case deployment.ReportableCount == 0:
+ row.Verdict, row.VerdictLabel = "ok", "Deployed"
+ case !deployment.HasReportedVersion():
+ row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
+ case deployment.UnknownCount > 0:
+ row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now"
default:
row.Verdict, row.VerdictLabel = "ok", "Deployed"
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 5cd2e4203..9a0af8938 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -2612,8 +2612,8 @@ 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"},
+ {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"},
+ {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready", Version: "d388035"},
},
Channels: []ChannelView{
{Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)},
@@ -2629,6 +2629,9 @@ func TestSummarizeVerdictAndURL(t *testing.T) {
if row.CanaryN != 1 {
t.Errorf("CanaryN = %d, want 1", row.CanaryN)
}
+ if row.Deployment.UniformVersion() != "d388035" {
+ t.Errorf("observed version = %q, want d388035", row.Deployment.UniformVersion())
+ }
v.Routes[0].Status = "failed"
row = summarize(v, "bab")
@@ -2641,16 +2644,44 @@ func TestSummarizeVerdictAndURL(t *testing.T) {
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")
+ edgeOnly := summarize(AppView{App: "edge-only", Routes: []RouteView{
+ {Host: "edge.example", Account: "bab", Channel: "edge", Version: "v9"},
+ }}, "bab")
+ if edgeOnly.Verdict != "ok" || edgeOnly.Deployment.UniformVersion() != "v9" {
+ t.Errorf("edge-only route = %+v, want deployed at its observed v9", edgeOnly)
+ }
+
+ mixed := summarize(AppView{App: "mixed", Routes: []RouteView{
+ {Host: "a.example", Account: "bab", Channel: "production", Version: "v1"},
+ {Host: "b.example", Account: "bab", Channel: "canary/live", Version: "v2"},
+ }}, "bab")
+ if mixed.Verdict != "ok" || !mixed.Deployment.Mixed() || mixed.Deployment.UniformVersion() != "" {
+ t.Errorf("mixed deployment = %+v", mixed)
+ }
+
+ partial := summarize(AppView{App: "partial", Routes: []RouteView{
+ {Host: "a.example", Account: "bab", Version: "v1"},
+ {Host: "b.example", Account: "bab"},
+ }}, "bab")
+ if partial.Verdict != "unknown" {
+ t.Errorf("partial deployment verdict = %q, want unknown", partial.Verdict)
+ }
+
+ obsolete := summarize(AppView{App: "obsolete", Channels: []ChannelView{{Name: "stable", Version: "old"}}}, "bab")
+ if obsolete.Verdict != "notdeployed" || obsolete.Deployment.UniformVersion() != "" {
+ t.Errorf("obsolete stable target = %+v, want not deployed", obsolete)
+ }
+
+ bad := summarize(AppView{App: "kass", Routes: []RouteView{{Host: "x", Account: "bab"}}, 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)
+ t.Errorf("unknown route report: 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: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready", Version: "edge-v"},
{Host: "a.a", Account: "other", Kind: "instance", Status: "failed"},
},
Channels: []ChannelView{
diff --git a/internal/console/statuscopy.go b/internal/console/statuscopy.go
new file mode 100644
index 000000000..f663c9bbd
--- /dev/null
+++ b/internal/console/statuscopy.go
@@ -0,0 +1,40 @@
+package console
+
+import (
+ "strconv"
+ "strings"
+)
+
+// StatusCopy is filled only with language approved through the repository's
+// copy-review gate. Keeping the structural status work independent lets its
+// state transitions and tests land without smuggling draft prose into the UI.
+type StatusCopy struct {
+ ObservedLabel string
+ ReportingCount string
+ Mixed string
+ NoInstances string
+ NoVersion string
+ Partial string
+ Next string
+}
+
+var approvedStatusCopy = StatusCopy{
+ ObservedLabel: "Observed versions",
+ ReportingCount: "{reported} of {total} instances report a version.",
+ Mixed: "Multiple versions",
+ NoInstances: "No instances yet.",
+ NoVersion: "No instances have reported a version yet.",
+ Partial: "Some instances have not reported a version.",
+ Next: "Candidate for promotion: {version}.",
+}
+
+func (d DeploymentStatus) ReportingText() string {
+ return strings.NewReplacer(
+ "{reported}", strconv.Itoa(d.ReportedCount),
+ "{total}", strconv.Itoa(d.ReportableCount),
+ ).Replace(approvedStatusCopy.ReportingCount)
+}
+
+func (s ReleaseSummary) CandidateText() string {
+ return strings.ReplaceAll(approvedStatusCopy.Next, "{version}", s.NextVersion)
+}
diff --git a/internal/console/statuscopy_test.go b/internal/console/statuscopy_test.go
new file mode 100644
index 000000000..eaa0321ee
--- /dev/null
+++ b/internal/console/statuscopy_test.go
@@ -0,0 +1,70 @@
+package console
+
+import (
+ "bytes"
+ "strings"
+ "testing"
+)
+
+func TestOverviewRendersMixedAndPartialObservedVersions(t *testing.T) {
+ deployment := DeploymentStatus{
+ RouteCount: 3, ReportableCount: 3, ReportedCount: 2, UnknownCount: 1,
+ Versions: []DeploymentVersion{{Version: "v1", Count: 1}, {Version: "v2", Count: 1}},
+ }
+ p := page{
+ Detail: &AppView{},
+ Account: &Account{},
+ StatusCopy: approvedStatusCopy,
+ Overview: OverviewStats{Deployment: deployment, Verdict: "unknown", VerdictLabel: "Can't tell right now"},
+ }
+ var out bytes.Buffer
+ if err := templates.ExecuteTemplate(&out, "app_overview", p); err != nil {
+ t.Fatal(err)
+ }
+ body := out.String()
+ for _, want := range []string{
+ "Observed versions", "2 of 3 instances report a version.",
+ "<code>v1</code>", "<code>v2</code>", "Some instances have not reported a version.",
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("missing %q", want)
+ }
+ }
+}
+
+func TestReleasesRendersApprovedCandidateText(t *testing.T) {
+ p := page{
+ Detail: &AppView{},
+ Account: &Account{},
+ StatusCopy: approvedStatusCopy,
+ ReleaseSummary: ReleaseSummary{NextVersion: "v9"},
+ }
+ var out bytes.Buffer
+ if err := templates.ExecuteTemplate(&out, "app_releases", p); err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(out.String(), "Candidate for promotion: v9.") {
+ t.Fatal("release summary did not render the approved candidate text")
+ }
+}
+
+func TestStaticOnlyPagesDoNotClaimAMissingVersionReport(t *testing.T) {
+ deployment := DeploymentStatus{RouteCount: 1}
+ p := page{
+ Detail: &AppView{},
+ Account: &Account{},
+ StatusCopy: approvedStatusCopy,
+ Overview: OverviewStats{Deployment: deployment, Verdict: "ok", VerdictLabel: "All good"},
+ ReleaseSummary: ReleaseSummary{Deployment: deployment},
+ }
+ for _, name := range []string{"app_overview", "app_releases"} {
+ var out bytes.Buffer
+ if err := templates.ExecuteTemplate(&out, name, p); err != nil {
+ t.Fatal(err)
+ }
+ body := out.String()
+ if !strings.Contains(body, "<p>—</p>") || strings.Contains(body, approvedStatusCopy.NoInstances) || strings.Contains(body, approvedStatusCopy.NoVersion) {
+ t.Errorf("%s rendered the wrong non-reportable state", name)
+ }
+ }
+}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index 131d17b33..1ed17eaab 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -6,14 +6,17 @@
{{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>
+ <h2>{{$.StatusCopy.ObservedLabel}}</h2>
+ {{if not .Deployment.HasRoutes}}
+ <p>{{$.StatusCopy.NoInstances}}</p>
+ {{else if not .Deployment.HasReportable}}
+ <p>—</p>
+ {{else if not .Deployment.HasReportedVersion}}
+ <p>{{$.StatusCopy.NoVersion}}</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">{{$.DeploymentsCopy.History}}</a> tab.</p>
+ <p>{{.Deployment.ReportingText}}</p>
+ <ul>{{range .Deployment.Versions}}<li><code>{{.Version}}</code></li>{{end}}</ul>
+ {{if .Deployment.UnknownCount}}<p>{{$.StatusCopy.Partial}}</p>{{end}}
{{end}}
</section>
{{end}}
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index 2f093403a..78febd318 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -11,15 +11,20 @@
{{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>
+ <h2>{{$.StatusCopy.ObservedLabel}}</h2>
+ {{if not .Deployment.HasRoutes}}
+ <p>{{$.StatusCopy.NoInstances}}</p>
+ {{else if not .Deployment.HasReportable}}
+ <p>—</p>
+ {{else if not .Deployment.HasReportedVersion}}
+ <p>{{$.StatusCopy.NoVersion}}</p>
{{else}}
- <p>Nothing is live yet.</p>
+ <p>{{.Deployment.ReportingText}}</p>
+ <ul>{{range .Deployment.Versions}}<li><code>{{.Version}}</code></li>{{end}}</ul>
+ {{if .Deployment.UnknownCount}}<p>{{$.StatusCopy.Partial}}</p>{{end}}
{{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>
+ <p>{{.CandidateText}}</p>
{{if and $.CanPromote $.IsOwner}}
<form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
<input type="hidden" name="csrf" value="{{$.CSRF}}">
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index 2189f45a4..d43b1318c 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -6,13 +6,13 @@
<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>
+ <tr><th scope="col">App</th><th scope="col">Running at</th><th scope="col">Health</th><th scope="col">{{$.StatusCopy.ObservedLabel}}</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>
+ <td>{{if .Deployment.Mixed}}{{$.StatusCopy.Mixed}}{{else if .Deployment.UniformVersion}}<code>{{.Deployment.UniformVersion}}</code>{{else}}—{{end}}{{if .CanaryN}} <span class="chip chip-canary">{{.CanaryN}} test version{{if ne .CanaryN 1}}s{{end}}</span>{{end}}</td>
</tr>
{{end}}
</table>
diff --git a/internal/console/templates/economics_accounts.html b/internal/console/templates/economics_accounts.html
index ae1d769ad..5456560a7 100644
--- a/internal/console/templates/economics_accounts.html
+++ b/internal/console/templates/economics_accounts.html
@@ -113,8 +113,7 @@
already IS the muted treatment (layout.html: font-size 0.9rem,
color var(--muted); there is no separate ".muted" class anywhere in
these templates, so this reuses ".small" rather than inventing
- one — apps.html's StableAge span is the same secondary-text
- pattern). A usage-only ghost row (no console account row at all, so
+ one). A usage-only ghost row (no console account row at all, so
.Name is "") or an explicitly empty name renders the bare sqid
alone, exactly as before this batch. The row's data-account
attribute above and every other field on this row (the tier form
diff --git a/internal/console/view.go b/internal/console/view.go
index 12200bf19..b3ea2c477 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -318,6 +318,7 @@ type CanaryView struct {
type RouteView struct {
Host, Kind, Status, Version, Channel string
Hibernate bool
+ Platform bool
Restartable bool
// Account is what the ROUTE says its account is (release.RouteAccount:
// the stamped account, or the ops account when unstamped). Routes are
@@ -564,6 +565,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
byApp[rt.App] = append(byApp[rt.App], RouteView{
Host: rt.Host, Kind: rt.Kind, Status: rt.Status,
Version: rt.Version, Channel: ch, Hibernate: rt.Hibernate,
+ Platform: rt.Platform,
Account: rtAcct,
Environment: rt.Environment,
Caps: capsFor,