carlos / platform Public

console: the one-page app era ends

Overview is the ladder plus a summary line; every section lives on its
tab, each loading only its own data.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 719e52e33734948a58fde951b7459863b831d738 parent d5a8a37
5 files changed, +149 −264
  • internal/console/server.go +1 −10
  • internal/console/server_account_test.go +3 −3
  • internal/console/server_test.go +120 −44
  • internal/console/templates/app.html +0 −207
  • internal/console/templates/app_overview.html +25 −0
diff --git a/internal/console/server.go b/internal/console/server.go
index 749bc036c..c31e827de 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -1345,13 +1345,7 @@ func (s *Server) handleApp(w http.ResponseWriter, r *http.Request) {
}
p.Versions = versions
p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
- p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
- if events, err := s.store.Events(acct.ID, app, eventsCap); err != nil {
- log.Printf("console: events %s: %v", app, err)
- } else {
- p.Events = events
- }
- s.render(w, http.StatusOK, "app", p)
+ s.render(w, http.StatusOK, "app_overview", p)
}
// handleAppReleases is the deploy surface: every shipped release, the
@@ -1406,9 +1400,6 @@ func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
s.render(w, http.StatusOK, "app_settings", p)
}
-// eventsCap bounds the app page's recent-activity list.
-const eventsCap = 10
-
// activityCap bounds the activity tab's own listing.
const activityCap = 50 // the activity tab has the room the overview strip never did
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index d1ea9e302..0eaf9889e 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -644,12 +644,12 @@ func TestAuditIsScopedToTheAccount(t *testing.T) {
// And through the page, which is where the leak would actually be seen.
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/activity"))
if !strings.Contains(body, "promoted v1 to stable") {
- t.Error("the account's own activity is missing from its app page")
+ t.Error("the account's own activity is missing from its activity tab")
}
if strings.Contains(body, "sam@keymail.dev") || strings.Contains(body, "promoted v9 to stable") {
- t.Errorf("another account's activity leaked onto this app page:\n%s", body)
+ t.Errorf("another account's activity leaked onto its activity tab:\n%s", body)
}
}
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 708f8f30e..9b83d292b 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -5,6 +5,7 @@ import (
"crypto/ed25519"
"encoding/json"
"errors"
+ "fmt"
"io"
"net/http"
"net/http/cookiejar"
@@ -242,21 +243,39 @@ func TestConsoleE2E(t *testing.T) {
t.Fatal(err)
}
- // Detail page: routes and channels, with the bake approximation labeled.
+ // Overview: the ladder, with channel names and versions on it.
status, _, body := get(t, c, fx.u("/apps/kass"))
if status != 200 {
t.Fatalf("/apps/kass = %d", status)
}
- for _, want := range []string{
- "Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom",
- "ready", "v1", "stable", "canary/x", "v2",
- "bakes until ~", "<td>live</td>", "approximation",
- } {
+ for _, want := range []string{"v1", "stable", "canary/x", "v2"} {
if !strings.Contains(body, want) {
t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
}
}
+ // Releases tab: the Channels table, with the bake approximation labeled.
+ status, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("/apps/kass/releases = %d", status)
+ }
+ for _, want := range []string{"canary/x", "v2", "bakes until ~", "<td>live</td>", "approximation"} {
+ if !strings.Contains(rbody, want) {
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, rbody)
+ }
+ }
+
+ // Settings tab: the app's domains.
+ status, _, sbody := get(t, c, fx.u("/apps/kass/settings"))
+ if status != 200 {
+ t.Fatalf("/apps/kass/settings = %d", status)
+ }
+ for _, want := range []string{"Domains", "kass.carlosapp.dev", "docs.kass.carlosapp.dev", "custom", "ready"} {
+ if !strings.Contains(sbody, want) {
+ t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, sbody)
+ }
+ }
+
// Apps not owned 404 exactly like apps that don't exist.
if status, _, _ := get(t, c, fx.u("/apps/otherapp")); status != http.StatusNotFound {
t.Fatalf("/apps/otherapp = %d, want 404", status)
@@ -420,14 +439,23 @@ func TestCreateApp(t *testing.T) {
if status != 200 {
t.Fatalf("/apps/zephyr = %d", status)
}
+ for _, want := range []string{"Created app zephyr", "0 releases"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
+ }
+ }
+ status, _, rbody := get(t, c, fx.u("/apps/zephyr/releases"))
+ if status != 200 {
+ t.Fatalf("/apps/zephyr/releases = %d", status)
+ }
for _, want := range []string{
- "Created app zephyr", "Nothing shipped yet",
+ "Nothing shipped yet",
"carlos ship -app zephyr -version $(git rev-parse --short HEAD) -target linux-arm64",
"carlos ship -app zephyr -kind static -version v1",
"promoted to channels from this page",
} {
- if !strings.Contains(body, want) {
- t.Fatalf("/apps/zephyr missing %q in:\n%s", want, body)
+ if !strings.Contains(rbody, want) {
+ t.Fatalf("/apps/zephyr/releases missing %q in:\n%s", want, rbody)
}
}
apps, err := fx.st.AppsIn(OpsAccountID)
@@ -488,6 +516,22 @@ func TestAppTabBar(t *testing.T) {
}
}
+func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ if strings.Contains(body, gone) {
+ t.Fatalf("overview still renders %q", gone)
+ }
+ }
+ for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("overview missing %q", want)
+ }
+ }
+}
+
func TestReleasesTab(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
@@ -498,12 +542,17 @@ func TestReleasesTab(t *testing.T) {
for _, want := range []string{
`aria-current="page">Releases</a>`,
"<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
- `name="back" value="releases"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("releases tab missing %q", want)
}
}
+ // Every mutation form on the tab (promote, rollback, new canary, retire
+ // canary) carries a back=releases hidden field, so its redirect returns
+ // here rather than to the overview — exactly 4, one per form.
+ if n := strings.Count(body, `name="back" value="releases"`); n != 4 {
+ t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n)
+ }
assertNonMember404(t, fx, "/apps/kass/releases")
}
@@ -679,10 +728,10 @@ func TestPromoteFlow(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // The app page offers promote forms per shipped version.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ // The releases tab offers promote forms per shipped version.
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if !strings.Contains(body, `action="/a/bab/apps/kass/promote"`) {
- t.Fatal("no promote form on the app page")
+ t.Fatal("no promote form on the releases tab")
}
if strings.Contains(body, "Promotions disabled") {
t.Fatal("keyed fixture rendered the disabled note")
@@ -774,7 +823,7 @@ func TestRollbackFlow(t *testing.T) {
// Fixture stable is v1 with no history: no rollback button for it (nor
// for the single-promote canary), and a forced POST politely refuses.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
if strings.Contains(body, `name="channel" value="stable"`) || strings.Contains(body, `name="channel" value="canary/x"`) {
t.Fatal("rollback button rendered for a channel with no history")
}
@@ -788,7 +837,7 @@ func TestRollbackFlow(t *testing.T) {
// primitive rather than promoted through the console, because this test is
// about rollback and the ladder has its own (TestLadderRefusesASkip).
seedChannel(t, fx, "kass", "stable", "v2")
- if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `name="channel" value="stable"`) {
+ if _, _, body := get(t, c, fx.u("/apps/kass/releases")); !strings.Contains(body, `name="channel" value="stable"`) {
t.Fatal("no rollback button after stable gained history")
}
status, loc, _ := postForm(t, c, fx.u("/apps/kass/rollback"),
@@ -831,14 +880,14 @@ func TestCanaryFlow(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // The app page: create form, and the fixture canary listed hostless.
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ // The releases tab: create form, and the fixture canary listed hostless.
+ _, _, body := get(t, c, fx.u("/apps/kass/releases"))
for _, want := range []string{
`action="/a/bab/apps/kass/canary"`, `action="/a/bab/apps/kass/canary/retire"`,
"canary/x", "no host yet", "Create canary",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/releases missing %q in:\n%s", want, body)
}
}
@@ -1001,9 +1050,9 @@ func TestAppPageInstances(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
- // Before any reporting route exists: no Instances section — the page is
+ // Before any reporting route exists: no Instances section — the tab is
// exactly the route-level view.
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "<h2>Instances</h2>") {
+ if _, _, body := get(t, c, fx.u("/apps/kass/instances")); strings.Contains(body, "<h2>Instances</h2>") {
t.Fatal("Instances section rendered with nothing reporting")
}
@@ -1011,26 +1060,31 @@ func TestAppPageInstances(t *testing.T) {
app := newInstanceApp(t, kassReport)
fx.addRoute(t, registry.Route{Host: app.host(t), Kind: "instance",
Status: "ready", App: "kass"})
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/instances"))
for _, want := range []string{
"<h2>Instances</h2>", "reports 3 internal instances",
"riders-2026", "spring-camp", "trial-run",
"eu-west-1", "archived", "read-only since June",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/instances missing %q in:\n%s", want, body)
}
}
}
+// TestRecentActivity pins the activity tab against real, console-driven
+// events (not synthetic RecordEvent calls) — TestActivityTab only checks the
+// tab's chrome, so this is where seeded rows actually render. It also pins
+// activityCap=50: past the cap, the oldest rows fall off and the newest 50
+// (and no more) are what's on the page.
func TestRecentActivity(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- // Fresh app page: no activity yet.
- if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Recent activity") {
- t.Fatal("activity section rendered before any action")
+ // Fresh activity tab: no activity yet.
+ if _, _, body := get(t, c, fx.u("/apps/kass/activity")); !strings.Contains(body, "Nothing yet") {
+ t.Fatal("activity tab did not show its empty state before any action")
}
// A promotion the ladder allows (a fresh build entering at edge), and a
@@ -1046,9 +1100,9 @@ func TestRecentActivity(t *testing.T) {
postForm(t, c, fx.u("/apps/kass/canary/retire"),
url.Values{"csrf": {csrf}, "name": {"rc"}})
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/activity"))
for _, want := range []string{
- "Recent activity", "paul@keymail.dev",
+ "paul@keymail.dev",
"promoted v3 to edge", "rolled back stable to v1",
"created canary rc at v1", "retired canary rc",
"CLI promotions don",
@@ -1061,6 +1115,26 @@ func TestRecentActivity(t *testing.T) {
if strings.Index(body, "retired canary rc") > strings.Index(body, "promoted v3 to edge") {
t.Fatal("activity is not newest-first")
}
+
+ // Push the account's own event count well past the cap. The 4 real
+ // actions above are now the oldest rows in the account, so this also
+ // exercises them falling off — not just synthetic filler.
+ for i := 0; i < 60; i++ {
+ if err := fx.st.RecordEvent(OpsAccountID, "kass", "paul@keymail.dev",
+ "marker", fmt.Sprintf("cap-marker-%03d", i), time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ _, _, capped := get(t, c, fx.u("/apps/kass/activity"))
+ if n := strings.Count(capped, "cap-marker-"); n != 50 {
+ t.Fatalf("activity tab rendered %d rows past the cap, want exactly 50 (activityCap)", n)
+ }
+ if !strings.Contains(capped, "cap-marker-059") {
+ t.Fatal("newest event fell off the capped activity tab")
+ }
+ if strings.Contains(capped, "cap-marker-000") || strings.Contains(capped, "promoted v3 to edge") {
+ t.Fatal("activity tab kept rows older than its 50-row cap should allow")
+ }
}
func TestActivityTab(t *testing.T) {
@@ -1105,11 +1179,12 @@ func TestPromotionsDisabledWithoutKey(t *testing.T) {
t.Fatal("keyless console is missing the disabled note")
}
// The Domains section is a read — keyless (read-only) mode still renders it.
- if !strings.Contains(body, "Domains") || !strings.Contains(body, "kass.carlosapp.dev") {
+ if _, _, sbody := get(t, c, fx.u("/apps/kass/settings")); !strings.Contains(sbody, "Domains") || !strings.Contains(sbody, "kass.carlosapp.dev") {
t.Fatal("keyless console is missing the Domains section")
}
- if strings.Contains(body, `action="/a/bab/apps/kass/promote"`) || strings.Contains(body, `action="/a/bab/apps/kass/rollback"`) ||
- strings.Contains(body, `action="/a/bab/apps/kass/canary"`) || strings.Contains(body, `action="/a/bab/apps/kass/canary/retire"`) {
+ _, _, rbody := get(t, c, fx.u("/apps/kass/releases"))
+ if strings.Contains(rbody, `action="/a/bab/apps/kass/promote"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/rollback"`) ||
+ strings.Contains(rbody, `action="/a/bab/apps/kass/canary"`) || strings.Contains(rbody, `action="/a/bab/apps/kass/canary/retire"`) {
t.Fatal("keyless console rendered mutation forms")
}
if status, _, _ := postForm(t, c, fx.u("/apps/kass/promote"),
@@ -1285,7 +1360,7 @@ func TestAppPageRendersDomains(t *testing.T) {
fx.addRoute(t, registry.Route{Host: "kass.bab.carlosapp.dev", Kind: "instance",
Status: "ready", App: "kass", Platform: true, DNSStatus: "pointing", Account: "paul@keymail.dev"})
c := signInPaul(t, fx)
- _, _, body := get(t, c, fx.u("/apps/kass"))
+ _, _, body := get(t, c, fx.u("/apps/kass/settings"))
for _, want := range []string{
"Domains", "kass.bab.carlosapp.dev", "platform", "pointing",
"kass.carlosapp.dev", "custom", "—",
@@ -1293,7 +1368,7 @@ func TestAppPageRendersDomains(t *testing.T) {
"Checks run every few minutes.",
} {
if !strings.Contains(body, want) {
- t.Fatalf("/apps/kass missing %q in:\n%s", want, body)
+ t.Fatalf("/apps/kass/settings missing %q in:\n%s", want, body)
}
}
}
@@ -1643,9 +1718,9 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200: %s", status, body)
+ t.Fatalf("/apps/kass/instances = %d, want 200: %s", status, body)
}
for _, want := range []string{
"Resources", "owned.example.com", "11%", "1.0 KB",
@@ -1669,19 +1744,20 @@ func TestAppPageOwnerMetricsBoundary(t *testing.T) {
}
// TestAppPageSurvivesMetricsFailure: metrics are decoration. A bucket that
-// fails every read must still leave the owner a fully rendered app page.
+// fails every read must still leave the owner a fully rendered instances tab
+// — the only tab that reads metrics now that the overview doesn't.
func TestAppPageSurvivesMetricsFailure(t *testing.T) {
fx := newConsoleFixture(t)
fx.addRoute(t, registry.Route{Host: "owned.example.com", Kind: "instance",
Status: "ready", App: "kass", Backing: registry.BackingUnit})
fx.srv.cfg.Releases = failingListStore{fx.rel}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass with a broken metrics read = %d, want 200: %s", status, body)
+ t.Fatalf("/apps/kass/instances with a broken metrics read = %d, want 200: %s", status, body)
}
- if !strings.Contains(body, "Domains") {
- t.Fatalf("app page did not render its normal sections: %s", body)
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatalf("instances tab did not render its normal chrome: %s", body)
}
}
@@ -1732,9 +1808,9 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200", status)
+ t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
if !strings.Contains(body, "11%") {
t.Fatalf("own instance's metrics missing in:\n%s", body)
@@ -1765,7 +1841,7 @@ func resourcesSection(t *testing.T, body string) string {
if i < 0 {
t.Fatalf("no Resources section in:\n%s", body)
}
- j := strings.Index(body[i:], "<h2>Channels</h2>")
+ j := strings.Index(body[i:], "</main>")
if j < 0 {
t.Fatalf("Resources section never ended:\n%s", body[i:])
}
@@ -1796,9 +1872,9 @@ func TestAppPageOwnerMetricsStaleBoxIsNotCurrent(t *testing.T) {
}
c := signInPaul(t, fx)
- status, _, body := get(t, c, fx.u("/apps/kass"))
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
if status != http.StatusOK {
- t.Fatalf("/apps/kass = %d, want 200", status)
+ t.Fatalf("/apps/kass/instances = %d, want 200", status)
}
if !strings.Contains(body, "asleep — no recent readings") {
t.Fatalf("stale box did not render as no-recent-readings:\n%s", resourcesSection(t, body))
diff --git a/internal/console/templates/app.html b/internal/console/templates/app.html
deleted file mode 100644
index df603a2bc..000000000
--- a/internal/console/templates/app.html
+++ /dev/null
@@ -1,207 +0,0 @@
-{{define "app"}}{{template "top" .}}
-{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
-{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
-{{template "appnav" .}}
-{{if .Detail.AccountMismatch}}
-<p class="banner error">
- <strong>This app is not deploying.</strong> Its hosts read releases from
- account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Its bucket objects were moved without the console's own record
- following. On the console's box, run:
- <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
- <br><span class="small">If it is the routes that are in the wrong account,
- move those back instead — whichever of the two is right, they have to
- agree.</span>
-</p>
-{{end}}
-
-{{template "ladder" .}}
-
-<h2>Domains</h2>
-{{if .Detail.Domains}}
-<div class="tablewrap">
-<table>
- <tr><th>Domain</th><th>Type</th><th>DNS</th><th>TLS</th></tr>
- {{range .Detail.Domains}}
- <tr>
- <td><code>{{.Domain}}</code></td>
- <td>{{.Type}}</td>
- <td>{{if .DNS}}{{.DNS}}{{else}}<span title="unknown — not checked yet">—</span>{{end}}</td>
- <td>{{if .TLS}}{{.TLS}}{{else}}<span title="unknown">—</span>{{end}}</td>
- </tr>
- {{if .Guidance}}
- <tr><td colspan="4" class="small">{{.Guidance}}</td></tr>
- {{end}}
- {{end}}
-</table>
-</div>
-{{else}}<p class="small">No domains yet — the first shipped route brings one.</p>{{end}}
-
-{{if and .Instances .Instances.Reporting}}
-<h2>Instances</h2>
-<p class="small">This app reports {{len .Instances.Instances}} internal instance{{if ne (len .Instances.Instances) 1}}s{{end}} (via <code>/.carlos/instances</code> on <code>{{.Instances.Host}}</code>).</p>
-{{if .Instances.Instances}}
-<div class="tablewrap">
-<table>
- <tr><th>Slug</th><th>Status</th><th>Region</th><th>Detail</th></tr>
- {{range .Instances.Instances}}
- <tr>
- <td><code>{{.Slug}}</code></td>
- <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td>
- <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td>
- <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td>
- </tr>
- {{end}}
-</table>
-</div>
-{{end}}
-{{end}}
-
-{{if .AppMetrics}}
-<h2>Resources</h2>
-<p class="small">CPU and memory for each of this app&#39;s instances, measured on the box it runs on. Sparklines cover the last 48 hours; the memory line is drawn against that instance&#39;s own peak, and the number beside it is the current figure.</p>
-{{range .AppMetrics}}
-{{if .Reporting}}
-<p class="small"><code>{{.Host}}</code></p>
-<section class="tiles" aria-label="{{.Host}} resources">
- <div class="tile">
- <h2>CPU</h2>
- <p class="tile-value">{{pct .CPUFrac}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkCPU}}"/></svg>
- </div>
- <div class="tile">
- <h2>Memory</h2>
- <p class="tile-value">{{bytesOrDash .MemBytes}}</p>
- <svg viewBox="0 0 240 40"><polyline fill="none" stroke="currentColor" points="{{.SparkMem}}"/></svg>
- </div>
-</section>
-{{else}}
-{{if .Hibernate}}
-<p class="small"><code>{{.Host}}</code> — asleep — no recent readings</p>
-{{else}}
-<p class="small"><code>{{.Host}}</code> — no per-instance readings</p>
-{{end}}
-{{end}}
-{{end}}
-{{end}}
-
-<h2>Channels</h2>
-{{if .Detail.Channels}}
-<div class="tablewrap">
-<table>
- <tr><th>Channel</th><th>Version</th><th>Promoted</th><th>Bake</th><th></th></tr>
- {{range .Detail.Channels}}
- <tr>
- <td>{{.Name}}</td>
- {{if .Err}}
- <td colspan="3" class="error">{{.Err}}</td>
- {{else}}
- <td><code>{{.Version}}</code></td>
- <td>{{fmtTime .PromotedAt}}</td>
- <td>{{if .BakedUntil.IsZero}}live{{else}}bakes until ~{{fmtTime .BakedUntil}}{{end}}{{if .FastTrack}} · fast-tracked{{end}}</td>
- {{end}}
- <td>{{if and $.CanPromote .HasHistory (not .Err)}}
- <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/rollback">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="channel" value="{{.Name}}">
- <button type="submit" class="linkish">Roll back</button>
- </form>
- {{end}}</td>
- </tr>
- {{end}}
-</table>
-</div>
-<p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p>
-{{else}}<p class="small">No channels yet.</p>{{end}}
-
-<h2>Canaries</h2>
-{{if .Detail.Canaries}}
-<div class="tablewrap">
-<table>
- <tr><th>Name</th><th>Channel</th><th>Version</th><th>Created</th><th>URL</th><th></th></tr>
- {{range .Detail.Canaries}}
- <tr>
- <td>{{.Name}}</td>
- <td><code>{{.Channel}}</code></td>
- {{if .Err}}
- <td colspan="3" class="error">{{.Err}}</td>
- {{else}}
- <td><code>{{.Version}}</code></td>
- <td>{{fmtTime .PromotedAt}}</td>
- <td>{{if .Hosts}}{{range .Hosts}}<a href="https://{{.}}">{{.}}</a> {{end}}{{else}}no host yet{{end}}</td>
- {{end}}
- <td>{{if $.CanPromote}}
- <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/canary/retire">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="name" value="{{.Name}}">
- <button type="submit" class="linkish">Retire</button>
- </form>
- {{end}}</td>
- </tr>
- {{end}}
-</table>
-</div>
-{{else}}<p class="small">No canaries — a canary is a fresh channel serving one chosen release immediately, with no bake window.</p>{{end}}
-{{if and .CanPromote .Versions}}
-<form class="signin" method="post" action="{{.Base}}/apps/{{.Detail.App}}/canary">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="name" placeholder="rc-2">
- <select name="version">
- {{range .Versions}}{{if not .Err}}<option value="{{.Version}}">{{.Version}}</option>{{end}}{{end}}
- </select>
- <button type="submit">Create canary</button>
-</form>
-<p class="small">Creates channel <code>canary/&lt;name&gt;</code> pointing at the chosen release — serving immediately, no bake. It gets its URL from a host that follows the channel: <code>carlos add -app {{.Detail.App}} -channel canary/&lt;name&gt; &lt;host&gt;</code>.</p>
-{{end}}
-
-<h2>Releases</h2>
-{{if .Versions}}
-<div class="tablewrap">
-<table>
- <tr><th>Version</th><th>Kind</th><th>Built</th>{{if .CanPromote}}<th>Promote</th>{{end}}</tr>
- {{range .Versions}}
- <tr>
- <td><code>{{.Version}}</code></td>
- {{if .Err}}
- <td colspan="2" class="error">{{.Err}}</td>
- {{else}}
- <td>{{.Kind}}</td>
- <td>{{if .BuiltAt}}{{.BuiltAt}}{{else}}—{{end}}</td>
- {{end}}
- {{if $.CanPromote}}<td>
- <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote">
- <input type="hidden" name="csrf" value="{{$.CSRF}}">
- <input type="hidden" name="version" value="{{.Version}}">
- <select name="channel">
- {{range $.Detail.Channels}}<option value="{{.Name}}">{{.Name}}</option>{{end}}
- </select>
- <input type="text" name="new_channel" placeholder="or a new channel" size="14">
- <button type="submit" class="linkish">Promote</button>
- </form>
- </td>{{end}}
- </tr>
- {{end}}
-</table>
-</div>
-{{else}}
-<p>Nothing shipped yet. Ship your first release from your repo:</p>
-<pre><code>carlos ship -app {{.Detail.App}} -version $(git rev-parse --short HEAD) -target linux-arm64 &lt;binary&gt;
-carlos ship -app {{.Detail.App}} -kind static -version v1 &lt;site-dir&gt;</code></pre>
-<p class="small">Releases appear here once shipped, and get promoted to channels from this page.</p>
-{{end}}
-{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
-
-{{if .Events}}
-<h2>Recent activity</h2>
-<div class="tablewrap">
-<table>
- <tr><th>When</th><th>Who</th><th>What</th></tr>
- {{range .Events}}
- <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
- {{end}}
-</table>
-</div>
-<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
-{{end}}
-{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
new file mode 100644
index 000000000..c312c4804
--- /dev/null
+++ b/internal/console/templates/app_overview.html
@@ -0,0 +1,25 @@
+{{define "app_overview"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+{{if .Detail.AccountMismatch}}
+<p class="banner error">
+ <strong>This app is not deploying.</strong> Its hosts read releases from
+ account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Its bucket objects were moved without the console's own record
+ following. On the console's box, run:
+ <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
+ <br><span class="small">If it is the routes that are in the wrong account,
+ move those back instead — whichever of the two is right, they have to
+ agree.</span>
+</p>
+{{end}}
+{{template "ladder" .}}
+<p class="small">
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a>
+ {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}}
+ · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a>
+</p>
+{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{template "bottom" .}}{{end}}