console: final-review fixes — tenant-filtered app rows, first-promote path, label/copy consistency
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+198
−46
internal/console/overview.go+5 −5internal/console/overview_test.go+12 −7internal/console/server.go+61 −18internal/console/server_test.go+110 −6internal/console/templates/app_overview.html+2 −2internal/console/templates/app_releases.html+3 −3internal/console/templates/appnav.html+1 −1internal/console/templates/apps.html+1 −1internal/console/templates/layout.html+3 −3
diff --git a/internal/console/overview.go b/internal/console/overview.go| index d87ba9467..d7738e9be 100644 |
| --- a/internal/console/overview.go |
| +++ b/internal/console/overview.go |
| @@ -123,11 +123,11 @@ func assembleOverview(view *AppView, acct string, metrics []InstanceMetrics, ver |
| st.InstanceTotal++ |
| if rt.Kind == "instance" { |
| st.InstanceExpected++ |
| - } |
| - if reporting[rt.Host] { |
| - st.InstanceReporting++ |
| - } else if rt.Hibernate { |
| - st.InstanceAsleep++ |
| + if reporting[rt.Host] { |
| + st.InstanceReporting++ |
| + } else if rt.Hibernate { |
| + st.InstanceAsleep++ |
| + } |
| } |
| } |
| // Routes and Domains are parallel slices — Assemble appends one of each |
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go| index 0fe40cfcc..4ada7994e 100644 |
| --- a/internal/console/overview_test.go |
| +++ b/internal/console/overview_test.go |
| @@ -79,13 +79,13 @@ func TestOverviewUnreadableStableIsUnknownNotOffline(t *testing.T) { |
| // and another account's same-named routes count for nothing at all. |
| func TestOverviewSumsOnlyReportingInstances(t *testing.T) { |
| view := &AppView{App: "kass", Routes: []RouteView{ |
| - {Host: "a", Account: "bab"}, |
| - {Host: "b", Account: "bab"}, |
| - {Host: "c", Account: "bab"}, |
| - {Host: "d", Account: "bab", Hibernate: true}, |
| - {Host: "e", Account: "bab"}, |
| + {Host: "a", Account: "bab", Kind: "instance"}, |
| + {Host: "b", Account: "bab", Kind: "instance"}, |
| + {Host: "c", Account: "bab", Kind: "instance"}, |
| + {Host: "d", Account: "bab", Kind: "instance", Hibernate: true}, |
| + {Host: "e", Account: "bab", Kind: "instance"}, |
| // Another account's same-named app: not this dashboard's instance. |
| - {Host: "theirs", Account: "cdef"}, |
| + {Host: "theirs", Account: "cdef", Kind: "instance"}, |
| }} |
| metrics := []InstanceMetrics{ |
| {Host: "a", Box: "box-1", CPUFrac: f64(0.25), MemBytes: i64(100)}, |
| @@ -118,7 +118,7 @@ func TestOverviewSumsOnlyReportingInstances(t *testing.T) { |
| // Nobody reported: nil, not zero. A zero here would render as "0%" and |
| // claim an idle app where there is simply no reading. |
| - quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab"}, {Host: "b", Account: "bab"}}} |
| + quietView := &AppView{App: "kass", Routes: []RouteView{{Host: "a", Account: "bab", Kind: "instance"}, {Host: "b", Account: "bab", Kind: "instance"}}} |
| quiet := assembleOverview(quietView, "bab", []InstanceMetrics{{Host: "a"}, {Host: "b"}}, nil) |
| if quiet.CPUSum != nil || quiet.MemSum != nil { |
| t.Fatalf("sums = %v %v with nothing reporting, want nil so the tiles show the em-dash", |
| @@ -337,6 +337,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) |
| + } |
| // The fixture's two domains need attention, which outranks everything |
| // else in healthVerdict — the headline verdict this page exists to show. |
| if !strings.Contains(body, "Needs attention") { |
diff --git a/internal/console/server.go b/internal/console/server.go| index 329c49c1e..4a9f9226a 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -453,9 +453,14 @@ type page struct { |
| // ReleaseSummary is the Releases tab's lede strip: what is live and |
| // what is next, folded from Detail.Channels once the tab has them. |
| ReleaseSummary ReleaseSummary |
| - Instances *InstanceReport // app page: the app's self-report, nil when unpolled |
| - Events []Event // app page: recent console actions, newest first |
| - CanPromote bool // a signing key is configured on this host |
| + // PromoteChannels is the per-row promote select's option list: the |
| + // ladder rungs (edge, beta, stable) so a fresh app with zero channels |
| + // still has somewhere to promote its first release, unioned with any |
| + // channels (including canary/*) the app already has. |
| + PromoteChannels []string |
| + Instances *InstanceReport // app page: the app's self-report, nil when unpolled |
| + Events []Event // app page: recent console actions, newest first |
| + CanPromote bool // a signing key is configured on this host |
| // App page: how a canary channel gets a URL, rendered as commands the |
| // reader can actually run from where they are reading. Computed |
| @@ -1300,7 +1305,7 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| for _, v := range views { |
| - p.Apps = append(p.Apps, summarize(v)) |
| + p.Apps = append(p.Apps, summarize(v, acct.Sqid)) |
| } |
| } |
| // The Trash link, and only when there is something in it. Best-effort: a |
| @@ -1510,6 +1515,7 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels) |
| + p.PromoteChannels = promoteChannelOptions(p.Detail.Channels) |
| versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName) |
| if err != nil { |
| log.Printf("console: versions %s: %v", p.AppName, err) |
| @@ -1547,22 +1553,22 @@ func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) { |
| // The "how do I give this a URL" copy — not shared chrome (it's computed |
| // per app page, not carried by appPage), but the same fill the Instances |
| // tab's empty state shows: attachHint below. |
| - p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName) |
| + p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "canary/<name>") |
| s.render(w, http.StatusOK, "app_releases", p) |
| } |
| // attachHint is the "give this channel a URL" advice shown on both the |
| -// Releases tab (the canary form's copy) and the Instances tab (the empty |
| -// state, before anything is attached) — one bucket read and one command |
| -// shape, so a reader who bounces between tabs sees the same door named both |
| -// times. Bounded and best-effort like every other store read behind an app |
| -// page: an unreadable instances manifest costs the concrete host shape, |
| -// never the page. |
| -func (s *Server) attachHint(ctx context.Context, sqid, app string) (cmd, enable string) { |
| +// Releases tab (the canary form's copy, channel "canary/<name>") and the |
| +// Instances tab's empty state (channel "stable" — the Running tab's empty |
| +// state should not be steering members onto a canary) — one bucket read and |
| +// one command shape per caller. Bounded and best-effort like every other |
| +// store read behind an app page: an unreadable instances manifest costs the |
| +// concrete host shape, never the page. |
| +func (s *Server) attachHint(ctx context.Context, sqid, app, channel string) (cmd, enable string) { |
| mctx, mcancel := context.WithTimeout(ctx, instanceStoreTimeout) |
| defer mcancel() |
| domain, absent := s.instanceDomainFor(mctx, sqid, app) |
| - return attachCommands(app, "canary/<name>", domain, s.cfg.AppsDomain, sqid, absent) |
| + return attachCommands(app, channel, domain, s.cfg.AppsDomain, sqid, absent) |
| } |
| // handleAppInstances is what the app is actually running: one row per |
| @@ -1577,7 +1583,9 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| p.Instances = s.instances.Report(r.Context(), p.Detail.Routes) |
| p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| p.InstanceRows = assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| - p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName) |
| + if len(p.InstanceRows) == 0 { |
| + p.AttachCmd, p.AttachEnable = s.attachHint(r.Context(), acct.Sqid, p.AppName, "stable") |
| + } |
| s.render(w, http.StatusOK, "app_instances", p) |
| } |
| @@ -2429,11 +2437,46 @@ func (s *Server) handleRetireCanary(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Retired canary "+name+" — its channel pointer is gone; shipped releases are untouched.") |
| } |
| -// summarize folds one AppView into its "your apps" table row. |
| -func summarize(v AppView) appRow { |
| - row := appRow{App: v.App, URL: apexHost(v.Routes)} |
| - attention := false |
| +// promoteChannelOptions is the per-row promote select's option list: the |
| +// ladder rungs first (so a fresh app with zero channels — nothing in |
| +// existing — still has somewhere to promote its first release), then any of |
| +// the app's existing channels not already a rung (canary/* and the like), |
| +// in their existing order. Ladder rungs are included whether or not the app |
| +// has them yet — promoting onto a rung that doesn't exist is exactly how it |
| +// comes to exist. |
| +func promoteChannelOptions(existing []ChannelView) []string { |
| + rungs := []string{release.RungEdge, release.RungBeta, release.RungStable} |
| + seen := make(map[string]bool, len(rungs)) |
| + options := make([]string, 0, len(rungs)+len(existing)) |
| + for _, r := range rungs { |
| + options = append(options, r) |
| + seen[r] = true |
| + } |
| + for _, ch := range existing { |
| + if seen[ch.Name] { |
| + continue |
| + } |
| + seen[ch.Name] = true |
| + options = append(options, ch.Name) |
| + } |
| + return options |
| +} |
| + |
| +// summarize folds one AppView into its "your apps" table row. Routes can |
| +// legitimately belong to another account (RouteView.Account's doc comment) |
| +// so both the apex-host pick and the failed-route scan are filtered to the |
| +// viewing account first — otherwise a foreign route can pick this app's URL |
| +// or trip its "needs attention" verdict. |
| +func summarize(v AppView, acct string) appRow { |
| + var mine []RouteView |
| for _, rt := range v.Routes { |
| + if rt.Account == acct { |
| + mine = append(mine, rt) |
| + } |
| + } |
| + row := appRow{App: v.App, URL: apexHost(mine)} |
| + attention := false |
| + for _, rt := range mine { |
| if rt.Status == "failed" { |
| attention = true |
| } |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 14635cf41..71ad872ed 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -550,7 +550,7 @@ func TestReleasesTab(t *testing.T) { |
| t.Fatalf("GET releases tab = %d", status) |
| } |
| for _, want := range []string{ |
| - `aria-current="page">Releases</a>`, |
| + `aria-current="page">Versions</a>`, |
| "<h2>Channels</h2>", "<h2>Canaries</h2>", "All versions (", |
| } { |
| if !strings.Contains(body, want) { |
| @@ -568,6 +568,92 @@ func TestReleasesTab(t *testing.T) { |
| assertNonMember404(t, fx, "/apps/kass/releases") |
| } |
| +// TestReleaseSummaryStripRenders pins the Make-live strip's form (M6, final |
| +// review): an owner with beta ahead of stable gets a real promote form |
| +// (channel=stable, version=beta's), a non-owner member sees no form at all — |
| +// just the "Only an account owner can make it live." note, since the fixture |
| +// is keyed and CanPromote is true either way. |
| +func TestReleaseSummaryStripRenders(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") |
| + |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/kass/releases")) |
| + // The hidden channel=stable input is unique to the make-live strip's form |
| + // (the per-row promote forms elsewhere on this tab use a <select>, not a |
| + // hidden "stable" input), so its presence pins that specific form. |
| + if !strings.Contains(body, `<input type="hidden" name="channel" value="stable">`) { |
| + t.Fatal("owner view missing the make-live promote form's channel=stable input") |
| + } |
| + if !strings.Contains(body, `<input type="hidden" name="version" value="v3">`) { |
| + t.Fatalf("make-live form missing its version=v3 hidden input:\n%s", body) |
| + } |
| + if !strings.Contains(body, `<input type="hidden" name="csrf" value="`) { |
| + t.Fatal("make-live form missing its csrf input") |
| + } |
| + if strings.Contains(body, "Only an account owner can make it live.") { |
| + t.Fatal("owner view should not show the owner-only note") |
| + } |
| + |
| + if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("kim@keymail.dev") |
| + member := newBrowser(t) |
| + signUp(t, fx, member, "code-kim-release-strip") |
| + _, _, mBody := get(t, member, fx.u("/apps/kass/releases")) |
| + if strings.Contains(mBody, `<input type="hidden" name="channel" value="stable">`) { |
| + t.Fatal("member view rendered the owner-only make-live form") |
| + } |
| + if !strings.Contains(mBody, "Only an account owner can make it live.") { |
| + t.Fatal("member view missing the owner-only note") |
| + } |
| +} |
| + |
| +// TestFirstPromoteChannelOptions (I4, final review): a fresh app has shipped |
| +// a release but has zero channels yet — the per-row promote select must |
| +// still offer the ladder rungs, so the first promotion has somewhere to go. |
| +func TestFirstPromoteChannelOptions(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "freshapp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "freshapp", "v1") |
| + |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/freshapp/releases")) |
| + want := `<option value="edge">edge</option><option value="beta">beta</option><option value="stable">stable</option>` |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("promote select missing the ladder-rung options in order:\n%s", body) |
| + } |
| + // The now-true copy this union restores: still says "ship a release, then |
| + // promote it below" even though there IS a release, because the promote |
| + // control the reader needs is right there in the versions table. |
| + if !strings.Contains(body, "No channels yet") { |
| + t.Fatal("zero-channel app missing the empty Channels-table copy") |
| + } |
| +} |
| + |
| +// TestInstancesEmptyStateHintsStable (I2, final review): the Running tab's |
| +// empty state must steer a member at "give this a URL" onto stable, not |
| +// canary/<name> — a canary is a private test version, the wrong first door |
| +// for "how do I run this at all". |
| +func TestInstancesEmptyStateHintsStable(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, "routeless", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/apps/routeless/instances")) |
| + if !strings.Contains(body, "-channel stable") { |
| + t.Fatalf("routeless app's Running empty state missing -channel stable:\n%s", body) |
| + } |
| + if strings.Contains(body, "canary/") { |
| + t.Fatalf("routeless app's Running empty state still points at a canary channel:\n%s", body) |
| + } |
| +} |
| + |
| func TestInstancesTab(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
| @@ -1207,7 +1293,7 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| csrf := sessionToken(t, fx, c) |
| _, _, body := get(t, c, fx.u("/apps/kass")) |
| - if !strings.Contains(body, "Deploys are temporarily unavailable on this console.") { |
| + if !strings.Contains(body, "Deploys are unavailable on this console — it has no signing key.") { |
| t.Fatal("keyless console is missing the disabled note") |
| } |
| // The Domains section is a read — keyless (read-only) mode still renders it. |
| @@ -2333,7 +2419,7 @@ func TestSummarizeVerdictAndURL(t *testing.T) { |
| {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)}, |
| {Name: "canary/rc-1", Version: "aaa1111"}, |
| }} |
| - row := summarize(v) |
| + row := summarize(v, "bab") |
| if row.URL != "kass.example.com" { |
| t.Errorf("URL = %q, want the apex host kass.example.com", row.URL) |
| } |
| @@ -2345,20 +2431,38 @@ func TestSummarizeVerdictAndURL(t *testing.T) { |
| } |
| v.Routes[0].Status = "failed" |
| - row = summarize(v) |
| + row = summarize(v, "bab") |
| if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" { |
| t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel) |
| } |
| - empty := summarize(AppView{App: "new"}) |
| + empty := summarize(AppView{App: "new"}, "bab") |
| if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" { |
| t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel) |
| } |
| - bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}) |
| + bad := summarize(AppView{App: "kass", 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) |
| } |
| + |
| + // 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: "a.a", Account: "other", Kind: "instance", Status: "failed"}, |
| + }, |
| + Channels: []ChannelView{ |
| + {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)}, |
| + }} |
| + row = summarize(foreign, "bab") |
| + if row.Verdict != "ok" || row.VerdictLabel != "Deployed" { |
| + t.Errorf("foreign failed route leaked into verdict: %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel) |
| + } |
| + if row.URL != "kass.bab.oncarlos.com" { |
| + t.Errorf("URL = %q, want the own-account host kass.bab.oncarlos.com, not the foreign apex-ier a.a", row.URL) |
| + } |
| } |
| func TestNormalizeHost(t *testing.T) { |
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html| index 8091f6edd..de5a45ea1 100644 |
| --- a/internal/console/templates/app_overview.html |
| +++ b/internal/console/templates/app_overview.html |
| @@ -30,7 +30,7 @@ |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Machines</h2> |
| <p class="tile-value">{{.InstanceTotal}}</p> |
| - <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} processes responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| </a> |
| <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| <h2>Resources</h2> |
| @@ -69,5 +69,5 @@ |
| </ul> |
| <p class="small"><a href="{{.Nav}}/apps/{{.AppName}}/activity">All activity</a></p> |
| {{end}} |
| -{{if not .CanPromote}}<p class="small">Deploys are temporarily unavailable on this console.</p>{{end}} |
| +{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 80152ffc6..589f324fa 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -31,7 +31,7 @@ |
| <input type="hidden" name="back" value="releases"> |
| <button type="submit" class="btn">Make {{.NextVersion}} live</button> |
| </form> |
| - {{else}} |
| + {{else if $.CanPromote}} |
| <p class="small">Only an account owner can make it live.</p> |
| {{end}} |
| {{end}} |
| @@ -62,7 +62,7 @@ |
| <input type="hidden" name="version" value="{{.Version}}"> |
| <input type="hidden" name="back" value="releases"> |
| <select name="channel" aria-label="Channel to promote {{.Version}} to"> |
| - {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}} |
| + {{range $.PromoteChannels}}<option value="{{.}}">{{.}}</option>{{end}} |
| </select> |
| <button type="submit" class="btn-sm">Promote</button> |
| </form> |
| @@ -171,5 +171,5 @@ carlos ship -app {{.Detail.App}} -kind static -version v1 <site-dir></code |
| {{end}} |
| {{end}} |
| </details> |
| -{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}} |
| +{{if not .CanPromote}}<p class="small">Deploys are unavailable on this console — it has no signing key.</p>{{end}} |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/appnav.html b/internal/console/templates/appnav.html| index ada05a6a0..d6696818c 100644 |
| --- a/internal/console/templates/appnav.html |
| +++ b/internal/console/templates/appnav.html |
| @@ -5,7 +5,7 @@ |
| </div> |
| <nav class="tabs" aria-label="App sections"> |
| <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a> |
| - <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a> |
| + <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Versions</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Running</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a> |
| <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a> |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index ee13be58e..2189f45a4 100644 |
| --- a/internal/console/templates/apps.html |
| +++ b/internal/console/templates/apps.html |
| @@ -22,7 +22,7 @@ |
| <p>The account <strong>{{.Account.Name}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p> |
| {{end}} |
| {{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps, |
| - restorable for 30 days.</p>{{end}} |
| + restorable for {{trashCooloffDays}} days.</p>{{end}} |
| <p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p> |
| {{template "bottom" .}}{{end}} |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index e64cf6af6..820d2c96a 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -9,7 +9,7 @@ |
| --bg: #ffffff; --ink: #201d29; --muted: #6f6480; |
| --accent: #5b3e96; --accent-strong: #4c3380; |
| --accent-soft: rgba(91, 62, 150, 0.09); |
| - --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; |
| + --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f; --good: #2f7a4f; |
| --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; |
| --sans: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| } |
| @@ -18,7 +18,7 @@ |
| --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd; |
| --accent: #b394f2; --accent-strong: #c9b2f7; |
| --accent-soft: rgba(179, 148, 242, 0.12); |
| - --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; |
| + --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f; |
| } |
| } |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| @@ -181,7 +181,7 @@ |
| row a reader must not skim past — in the warn tone, not .chip-prod's |
| red: a silent instance is a problem, not the unreviewed-build outage. |
| ok/notdeployed/unknown are bordered facts. */ |
| - .chip-health-ok { border: 1px solid #2f7a4f; color: #2f7a4f; } |
| + .chip-health-ok { border: 1px solid var(--good); color: var(--good); } |
| .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); } |
| .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); } |
| .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); } |