console: derive deployment status from observed instances
17 files changed,
+450
−201
internal/console/deploymentstatus.go+70 −0internal/console/deploymentstatus_test.go+49 −0internal/console/economicspage_test.go+2 −2internal/console/overview.go+19 −47internal/console/overview_test.go+93 −61internal/console/pipelinepage_test.go+4 −4internal/console/releasesummary.go+12 −27internal/console/releasesummary_test.go+10 −11internal/console/server.go+23 −27internal/console/server_test.go+36 −5internal/console/statuscopy.go+40 −0internal/console/statuscopy_test.go+70 −0internal/console/templates/app_overview.html+8 −7internal/console/templates/app_releases.html+9 −6internal/console/templates/apps.html+2 −2internal/console/templates/economics_accounts.html+1 −2internal/console/view.go+2 −0
CI passed — run details
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 5a635f281..27d664f31 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 02ab3834c..ccae56a0b 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 3ac7e6952..66a238326 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1304,11 +1304,12 @@ type page struct { |
| InstanceUnknownChannel bool |
| InstanceListURL string |
| - Title string |
| - Address string // signed-in address, "" when signed out |
| - CSRF string // the session token, double-submitted on the signout POST |
| - Error string |
| - Welcome bool // one-shot "account created" banner |
| + Title string |
| + Address string // signed-in address, "" when signed out |
| + CSRF string // the session token, double-submitted on the signout POST |
| + Error string |
| + Welcome bool // one-shot "account created" banner |
| + StatusCopy StatusCopy |
| ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings" |
| @@ -1594,7 +1595,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 |
| @@ -1606,12 +1608,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 |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.WriteHeader(status) |
| if err := templates.ExecuteTemplate(w, name, p); err != nil { |
| @@ -2738,7 +2740,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| if !ok { |
| 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 { |
| @@ -4275,36 +4277,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 d67a229a1..c81c8a013 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -2609,8 +2609,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)}, |
| @@ -2626,6 +2626,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") |
| @@ -2638,16 +2641,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..e5ebf4a3b |
| --- /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, 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 07a415522..7d01bbfe6 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -27,14 +27,15 @@ |
| {{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.HasReportable}} |
| + <p>{{$.StatusCopy.NoInstances}}</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">Versions</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 7e00f916e..755c7251d 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -27,15 +27,18 @@ |
| {{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.HasReportable}} |
| + <p>{{$.StatusCopy.NoInstances}}</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 ec65561ed..12dffc493 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -326,6 +326,7 @@ type CanaryView struct { |
| type RouteView struct { |
| Host, Kind, Status, Version, Channel string |
| Hibernate bool |
| + Platform bool |
| // Account is what the ROUTE says its account is (release.RouteAccount: |
| // the stamped account, or the ops account when unstamped). Routes are |
| // grouped by app NAME here, and app names are unique per account rather |
| @@ -607,6 +608,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, |