console: the Settings page tells the same domain story as the CLI (#299)
The Settings page was the last first-party surface still on the pre-#299 vocabulary. It read THIS box's own registry copy of a route and printed a DNS word and a TLS word, while `carlos domains list` — reading the fleet-wide sweep — could be saying something else about the same hostname at the same moment. Two surfaces, two stories, one domain: the complaint the issue was opened with. settingsDomains now derives State, Reason and Detail through the same domainState() the API doors use, over the same inputs: the fleet fold of every live box's alias stamps, plus the warm-outcome records. Folded once per page, not once per row. The table's DNS column becomes Status, showing the derived state where there is one and the raw DNS word where there is not — never both, because a Status column beside a DNS column is two answers to one question. Explain() decides the sentence under a row. A live domain gets none: a paragraph repeated under every working domain trains people to skip the one under the broken domain. Everything else gets the state's own detail, verbatim as the CLI prints it, followed by the DNS instructions when there are any. A row with no state — a platform domain, or anything the shared view builder produced without a fleet fold — keeps exactly today's behaviour. The attach footnote was FALSE and is corrected. It said the certificate is minted on the first request; since the cert warm shipped the platform orders it proactively, and nothing has to visit the domain first. Threaded at settingsDomains rather than at domainView, which the shared view builder calls with no fleet fold behind it. Consequence, stated rather than hidden: the dashboard's domainNeedsAttention still reads this box's local registry copy and can disagree with the Settings page. Filed as a follow-up rather than widened into this change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4 files changed,
+198
−11
internal/console/appsettings.go+31 −5internal/console/appsettings_test.go+113 −0internal/console/templates/app_settings.html+8 −6internal/console/view.go+46 −0
CI passed — run details
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index a8f2ae177..1c1431dec 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -406,10 +406,23 @@ func (s *Server) mailCard(ctx context.Context, acct *Account, app string) *MailC |
| } |
| // settingsDomains is the Settings page's own Domains & TLS rows: routed |
| -// (the AppView's own DomainView slice, untouched) plus a placeholder row |
| -// for any of this app's domain claims that routed has no entry for yet. |
| -// Best-effort like the rest of this file — a claim-list read failure is |
| -// logged and the page falls back to routed alone, never a hard error. |
| +// (the AppView's own DomainView slice) plus a placeholder row for any of |
| +// this app's domain claims that routed has no entry for yet — and, for every |
| +// claim-backed row, the derived lifecycle state. |
| +// |
| +// The state is the point. Until it was threaded here the Settings page was |
| +// the last first-party surface still telling the pre-#299 story: it read |
| +// THIS box's own registry copy of a route and printed a DNS word and a TLS |
| +// word, while `carlos domains list` — reading the fleet-wide sweep — could |
| +// be saying something else about the same hostname at the same moment. Both |
| +// now come from domainState(), over the same inputs. |
| +// |
| +// Best-effort like the rest of this file. A claim-list read failure is |
| +// logged and the page falls back to routed alone; a readings or warm-record |
| +// failure leaves the state empty and the row renders exactly as it did |
| +// before. Neither is ever a hard error — a settings page that 500s because a |
| +// telemetry prefix was briefly unreadable would be a worse answer than one |
| +// that shows a little less. |
| func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, routed []DomainView) []DomainView { |
| out := append([]DomainView(nil), routed...) |
| if s.cfg.Releases == nil { |
| @@ -424,15 +437,28 @@ func (s *Server) settingsDomains(ctx context.Context, acct *Account, app string, |
| log.Printf("console: settings claims %s/%s: %v", acct.Sqid, app, err) |
| return out |
| } |
| + // Folded ONCE for the page, not once per row: readings is every live |
| + // box's alias stamps, warm is one object per hostname currently failing |
| + // to get a certificate (usually none at all). |
| + now := time.Now() |
| + readings := aliasReadings(ctx, s.cfg.Releases, now) |
| + warm := warmRecords(ctx, s.cfg.Releases) |
| + state := func(host string) (st, reason, detail string) { |
| + reading, seen := readings[host] |
| + return domainState(reading, seen, warm[host], now) |
| + } |
| for _, c := range claims { |
| if c.Account != acct.Sqid || c.App != app { |
| continue |
| } |
| if i, ok := have[strings.ToLower(c.Host)]; ok { |
| out[i].Claimed = true |
| + out[i].State, out[i].Reason, out[i].Detail = state(out[i].Domain) |
| continue |
| } |
| - out = append(out, claimDomainView(c.Host, s.dnsTargets())) |
| + dv := claimDomainView(c.Host, s.dnsTargets()) |
| + dv.State, dv.Reason, dv.Detail = state(c.Host) |
| + out = append(out, dv) |
| } |
| return out |
| } |
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go| index 5c01b0b21..f4b6edde1 100644 |
| --- a/internal/console/appsettings_test.go |
| +++ b/internal/console/appsettings_test.go |
| @@ -12,6 +12,9 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "github.com/carlosframework/platform/internal/certwarm" |
| + "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/status" |
| "net/http" |
| "net/url" |
| "strings" |
| @@ -1439,3 +1442,113 @@ func TestSettingsPageEmailUnavailableOnAConsoleWithNoProvisioner(t *testing.T) { |
| t.Fatalf("a provisioner-less console still suggests a command that would 404:\n%s", body) |
| } |
| } |
| + |
| +// The Settings page was the last first-party surface still telling the |
| +// pre-#299 story: it read THIS box's registry copy and printed a DNS word, |
| +// while `carlos domains list` read the fleet-wide sweep and could be saying |
| +// something else about the same hostname at the same moment. Both must now |
| +// come from domainState over the same inputs. |
| +func TestSettingsDomainsCarryTheSameStateTheAPIDoorDerives(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| + } |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "www.customer.test", Account: fx.sqid, App: "kass", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "www.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := fx.srv.settingsDomains(ctx, acct, "kass", nil) |
| + if len(got) != 1 { |
| + t.Fatalf("rows = %+v", got) |
| + } |
| + wantState, wantReason, wantDetail := domainState( |
| + status.AliasReading{Route: "kass.carlosapp.dev", DNSStatus: "elsewhere", DNSCheckedAt: now.Unix()}, |
| + true, nil, now) |
| + if got[0].State != wantState || got[0].Reason != wantReason || got[0].Detail != wantDetail { |
| + t.Errorf("row = state %q reason %q detail %q; want %q / %q / %q — the page and the CLI must derive one verdict", |
| + got[0].State, got[0].Reason, got[0].Detail, wantState, wantReason, wantDetail) |
| + } |
| + if got[0].Status() != wantState { |
| + t.Errorf("Status() = %q, want the derived state %q", got[0].Status(), wantState) |
| + } |
| +} |
| + |
| +// A failing certificate reaches the page, through the same warm-outcome |
| +// record the API doors read (#299 §3e). This is the surface a member is most |
| +// likely to be looking at when it happens. |
| +func TestSettingsDomainsShowAFailedCertificate(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + now := time.Now() |
| + acct, err := fx.st.AccountBySqid(fx.sqid) |
| + if err != nil || acct == nil { |
| + t.Fatalf("fixture account %s: %v", fx.sqid, err) |
| + } |
| + if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{ |
| + Host: "broken.customer.test", Account: fx.sqid, App: "kass", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := status.WriteBox(ctx, fx.rel, status.Box{ |
| + GeneratedAt: now, Label: "ada-reporting", Region: "eu-west-1", |
| + Metrics: &status.BoxMetrics{Aliases: map[string]status.AliasReading{ |
| + "broken.customer.test": {Route: "kass.carlosapp.dev", DNSStatus: "pointing", DNSCheckedAt: now.Unix()}, |
| + }}, |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + body, err := certwarm.Record{Host: "broken.customer.test", Attempts: domainFailedAttempts, |
| + Class: certwarm.ClassOther, NextAttemptAt: now.Add(time.Hour).Unix()}.Encode(now) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, certwarm.Key("broken.customer.test"), body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := fx.srv.settingsDomains(ctx, acct, "kass", nil) |
| + if len(got) != 1 || got[0].State != StateFailed { |
| + t.Fatalf("rows = %+v, want one row in state %q", got, StateFailed) |
| + } |
| + // And it EXPLAINS. A one-word "failed" with no sentence under it is the |
| + // worst version of this row. |
| + if got[0].Explain() == "" { |
| + t.Error("a failed certificate row carries no explanation") |
| + } |
| +} |
| + |
| +// Explain is what keeps the page readable: a working domain gets no |
| +// paragraph, because a sentence repeated under every healthy row trains |
| +// people to skip the one under the broken row. |
| +func TestDomainViewExplainStaysQuietWhenThereIsNothingToSay(t *testing.T) { |
| + for _, tc := range []struct { |
| + name string |
| + d DomainView |
| + want string |
| + }{ |
| + {"live says nothing", DomainView{State: StateLive, Detail: "served over HTTPS"}, ""}, |
| + {"a problem says its detail", DomainView{State: StateMisdirected, Detail: "points elsewhere"}, "points elsewhere"}, |
| + {"a problem with records says both", |
| + DomainView{State: StatePendingDNS, Detail: "no record yet", Guidance: "Point an A record."}, |
| + "no record yet Point an A record."}, |
| + // A row the shared view builder produced, with no fleet fold behind |
| + // it, must render exactly as it always has. |
| + {"no state falls back to guidance", DomainView{Guidance: "Point an A record."}, "Point an A record."}, |
| + {"no state and no guidance", DomainView{}, ""}, |
| + } { |
| + if got := tc.d.Explain(); got != tc.want { |
| + t.Errorf("%s: Explain() = %q, want %q", tc.name, got, tc.want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 75f72e3e8..5dafd1603 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -37,16 +37,16 @@ bucket keys and stays.</p> |
| {{if .Settings.Domains}} |
| <div class="tablewrap"> |
| <table> |
| - <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr> |
| + <tr><th>Domain</th><th>Type</th><th>Status</th><th>Certificate</th></tr> |
| {{range $i, $d := .Settings.Domains}} |
| <tr> |
| <td><code>{{$d.Domain}}</code></td> |
| <td>{{$d.Type}}</td> |
| - <td>{{if $d.DNS}}{{$d.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| + <td>{{with $d.Status}}{{.}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td> |
| <td>{{if $d.TLS}}{{$d.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td> |
| </tr> |
| - {{if $d.Guidance}} |
| - <tr><td colspan="4" class="small">{{$d.Guidance}}</td></tr> |
| + {{with $d.Explain}} |
| + <tr><td colspan="4" class="small">{{.}}</td></tr> |
| {{end}} |
| {{if and $.IsOwner $d.Claimed}} |
| <tr><td colspan="4" class="small"> |
| @@ -78,8 +78,10 @@ at its platform domain; attaching adds addresses, it never removes one.</p>{{end |
| {{end}} |
| <button type="submit" class="btn-sm">Attach domain</button> |
| </form> |
| -<p class="small">Point the domain's A record at this deployment's edge; |
| -the certificate is minted on the first request and renews on its own.</p> |
| +<p class="small">Point the domain's DNS at this deployment — once it is |
| +attached, the row above says exactly what to create. The platform obtains the |
| +certificate itself and renews it, so nothing has to visit the domain |
| +first.</p> |
| {{end}} |
| {{if and .Settings .Settings.MismatchTarget}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index c0c2a9926..ec65561ed 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -128,6 +128,17 @@ func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 } |
| type DomainView struct { |
| Domain, Type, DNS, TLS string |
| Guidance string |
| + // State, Reason and Detail are the derived lifecycle words (#299 §2a) — |
| + // the SAME three domainState() gives `carlos domains list` and the API |
| + // doors, from the same fleet-wide readings, so the console page and the |
| + // CLI can never tell a member two stories about one hostname. That was |
| + // the whole complaint #299 opened with. |
| + // |
| + // Empty on a row that has not been through settingsDomains: the shared |
| + // view builder (domainView, below) folds one box's own registry copy and |
| + // has no fleet fold to derive a state from. Readers must treat "" as |
| + // "this build did not derive one here", never as a verdict. |
| + State, Reason, Detail string |
| // Claimed is Settings-only (settingsDomains, appsettings.go): true when |
| // this row corresponds to a domain claim this app holds — routed or not |
| // — which is what the Settings page uses to decide whether a detach |
| @@ -136,6 +147,41 @@ type DomainView struct { |
| Claimed bool |
| } |
| +// Status is the one word the Domains table shows: the derived lifecycle |
| +// state where there is one, and otherwise the raw DNS word this box |
| +// stamped. Never both — a Status column and a DNS column side by side would |
| +// be two answers to one question, which is what #299 was opened about. |
| +func (d DomainView) Status() string { |
| + if d.State != "" { |
| + return d.State |
| + } |
| + return d.DNS |
| +} |
| + |
| +// Explain is the sentence under a Domains row, or "" for no sentence. |
| +// |
| +// A healthy row gets none: "live" beside a current renewal date says |
| +// everything, and a paragraph repeating it under every working domain is |
| +// noise that trains people to skip the paragraph under the broken one. |
| +// Everything else gets the state's own detail — the same sentence the CLI |
| +// prints, verbatim — followed by the DNS instructions when there are any. |
| +// |
| +// A row with no state at all keeps exactly today's behaviour: guidance |
| +// alone. That is the platform-domain rows and anything the shared view |
| +// builder produced without a fleet fold. |
| +func (d DomainView) Explain() string { |
| + if d.State == "" { |
| + return d.Guidance |
| + } |
| + if d.State == StateLive || d.Detail == "" { |
| + return "" |
| + } |
| + if d.Guidance == "" { |
| + return d.Detail |
| + } |
| + return d.Detail + " " + d.Guidance |
| +} |
| + |
| // DNSTargets is where this deployment tells a member to point a custom |
| // domain. Two facts, threaded together because the guidance is one |
| // sentence and needs both: the edge addresses an A record names, and — |