carlos / platform Public

Merge origin/main into console-ux-05-deployments

Paul Campbell pushed by paul@keymail.dev da01efb131afdb30a1d8075d990287acbb789ec0 parent df6621eparent f4207d3
18 files changed, +1079 −202
  • internal/console/accountreview.go +99 −0
  • internal/console/accountreview_test.go +283 −0
  • internal/console/apilogs_test.go +169 −0
  • internal/console/appsettings.go +32 −35
  • internal/console/appsettings_test.go +5 −8
  • internal/console/logsview_test.go +31 −0
  • internal/console/mismatchcopy.go +23 −0
  • internal/console/overview_test.go +5 −2
  • internal/console/server.go +217 −13
  • internal/console/server_account_test.go +5 −8
  • internal/console/templates/account_review.html +24 −0
  • internal/console/templates/app_logs.html +155 −13
  • internal/console/templates/app_overview.html +1 −22
  • internal/console/templates/app_releases.html +1 −22
  • internal/console/templates/app_settings.html +3 −0
  • internal/console/templates/layout.html +6 −1
  • internal/console/view.go +14 −66
  • internal/console/view_test.go +6 −12

CI passed — run details

diff --git a/internal/console/accountreview.go b/internal/console/accountreview.go
new file mode 100644
index 000000000..81af04eac
--- /dev/null
+++ b/internal/console/accountreview.go
@@ -0,0 +1,99 @@
+package console
+
+import (
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+type AccountReviewGroup struct {
+ Account Account
+ Hosts []string
+}
+
+type AccountReview struct {
+ Groups []AccountReviewGroup
+ Count int
+ NoInstances bool
+ Owned int
+ blocked bool
+}
+
+func (a AccountReview) SummaryText() string {
+ return strings.ReplaceAll(approvedMismatchCopy.Summary, "{count}", strconv.Itoa(a.Count))
+}
+
+// appAccountReview returns only candidates whose source account the viewer
+// can access. A source with its own same-named app is a legitimate sibling,
+// not evidence that the viewed app is half-migrated.
+func (s *Server) appAccountReview(address string, viewed *Account, app string) (AccountReview, bool, error) {
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ routes, err := reg.List()
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ accounts, err := s.store.AccountsFor(address)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ accessible := make(map[string]Account, len(accounts))
+ for _, account := range accounts {
+ accessible[account.Sqid] = account
+ }
+ groups := make(map[string][]string)
+ owned, hidden, sibling := 0, false, false
+ for _, rt := range routes {
+ if rt.App != app || rt.Platform {
+ continue
+ }
+ source := release.RouteAccount(rt)
+ if source == viewed.Sqid {
+ owned++
+ continue
+ }
+ account, ok := accessible[source]
+ if !ok {
+ hidden = true
+ continue
+ }
+ has, err := s.store.HasAppIn(account.ID, app)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ if !has {
+ has, err = s.store.HasTrashedAppIn(account.ID, app)
+ if err != nil {
+ return AccountReview{}, false, err
+ }
+ }
+ if has {
+ sibling = true
+ continue
+ }
+ groups[source] = append(groups[source], rt.Host)
+ }
+ var review AccountReview
+ review.Owned = owned
+ review.blocked = hidden || sibling
+ for source, hosts := range groups {
+ sort.Strings(hosts)
+ review.Groups = append(review.Groups, AccountReviewGroup{Account: accessible[source], Hosts: hosts})
+ review.Count += len(hosts)
+ }
+ sort.Slice(review.Groups, func(i, j int) bool { return review.Groups[i].Account.Sqid < review.Groups[j].Account.Sqid })
+ return review, review.Count > 0 && owned == 0 && !hidden && !sibling && len(review.Groups) == 1, nil
+}
+
+func (s *Server) handleAppAccountReview(w http.ResponseWriter, r *http.Request) {
+ p, _, ok := s.appPage(w, r, "overview")
+ if !ok {
+ return
+ }
+ s.render(w, http.StatusOK, "app_account_review", p)
+}
diff --git a/internal/console/accountreview_test.go b/internal/console/accountreview_test.go
new file mode 100644
index 000000000..6dc05deca
--- /dev/null
+++ b/internal/console/accountreview_test.go
@@ -0,0 +1,283 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAccountReviewExcludesLegitimateSameNamedApp(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "other.example", App: "kass", Account: "cdef", Kind: "instance"})
+ viewed, err := fx.st.AccountBySqid("bab")
+ if err != nil || viewed == nil {
+ t.Fatal(err)
+ }
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("legitimate sibling became a repair candidate: %+v all=%v", review, all)
+ }
+}
+
+func TestAccountReviewSiblingPreventsUnrelatedMove(t *testing.T) {
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ for _, tc := range []struct {
+ id int64
+ sqid string
+ name string
+ app bool
+ }{{4242, "cdef", "Sibling", true}, {4343, "cghi", "Candidate", false}} {
+ if err := fx.st.CreateAccount(tc.id, tc.sqid, tc.name, "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(tc.id, "paul@keymail.dev", RoleOwner, now); err != nil {
+ t.Fatal(err)
+ }
+ if tc.app {
+ if _, err := fx.st.CreateAppIn(tc.id, "kass", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ fx.addRoute(t, registry.Route{Host: "sibling.example", App: "kass", Account: "cdef", Kind: "instance"})
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "kass", Account: "cghi", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 1 || all {
+ t.Fatalf("review = %+v all=%v; sibling must prevent move authority", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; same-named sibling must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewHidesInaccessibleSource(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.addRoute(t, registry.Route{Host: "secret.example", App: "kass", Account: "unknown", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("inaccessible source disclosed: %+v all=%v", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; an inaccessible source must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewTreatsTrashedSameNamedAppAsSibling(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.TrashAppRow(otherID, "kass", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "trashed-sibling.example", App: "kass", Account: "cdef", Kind: "instance"})
+ viewed, _ := fx.st.AccountBySqid("bab")
+ review, all, err := fx.srv.appAccountReview("paul@keymail.dev", viewed, "kass")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if review.Count != 0 || all {
+ t.Fatalf("trashed sibling became a move candidate: %+v all=%v", review, all)
+ }
+ if _, agreed, err := fx.srv.routeAccountFor("paul@keymail.dev", viewed, "kass"); !errors.Is(err, errMoveTargetUnavailable) || agreed {
+ t.Fatalf("routeAccountFor agreed=%v err=%v; trashed sibling must hide the move target", agreed, err)
+ }
+}
+
+func TestAccountReviewPageShowsOwnedCandidatesAndHidesFromNonMembers(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, "cdef", "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(otherID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "kass", Account: "cdef", Kind: "instance"})
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, "candidate.example") || !strings.Contains(body, "Other") ||
+ !strings.Contains(body, approvedMismatchCopy.Partial) {
+ t.Fatalf("account review = %d, missing candidate/account/partial copy:\n%s", status, body)
+ }
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ routes, err := reg.List()
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, route := range routes {
+ if route.App == "kass" && !route.Platform {
+ if err := reg.SetAccount(route.Host, "cdef"); err != nil {
+ t.Fatal(err)
+ }
+ }
+ }
+ reg.Close()
+ status, _, body = get(t, c, fx.u("/apps/kass/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, approvedMismatchCopy.Repairable) ||
+ strings.Contains(body, approvedMismatchCopy.Partial) {
+ t.Fatalf("fully mismatched review = %d, missing repairable branch:\n%s", status, body)
+ }
+
+ fx.setUser("eve@keymail.dev")
+ stranger := newBrowser(t)
+ signUp(t, fx, stranger, "code-account-review-stranger")
+ strangerCSRF := sessionToken(t, fx, stranger)
+ strangerStatus, _, strangerBody := get(t, stranger, fx.u("/apps/kass/account-review"))
+ ghostStatus, _, ghostBody := get(t, stranger, fx.ts.URL+"/definitely-not-a-page")
+ if strangerStatus != http.StatusNotFound || strangerStatus != ghostStatus || strangerBody != ghostBody {
+ t.Fatalf("non-member review = %d %q; unrouted = %d %q", strangerStatus, strangerBody, ghostStatus, ghostBody)
+ }
+ moveStatus, _, moveBody := postForm(t, stranger, fx.u("/apps/kass/move"), url.Values{"csrf": {strangerCSRF}})
+ if moveStatus != http.StatusNotFound || moveBody != ghostBody {
+ t.Fatalf("non-member move = %d %q; unrouted = %d %q", moveStatus, moveBody, ghostStatus, ghostBody)
+ }
+}
+
+func TestMoveRefusesHiddenOrSiblingSources(t *testing.T) {
+ for _, tc := range []struct {
+ name, account string
+ sibling bool
+ }{
+ {name: "hidden", account: "unknown"},
+ {name: "sibling", account: "cdef", sibling: true},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if tc.sibling {
+ const otherID = 4242
+ if err := fx.st.CreateAccount(otherID, tc.account, "Other", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(otherID, "kass", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ }
+ fx.addRoute(t, registry.Route{Host: tc.name + ".example", App: "kass", Account: tc.account, Kind: "instance"})
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, _, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}})
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("unavailable move = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody)
+ }
+ accounts, err := fx.st.AppAccounts("kass")
+ if err != nil || len(accounts) == 0 || accounts[0] != OpsAccountID {
+ t.Fatalf("AppAccounts after refusal = %v, %v", accounts, err)
+ }
+ })
+ }
+}
+
+func TestPublishedAppWithoutOwnedInstancesWarnsFromOwnedStateOnly(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ c := signInPaul(t, fx)
+
+ _, _, empty := get(t, c, fx.u("/apps/empty"))
+ if strings.Contains(empty, approvedMismatchCopy.NoInstances) {
+ t.Fatalf("an unshipped empty app shows the published-app warning:\n%s", empty)
+ }
+
+ manifest := release.Manifest{
+ Schema: 1, App: "empty", Version: "v1", Kind: "binary", BuiltAt: time.Now().UTC().Format(time.RFC3339),
+ Artifacts: map[string]release.Artifact{"linux-amd64": {File: "empty"}},
+ }
+ if err := release.Ship(context.Background(), fx.rel, "bab", manifest,
+ map[string][]byte{"linux-amd64": []byte("binary")}); err != nil {
+ t.Fatal(err)
+ }
+ seedChannel(t, fx, "empty", "stable", "v1")
+ _, _, absentForeign := get(t, c, fx.u("/apps/empty"))
+ if !strings.Contains(absentForeign, approvedMismatchCopy.NoInstances) ||
+ !strings.Contains(absentForeign, `/apps/empty/instances">Instances</a>`) {
+ t.Fatalf("published app with no owned instances lacks the warning and Instances link:\n%s", absentForeign)
+ }
+ _, _, settings := get(t, c, fx.u("/apps/empty/settings"))
+ if !strings.Contains(settings, approvedMismatchCopy.NoInstances) || strings.Contains(settings, "This app deploys from") {
+ t.Fatalf("settings reassures a published app with no owned instances:\n%s", settings)
+ }
+
+ fx.addRoute(t, registry.Route{Host: "hidden.example", App: "empty", Account: "unknown", Kind: "instance"})
+ _, _, hiddenForeign := get(t, c, fx.u("/apps/empty"))
+ if hiddenForeign != absentForeign {
+ t.Fatal("a hidden foreign route changed the owned app warning render")
+ }
+
+ fx.addRoute(t, registry.Route{Host: "static.example", App: "empty", Account: "bab", Kind: "static"})
+ _, _, ownedStatic := get(t, c, fx.u("/apps/empty"))
+ if strings.Contains(ownedStatic, approvedMismatchCopy.NoInstances) {
+ t.Fatalf("an owned static route still shows the no-instances warning:\n%s", ownedStatic)
+ }
+}
+
+func TestAccountReviewDoesNotClaimOwnedInstancesWhenABlockerLeavesNone(t *testing.T) {
+ fx := newConsoleFixture(t)
+ const candidateID = 4242
+ if err := fx.st.CreateAccount(candidateID, "cdef", "Candidate", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(candidateID, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ const siblingID = 4343
+ if err := fx.st.CreateAccount(siblingID, "cghi", "Sibling", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(siblingID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "empty", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.addRoute(t, registry.Route{Host: "candidate.example", App: "empty", Account: "cdef", Kind: "instance"})
+ fx.addRoute(t, registry.Route{Host: "sibling.example", App: "empty", Account: "cghi", Kind: "instance"})
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/empty/account-review"))
+ if status != http.StatusOK || !strings.Contains(body, "candidate.example") {
+ t.Fatalf("blocked review = %d, missing safe candidate:\n%s", status, body)
+ }
+ if strings.Contains(body, approvedMismatchCopy.Partial) || strings.Contains(body, "sibling.example") {
+ t.Fatalf("blocked review claims owned instances or exposes sibling:\n%s", body)
+ }
+}
diff --git a/internal/console/apilogs_test.go b/internal/console/apilogs_test.go
index 5de42866c..4deb40d38 100644
--- a/internal/console/apilogs_test.go
+++ b/internal/console/apilogs_test.go
@@ -4,12 +4,15 @@ import (
"context"
"encoding/json"
"fmt"
+ "io"
"net/http"
+ "net/url"
"strings"
"testing"
"time"
"github.com/carlosframework/platform/internal/applog"
+ "github.com/carlosframework/platform/internal/registry"
"github.com/carlosframework/platform/internal/release"
)
@@ -44,6 +47,24 @@ func seedLogChunks(t *testing.T, fx *consoleFixture, now time.Time) []applog.Rec
return recs
}
+func seedManyLogRecords(t *testing.T, fx *consoleFixture, now time.Time, n int) []applog.Record {
+ t.Helper()
+ recs := make([]applog.Record, n)
+ for i := range recs {
+ recs[i] = applog.Record{TS: now.Add(time.Duration(i) * time.Millisecond), Box: "box-many",
+ Host: "titogo.example.com", Stream: applog.StreamApp, Msg: fmt.Sprintf("line %d", i), Seq: uint64(1000 + i)}
+ }
+ body, err := applog.EncodeChunk(recs)
+ if err != nil {
+ t.Fatal(err)
+ }
+ key := applog.ChunkKey(release.OpsAccount, "titogo", "box-many", applog.HourOf(recs[0].TS), recs[0].Seq, len(body))
+ if _, err := fx.rel.Put(context.Background(), key, body, ""); err != nil {
+ t.Fatal(err)
+ }
+ return recs
+}
+
// TestAPILogsTailFiltersAndFollow drives the endpoint end to end: default
// tail, stream/level/grep/field filters, and the cursor follow loop.
func TestAPILogsTailFiltersAndFollow(t *testing.T) {
@@ -151,10 +172,127 @@ func TestAPILogsRefusals(t *testing.T) {
_ = fmt.Sprintf("%v", resp)
}
+func TestAppLogsDataUsesBrowserSessionAndFilters(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-logs-browser-data")
+ seedLogChunks(t, fx, time.Now().UTC())
+ fx.setUser("alice@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-logs-browser-data-signin")
+
+ status, _, body := get(t, c, fx.u("/apps/titogo/logs/data?host=titogo.example.com&level=error"))
+ if status != http.StatusOK {
+ t.Fatalf("browser logs data = %d: %s", status, body)
+ }
+ var resp appLogsDataResponse
+ if err := json.Unmarshal([]byte(body), &resp); err != nil {
+ t.Fatalf("browser logs JSON: %v: %s", err, body)
+ }
+ if len(resp.Lines) != 1 || !strings.Contains(resp.Lines[0].Msg, "wake failed") || resp.Lines[0].Tone != "error" {
+ t.Fatalf("filtered browser lines = %+v", resp.Lines)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?host=deleted.example.com"))
+ if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) {
+ t.Fatalf("historical host filter broadened = %d: %s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs/data?text=wake%5B"))
+ if status != http.StatusOK || !strings.Contains(body, `"lines":[]`) {
+ t.Fatalf("browser text search was treated as a regular expression = %d: %s", status, body)
+ }
+ assertNonMember404(t, fx, "/apps/titogo/logs/data")
+}
+
+func TestAppLogsDataCursorPaginationAndRefusals(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-logs-browser-cursor")
+ now := time.Now().UTC()
+ recs := seedLogChunks(t, fx, now)
+ fx.setUser("alice@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-logs-browser-cursor-signin")
+
+ read := func(query string) (int, http.Header, appLogsDataResponse, string) {
+ t.Helper()
+ req, err := http.NewRequest(http.MethodGet, fx.u("/apps/titogo/logs/data?"+query), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer res.Body.Close()
+ b, err := io.ReadAll(res.Body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ status, headers, body := res.StatusCode, res.Header, string(b)
+ var out appLogsDataResponse
+ if status == http.StatusOK {
+ if err := json.Unmarshal([]byte(body), &out); err != nil {
+ t.Fatalf("decode logs data: %v: %s", err, body)
+ }
+ }
+ return status, headers, out, body
+ }
+
+ status, headers, out, body := read("cursor=" + url.QueryEscape(applog.EncodeCursor(recs[0])))
+ if status != http.StatusOK || len(out.Lines) != 1 || !strings.Contains(out.Lines[0].Msg, "wake failed") {
+ t.Fatalf("cursor read = %d %+v: %s", status, out, body)
+ }
+ if headers.Get("Cache-Control") != "no-store" {
+ t.Fatalf("logs data Cache-Control = %q", headers.Get("Cache-Control"))
+ }
+ last := applog.EncodeCursor(recs[1])
+ _, _, out, _ = read("cursor=" + url.QueryEscape(last))
+ if len(out.Lines) != 0 || out.Cursor != last || out.Gap {
+ t.Fatalf("empty cursor read = %+v", out)
+ }
+ expired := applog.EncodeCursor(applog.Record{TS: now.Add(-2 * time.Hour), Box: "old", Seq: 1})
+ _, _, out, _ = read("since=1h&cursor=" + url.QueryEscape(expired))
+ if !out.Gap || len(out.Lines) != 0 {
+ t.Fatalf("expired cursor did not report a gap: %+v", out)
+ }
+ _, _, out, _ = read("cursor=not-a-cursor")
+ if !out.Gap {
+ t.Fatalf("invalid cursor did not report a gap: %+v", out)
+ }
+
+ seedManyLogRecords(t, fx, now.Add(-2*time.Minute), appLogsPageCap+1)
+ _, _, out, _ = read("limit=999")
+ if len(out.Lines) != appLogsPageCap || !out.Truncated || out.Gap {
+ t.Fatalf("bounded page = %d lines, truncated=%v gap=%v", len(out.Lines), out.Truncated, out.Gap)
+ }
+ for _, query := range []string{"since=bad", "level=loud"} {
+ status, _, _, _ := read(query)
+ if status != http.StatusBadRequest {
+ t.Errorf("%s = %d, want 400", query, status)
+ }
+ }
+ fx.srv.cfg.Releases = nil
+ status, headers, _, _ = read("")
+ if status != http.StatusServiceUnavailable || headers.Get("Cache-Control") != "no-store" {
+ t.Fatalf("bucketless logs data = %d, Cache-Control %q", status, headers.Get("Cache-Control"))
+ }
+}
+
// TestAppLogsPage: the console tab renders the same timeline — filters as
// GET params, member-gated like every app page, honest empty state.
func TestAppLogsPage(t *testing.T) {
fx, _ := instanceFixture(t, "code-logs-page")
+ reg, err := registry.Open(fx.regPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, rt := range []registry.Route{
+ {Host: "legacy.example.com", Kind: "instance", App: "titogo", Account: "bab"},
+ {Host: "foreign.example.com", Kind: "instance", App: "titogo", Account: "other"},
+ } {
+ if err := reg.Upsert(rt); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if err := reg.Close(); err != nil {
+ t.Fatal(err)
+ }
now := time.Now().UTC()
seedLogChunks(t, fx, now)
fx.setUser("paul@keymail.dev")
@@ -170,11 +308,42 @@ func TestAppLogsPage(t *testing.T) {
t.Errorf("page missing %q", want)
}
}
+ for _, want := range []string{"All instances", "Search logs", "All levels", "Follow live", "Pause"} {
+ if !strings.Contains(body, want) {
+ t.Errorf("page missing approved copy %q", want)
+ }
+ }
+ if !strings.Contains(body, `option value="legacy.example.com"`) || strings.Contains(body, `option value="foreign.example.com"`) {
+ t.Fatalf("instance filter did not include only the account's legacy routes")
+ }
// Stream filter narrows.
status, _, body = get(t, c, fx.u("/apps/titogo/logs?stream=platform"))
if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") {
t.Fatalf("stream filter page: %d", status)
}
+ // Instance, severity and text filters compose on the browser page too.
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=titogo.example.com&level=error&text=wake"))
+ if status != http.StatusOK || strings.Contains(body, "listening on socket") || !strings.Contains(body, "wake failed") {
+ t.Fatalf("composed browser filters: %d", status)
+ }
+ if !strings.Contains(body, `option value="titogo.example.com" selected`) {
+ t.Fatalf("browser host filter was not retained: %s", body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs?host=deleted.example.com"))
+ if status != http.StatusOK || strings.Contains(body, "wake failed") {
+ t.Fatalf("historical browser host broadened: %d", status)
+ }
+ stamp := url.QueryEscape(now.Add(-30 * time.Minute).Format(time.RFC3339))
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs?since="+stamp+"&grep=wake&follow=1"))
+ if status != http.StatusOK || !strings.Contains(body, `selected>`+now.Add(-30*time.Minute).Format(time.RFC3339)+`</option>`) ||
+ !strings.Contains(body, `type="hidden" name="grep" value="wake"`) ||
+ !strings.Contains(body, `name="text" value=""`) || !strings.Contains(body, `setFollowing(true); poll();`) {
+ t.Fatalf("non-preset/legacy/follow state was not retained = %d: %s", status, body)
+ }
+ status, _, body = get(t, c, fx.u("/apps/titogo/logs?cursor=hand-written"))
+ if status != http.StatusOK || !strings.Contains(body, `<option value="1h" selected>hour</option>`) {
+ t.Fatalf("page cursor without since lost the bounded default window = %d: %s", status, body)
+ }
// Window with nothing in it: the honest empty state, still a 200.
status, _, body = get(t, c, fx.u("/apps/titogo/logs?since=1ns"))
if status != http.StatusOK || !strings.Contains(body, "No log lines in this window") {
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index c6af24e27..6e7d2c3d0 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -1047,7 +1047,11 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) {
back("err", msg)
return
}
- target, agreed, err := s.routeAccountFor(app)
+ target, agreed, err := s.routeAccountFor(addr, acct, app)
+ if errors.Is(err, errMoveTargetUnavailable) {
+ http.NotFound(w, r)
+ return
+ }
if err != nil {
back("err", "Could not read the routes — nothing was moved. Try again.")
return
@@ -1129,45 +1133,38 @@ func moveAdvice(err error, destName string) string {
return "Try again."
}
-// routeAccountFor reads what the ROUTES say the app's account is, and whether
-// they say it with one voice: the first non-platform route's stamped account,
-// plus agreed=false the moment any other non-platform route names a different
-// one. Both are read fresh at action time, so the move never trusts a stale
-// page.
-//
-// The Settings card only offers the move when AllMismatched (assembleSettings),
-// and under AllMismatched every non-platform route disagrees with the viewed
-// account — so the first route is provably also the first MISMATCHED one, and
-// the target here is the same account Assemble's mismatchedByApp[app][0].Account
-// is. But a target alone cannot police the door: outside that gate the first
-// route may be the ONLY one disagreeing, and moving to it would re-home the
-// record away from where most of the app's hosts actually deploy from. agreed
-// is what the handler refuses on — an app whose routes contradict each other
-// has no single account to move to, and the routes are the repair, not the
-// record. An app with no non-platform routes at all agrees vacuously (target
-// ""), which the handler's own nothing-to-move refusal then catches.
-func (s *Server) routeAccountFor(app string) (target string, agreed bool, err error) {
- reg, err := openRegistry(s.cfg.Registry)
- if err != nil {
- return "", false, err
- }
- routes, err := reg.List()
+var errMoveTargetUnavailable = errors.New("move target unavailable to this viewer")
+
+// routeAccountFor recomputes the viewer-safe diagnostic at action time. It
+// grants a target only when every relevant route points to one accessible
+// account which does not already claim its own same-named app.
+func (s *Server) routeAccountFor(address string, source *Account, app string) (target string, agreed bool, err error) {
+ review, all, err := s.appAccountReview(address, source, app)
if err != nil {
return "", false, err
}
- var seen bool
- for _, rt := range routes {
- if rt.App != app || rt.Platform {
- continue
+ if all {
+ target := review.Groups[0].Account.Sqid
+ reg, err := openRegistry(s.cfg.Registry)
+ if err != nil {
+ return "", false, err
}
- acct := release.RouteAccount(rt)
- if !seen {
- target, seen = acct, true
- continue
+ routes, err := reg.List()
+ if err != nil {
+ return "", false, err
}
- if acct != target {
- return target, false, nil
+ for _, rt := range routes {
+ if rt.App == app && !rt.Platform && release.RouteAccount(rt) != target {
+ return "", false, nil
+ }
}
+ return target, true, nil
+ }
+ if review.blocked {
+ return "", false, errMoveTargetUnavailable
+ }
+ if review.Count == 0 && !review.blocked {
+ return source.Sqid, true, nil
}
- return target, true, nil
+ return "", false, nil
}
diff --git a/internal/console/appsettings_test.go b/internal/console/appsettings_test.go
index f4b6edde1..e314c2cb3 100644
--- a/internal/console/appsettings_test.go
+++ b/internal/console/appsettings_test.go
@@ -549,7 +549,7 @@ func TestAppMoveConfirmsThenMovesTheRecord(t *testing.T) {
}
// Moving the record into an account the viewer does not belong to would strand
-// the app for them — refused, with the operator path named instead.
+// the app for them. The refusal must not disclose that account as a move target.
func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
fx := newConsoleFixture(t)
const destID = 4343
@@ -560,16 +560,13 @@ func TestAppMoveRefusesANonMemberOfTheTarget(t *testing.T) {
c := signInPaul(t, fx)
csrf := sessionToken(t, fx, c)
- status, loc, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
+ status, _, body := postGated(t, fx, c, fx.u("/apps/kass/move"),
url.Values{"csrf": {csrf}, "confirmed": {"1"}})
- if status != http.StatusSeeOther || loc != fx.path("/apps/kass/settings") {
- t.Fatalf("move into a stranger account = %d -> %q:\n%s", status, loc, body)
+ ghostStatus, _, ghostBody := get(t, c, fx.ts.URL+"/definitely-not-a-page")
+ if status != http.StatusNotFound || status != ghostStatus || body != ghostBody {
+ t.Fatalf("move into a stranger account = %d %q; unrouted = %d %q", status, body, ghostStatus, ghostBody)
}
notACard(t, body)
- if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page,
- "You are not a member of Third (cghi)") {
- t.Fatalf("no non-membership refusal flash:\n%s", page)
- }
if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has {
t.Fatalf("HasAppIn(ops) = %v %v, want the record where it was", has, err)
}
diff --git a/internal/console/logsview_test.go b/internal/console/logsview_test.go
index 2480b63cd..c5a3e3b33 100644
--- a/internal/console/logsview_test.go
+++ b/internal/console/logsview_test.go
@@ -130,3 +130,34 @@ func TestAppLogsPageIsATerminalPane(t *testing.T) {
t.Fatalf("app output not rendered at all (escaped form absent) in:\n%s", html)
}
}
+
+func TestAppLogsEmptyStateKeepsTerminalAndFollowControls(t *testing.T) {
+ p := page{
+ Title: "Logs", CSRF: "tok", AppName: "taskmonger", ActiveTab: "logs",
+ Account: &Account{Name: "tom@keymail.dev", Sqid: "bdk"}, Base: "/a/bdk", Nav: "/a/bdk",
+ LogSince: "1h", LogWidenURL: "/a/bdk/apps/taskmonger/logs?since=24h",
+ }
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, "app_logs", p); err != nil {
+ t.Fatalf("render empty app_logs: %v", err)
+ }
+ html := buf.String()
+ for _, want := range []string{`class="term"`, `id="log-empty"`, `id="log-follow"`, `id="log-pause"`, `logs/data`} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("empty Logs view missing %q:\n%s", want, html)
+ }
+ }
+ for _, want := range []string{
+ "textContent = value", `querySelectorAll(".tl").length > 200`, "request.abort()",
+ "var activeQuery = new URLSearchParams(new FormData(form))",
+ "if (!following || thisGeneration !== generation) return",
+ `form.addEventListener("input"`, `q.set("follow", "1")`, "location.assign",
+ } {
+ if !strings.Contains(html, want) {
+ t.Fatalf("Logs follow script missing safety %q", want)
+ }
+ }
+ if strings.Contains(html, "innerHTML") {
+ t.Fatal("Logs follow script writes live records through innerHTML")
+ }
+}
diff --git a/internal/console/mismatchcopy.go b/internal/console/mismatchcopy.go
new file mode 100644
index 000000000..dd9ee8385
--- /dev/null
+++ b/internal/console/mismatchcopy.go
@@ -0,0 +1,23 @@
+package console
+
+type MismatchCopy struct {
+ Summary string
+ Link string
+ Title string
+ Intro string
+ Partial string
+ Repairable string
+ Empty string
+ NoInstances string
+}
+
+var approvedMismatchCopy = MismatchCopy{
+ Summary: "{count} addresses need account review.",
+ Link: "Review account use",
+ Title: "Review account use",
+ Intro: "These addresses use another account’s releases. Review ownership before making changes.",
+ Partial: "This account also has its own instances. Review each address before changing its account.",
+ Repairable: "Review this app’s account in Settings before deciding whether to move it.",
+ Empty: "No addresses need account review.",
+ NoInstances: "No instances yet.",
+}
diff --git a/internal/console/overview_test.go b/internal/console/overview_test.go
index 02ab3834c..8e7ec1e3d 100644
--- a/internal/console/overview_test.go
+++ b/internal/console/overview_test.go
@@ -583,6 +583,9 @@ func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
t.Fatal(err)
}
+ if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
// Only ONE of kass's two fixture routes moves (kass.carlosapp.dev);
// docs.kass.carlosapp.dev stays on ops — a partial mismatch, not a full
// one.
@@ -595,7 +598,7 @@ func TestAppPageShowsPartialMismatchNotFullAlarm(t *testing.T) {
if strings.Contains(body, "This app is not deploying") {
t.Errorf("a partial mismatch must not render the full-alarm copy:\n%s", body)
}
- if !strings.Contains(body, "kass.carlosapp.dev") || !strings.Contains(body, "(reads <code>"+sqid+"</code>)") {
- t.Errorf("the partial-mismatch banner does not name the disagreeing route and its account:\n%s", body)
+ if !strings.Contains(body, `/account-review`) {
+ t.Errorf("the partial-mismatch banner does not link to diagnostics:\n%s", body)
}
}
diff --git a/internal/console/server.go b/internal/console/server.go
index 9c0e03f21..5d4f98ec3 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -22,6 +22,7 @@ import (
"os"
"regexp"
"slices"
+ "sort"
"strings"
"sync"
"sync/atomic"
@@ -1113,9 +1114,11 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/deployments", s.handleAppReleases)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/account-review", s.handleAppAccountReview)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances/{host}", s.handleAppInstance)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs", s.handleAppLogs)
+ s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/logs/data", s.handleAppLogsData)
// The Errors tab and its two setting doors (errorsview.go): the tab is
// member-readable like every other app tab; the level is the owner's,
// at app and account level, and both POSTs go through the console gate
@@ -1310,6 +1313,9 @@ type page struct {
Error string
Welcome bool // one-shot "account created" banner
+ AccountReview AccountReview
+ MismatchCopy MismatchCopy
+
ActiveNav string // account nav section this page sits in: "apps" | "fleet" | "team" | "settings"
ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
@@ -1459,11 +1465,20 @@ type page struct {
// App Logs (apilogs.go's sibling page): the merged timeline plus the
// filter state the form re-renders with. Empty LogRows with LogsErr ""
// is the normal nothing-logged-yet state, never an error.
- LogRows []applog.Record
- LogStream string
- LogSince string
- LogTruncated bool
- LogsErr string
+ LogRows []applog.Record
+ LogStream string
+ LogSince string
+ LogHost string
+ LogLevel string
+ LogGrep string
+ LogLegacyGrep string
+ LogFollow bool
+ LogHosts []string
+ LogCopy appLogsCopy
+ LogWidenURL string
+ LogCursor string
+ LogTruncated bool
+ LogsErr string
// App Errors (errorsview.go's page, apierrors.go's sibling): the
// assembled window plus the filter state the form re-renders with. Empty
@@ -1577,6 +1592,10 @@ type page struct {
ServiceSecret *ServiceSecretView
}
+type appLogsCopy struct {
+ Instance, AllInstances, Search, AllLevels, Follow, Pause, Widen, FollowError, Refreshed string
+}
+
// IsOwner is the template's one permission question.
func (p page) IsOwner() bool { return p.Role == RoleOwner }
@@ -1599,6 +1618,7 @@ type appRow struct {
}
func (s *Server) render(w http.ResponseWriter, status int, name string, p page) {
+ p.MismatchCopy = approvedMismatchCopy
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := templates.ExecuteTemplate(w, name, p); err != nil {
@@ -2586,10 +2606,34 @@ func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (pa
p.ActiveTab = tab
p.ActiveNav = "apps"
p.Detail = &views[0]
+ if review, all, err := s.appAccountReview(addr, acct, app); err != nil {
+ log.Printf("console: account review %s/%s: %v", acct.Sqid, app, err)
+ } else {
+ p.AccountReview = review
+ p.AccountReview.NoInstances = review.Owned == 0 && hasPublishedChannel(p.Detail.Channels)
+ for _, group := range review.Groups {
+ for _, host := range group.Hosts {
+ p.Detail.Mismatched = append(p.Detail.Mismatched, MismatchedRoute{Host: host, Account: group.Account.Sqid})
+ }
+ }
+ p.Detail.AllMismatched = all
+ if all {
+ p.Detail.RouteAccount = review.Groups[0].Account.Sqid
+ }
+ }
s.applyPipelineView(r.Context(), p.Detail, acct)
return p, acct, true
}
+func hasPublishedChannel(channels []ChannelView) bool {
+ for _, channel := range channels {
+ if channel.Err == "" && channel.Version != "" {
+ return true
+ }
+ }
+ return false
+}
+
// applyPipelineView is the one place a single app's view becomes
// pipeline-aware (release pipelines v2 §2, §5) — deliberately not inside
// Assemble (AppView.Pipeline's own doc comment: the account home page calls
@@ -2860,19 +2904,64 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
return
}
q := r.URL.Query()
- p.LogStream = q.Get("stream")
- if p.LogStream != "" && !applog.ValidStream(p.LogStream) {
- p.LogStream = ""
+ p.LogCopy = appLogsCopy{
+ Instance: "Instances", AllInstances: "All instances", Search: "Search logs",
+ AllLevels: "All levels", Follow: "Follow live", Pause: "Pause",
+ Widen: "Show the last 24 hours",
+ FollowError: "Live updates failed. Follow live again to retry.", Refreshed: "Updated",
}
+ p.LogStream = q.Get("stream")
p.LogSince = q.Get("since")
- if _, err := time.ParseDuration(p.LogSince); err != nil {
+ if p.LogSince == "" {
p.LogSince = "1h"
}
- f := applog.Filter{Stream: p.LogStream}
- d, _ := time.ParseDuration(p.LogSince)
- f.Since = time.Now().Add(-d)
-
+ p.LogHost = q.Get("host")
+ p.LogLevel = q.Get("level")
+ p.LogGrep = q.Get("text")
+ p.LogLegacyGrep = q.Get("grep")
+ p.LogFollow = q.Get("follow") == "1"
+ widen, _ := url.ParseQuery(q.Encode())
+ widen.Set("since", "24h")
+ widen.Del("cursor")
+ widen.Del("follow")
+ p.LogWidenURL = p.Nav + "/apps/" + url.PathEscape(p.AppName) + "/logs?" + widen.Encode()
+ now := time.Now()
+ f, _, bad := browserLogsFilterFromQuery(r, now)
+ // Cursor is a data-endpoint concern. A hand-written page URL carrying one
+ // must not turn the page's normal one-hour default into an all-history read.
+ if f.Since.IsZero() {
+ f.Since = now.Add(-logsDefaultWindow)
+ }
+ if bad != "" {
+ p.LogsErr = bad
+ }
+ seenHosts := map[string]bool{}
+ if p.LogHost != "" {
+ seenHosts[p.LogHost] = true
+ p.LogHosts = append(p.LogHosts, p.LogHost)
+ }
+ for _, rt := range p.Detail.Routes {
+ if rt.Account == acct.Sqid && !seenHosts[rt.Host] {
+ seenHosts[rt.Host] = true
+ p.LogHosts = append(p.LogHosts, rt.Host)
+ }
+ }
if s.cfg.Releases != nil {
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, p.AppName, instanceListMax)
+ cancel()
+ if err == nil {
+ for _, rec := range recs {
+ if !seenHosts[rec.Host] {
+ seenHosts[rec.Host] = true
+ p.LogHosts = append(p.LogHosts, rec.Host)
+ }
+ }
+ }
+ }
+ sort.Strings(p.LogHosts)
+
+ if s.cfg.Releases != nil && bad == "" {
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, p.AppName, f, logsCollectCap)
@@ -2886,10 +2975,125 @@ func (s *Server) handleAppLogs(w http.ResponseWriter, r *http.Request) {
p.LogTruncated = true
}
p.LogRows = recs
+ if n := len(recs); n > 0 {
+ p.LogCursor = applog.EncodeCursor(recs[n-1])
+ }
}
s.render(w, http.StatusOK, "app_logs", p)
}
+type appLogsDataLine struct {
+ Time string `json:"time"`
+ Stream string `json:"stream"`
+ Host string `json:"host,omitempty"`
+ Msg string `json:"msg"`
+ Fields string `json:"fields,omitempty"`
+ Tone string `json:"tone,omitempty"`
+}
+
+type appLogsDataResponse struct {
+ Lines []appLogsDataLine `json:"lines"`
+ Truncated bool `json:"truncated"`
+ Gap bool `json:"gap,omitempty"`
+ Cursor string `json:"cursor,omitempty"`
+}
+
+// handleAppLogsData is the browser's follow endpoint. It uses the session and
+// app-membership gate, then the API reader's filter/cursor semantics; browser
+// JavaScript never receives or manufactures a bearer credential.
+func (s *Server) handleAppLogsData(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Cache-Control", "no-store")
+ _, _, acct, _, app, ok := s.accountApp(w, r)
+ if !ok {
+ return
+ }
+ now := time.Now()
+ f, limit, bad := browserLogsFilterFromQuery(r, now)
+ if bad != "" {
+ jsonError(w, http.StatusBadRequest, bad)
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable, "could not read the logs")
+ return
+ }
+ cursorText := r.URL.Query().Get("cursor")
+ cur, hasCursor := applog.DecodeCursor(cursorText)
+ if cursorText != "" && !hasCursor {
+ writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true})
+ return
+ }
+ if hasCursor {
+ if !f.Since.IsZero() && cur.TS.Before(f.Since) {
+ writeJSON(w, http.StatusOK, appLogsDataResponse{Lines: []appLogsDataLine{}, Gap: true})
+ return
+ }
+ cursorSince := cur.TS.Add(-time.Second)
+ if f.Since.IsZero() || cursorSince.After(f.Since) {
+ f.Since = cursorSince
+ }
+ }
+ limit = min(limit, appLogsPageCap)
+ ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
+ defer cancel()
+ recs, trimmed, err := applog.ReadWindow(ctx, s.cfg.Releases, acct.Sqid, app, f, logsCollectCap)
+ if err != nil {
+ log.Printf("console: app logs data %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not read the logs")
+ return
+ }
+ resp := appLogsDataResponse{Gap: trimmed && hasCursor}
+ if hasCursor {
+ var after []applog.Record
+ for _, rec := range recs {
+ if cur.Before(rec) {
+ after = append(after, rec)
+ }
+ }
+ if len(after) > limit {
+ after = after[:limit]
+ resp.Truncated = true
+ }
+ recs = after
+ } else if len(recs) > limit {
+ recs = recs[len(recs)-limit:]
+ resp.Truncated = true
+ }
+ for _, line := range logLines(recs) {
+ resp.Lines = append(resp.Lines, appLogsDataLine{
+ Time: line.Time, Stream: line.Stream, Host: line.Host,
+ Msg: line.Msg, Fields: line.Fields, Tone: line.Tone,
+ })
+ }
+ if n := len(recs); n > 0 {
+ resp.Cursor = applog.EncodeCursor(recs[n-1])
+ } else {
+ resp.Cursor = cursorText
+ }
+ if resp.Lines == nil {
+ resp.Lines = []appLogsDataLine{}
+ }
+ writeJSON(w, http.StatusOK, resp)
+}
+
+// browserLogsFilterFromQuery keeps the CLI's filter parser as the one source
+// of validation while making the console's search box literal. A stale
+// console URL carrying grep continues to use its original regular expression.
+func browserLogsFilterFromQuery(r *http.Request, now time.Time) (applog.Filter, int, string) {
+ text := r.URL.Query().Get("text")
+ if text == "" {
+ return logsFilterFromQuery(r, now)
+ }
+ r2 := r.Clone(r.Context())
+ u := *r.URL
+ q := u.Query()
+ q.Set("grep", regexp.QuoteMeta(text))
+ q.Del("text")
+ u.RawQuery = q.Encode()
+ r2.URL = &u
+ return logsFilterFromQuery(r2, now)
+}
+
// handleAppActivity is the console's own audit trail for this app.
func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
p, acct, ok := s.appPage(w, r, "activity")
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go
index 700346616..cac0c3935 100644
--- a/internal/console/server_account_test.go
+++ b/internal/console/server_account_test.go
@@ -684,6 +684,9 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
if err := fx.st.CreateAccount(moved, sqid, "Kass", "paul@keymail.dev", time.Now()); err != nil {
t.Fatal(err)
}
+ if err := fx.st.AddMember(moved, "paul@keymail.dev", RoleOwner, time.Now()); err != nil {
+ t.Fatal(err)
+ }
for _, host := range []string{"kass.carlosapp.dev", "docs.kass.carlosapp.dev"} {
if err := reg.SetAccount(host, sqid); err != nil {
t.Fatal(err)
@@ -692,23 +695,17 @@ func TestAppPageWarnsWhenAccountsDisagree(t *testing.T) {
reg.Close()
_, _, body = get(t, c, fx.u("/apps/kass"))
- if !strings.Contains(body, "not deploying") {
+ if !strings.Contains(body, `/apps/kass/account-review`) {
t.Errorf("no warning on a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, `/apps/kass/settings"`) {
- t.Errorf("the warning does not link to the Settings tab, where the fix now lives:\n%s", body)
- }
// The promote/rollback/canary forms live on the releases tab, not the
// overview — a deep link there must not promote blind. Same warning,
// same fix, on the page that actually hosts the mutations.
_, _, body = get(t, c, fx.u("/apps/kass/releases"))
- if !strings.Contains(body, "not deploying") {
+ if !strings.Contains(body, `/apps/kass/account-review`) {
t.Errorf("no warning on the releases tab of a half-migrated app:\n%s", body)
}
- if !strings.Contains(body, `/apps/kass/settings"`) {
- t.Errorf("the releases tab warning does not link to the Settings tab, where the fix now lives:\n%s", body)
- }
// Settings itself holds the actual repair. paul owns the target account
// here (CreateAccount above makes its creator the owner), so he can reach
diff --git a/internal/console/templates/account_review.html b/internal/console/templates/account_review.html
new file mode 100644
index 000000000..717f81702
--- /dev/null
+++ b/internal/console/templates/account_review.html
@@ -0,0 +1,24 @@
+{{define "accountreviewbanner"}}{{if .AccountReview.Count}}
+<p class="banner">{{.AccountReview.SummaryText}}
+ <a href="{{.Nav}}/apps/{{.AppName}}/account-review">{{.MismatchCopy.Link}}</a>
+</p>
+{{else if .AccountReview.NoInstances}}
+<p class="banner">{{.MismatchCopy.NoInstances}}
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances">Instances</a>
+</p>
+{{end}}{{end}}
+
+{{define "app_account_review"}}{{template "top" .}}
+{{template "appnav" .}}
+<h2>{{.MismatchCopy.Title}}</h2>
+<p>{{.MismatchCopy.Intro}}</p>
+{{if .AccountReview.Groups}}
+ {{range .AccountReview.Groups}}
+ <h3>{{.Account.Name}} (<code>{{.Account.Sqid}}</code>)</h3>
+ <ul>{{range .Hosts}}<li><code>{{.}}</code></li>{{end}}</ul>
+ {{end}}
+ {{if .Detail.AllMismatched}}<p>{{.MismatchCopy.Repairable}}</p>{{else if .AccountReview.Owned}}<p>{{.MismatchCopy.Partial}}</p>{{end}}
+{{else}}
+<p>{{.MismatchCopy.Empty}}</p>
+{{end}}
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_logs.html b/internal/console/templates/app_logs.html
index df3adf516..903a27789 100644
--- a/internal/console/templates/app_logs.html
+++ b/internal/console/templates/app_logs.html
@@ -6,7 +6,13 @@
<h2>Logs</h2>
{{if .LogsErr}}<p class="banner error">{{.LogsErr}}</p>{{end}}
-<form method="get" class="small">
+<form method="get" class="small" id="logs-filter" data-url="{{.Base}}/apps/{{.AppName}}/logs/data" data-cursor="{{.LogCursor}}" data-follow-error="{{.LogCopy.FollowError}}" data-refreshed="{{.LogCopy.Refreshed}}">
+ <label>{{.LogCopy.Instance}}
+ <select name="host">
+ <option value="">{{.LogCopy.AllInstances}}</option>
+ {{range .LogHosts}}<option value="{{.}}"{{if eq . $.LogHost}} selected{{end}}>{{.}}</option>{{end}}
+ </select>
+ </label>
<label>Show
<select name="stream">
<option value=""{{if eq .LogStream ""}} selected{{end}}>everything</option>
@@ -16,6 +22,7 @@
</label>
<label>from the last
<select name="since">
+ {{if and (ne .LogSince "15m") (ne .LogSince "1h") (ne .LogSince "6h") (ne .LogSince "24h") (ne .LogSince "168h")}}<option value="{{.LogSince}}" selected>{{.LogSince}}</option>{{end}}
<option value="15m"{{if eq .LogSince "15m"}} selected{{end}}>15 minutes</option>
<option value="1h"{{if eq .LogSince "1h"}} selected{{end}}>hour</option>
<option value="6h"{{if eq .LogSince "6h"}} selected{{end}}>6 hours</option>
@@ -23,30 +30,165 @@
<option value="168h"{{if eq .LogSince "168h"}} selected{{end}}>week</option>
</select>
</label>
+ <label>{{.LogCopy.AllLevels}}
+ <select name="level">
+ <option value=""{{if eq .LogLevel ""}} selected{{end}}>{{.LogCopy.AllLevels}}</option>
+ <option value="debug"{{if eq .LogLevel "debug"}} selected{{end}}>debug</option>
+ <option value="info"{{if eq .LogLevel "info"}} selected{{end}}>info</option>
+ <option value="warn"{{if eq .LogLevel "warn"}} selected{{end}}>warn</option>
+ <option value="error"{{if eq .LogLevel "error"}} selected{{end}}>error</option>
+ </select>
+ </label>
+ <label>{{.LogCopy.Search}}
+ <input type="search" name="text" value="{{.LogGrep}}">
+ </label>
+ {{if .LogLegacyGrep}}<input type="hidden" name="grep" value="{{.LogLegacyGrep}}">{{end}}
<button type="submit">Update</button>
+ <button type="button" id="log-follow">{{.LogCopy.Follow}}</button>
+ <button type="button" id="log-pause" hidden>{{.LogCopy.Pause}}</button>
</form>
-{{if .LogRows}}
{{if .LogTruncated}}<p class="small">Showing the newest {{len .LogRows}} lines of this window — narrow the window, or use <code>carlos logs</code> for more.</p>{{end}}
-<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0">
+<div class="term" id="logterm" role="log" aria-label="Log output" tabindex="0" aria-live="polite">
{{- range logLines .LogRows -}}
<div class="tl{{if .Tone}} tl-{{.Tone}}{{end}}"><span class="tl-t">{{.Time}}</span> <span class="tl-s">{{.Stream}}</span>{{if .Host}} <span class="tl-h">{{.Host}}</span>{{end}} <span class="tl-m">{{.Msg}}</span>{{if .Fields}}<span class="tl-f">{{.Fields}}</span>{{end}}</div>
{{- end -}}
+{{if and (not .LogRows) (not .LogsErr)}}<div id="log-empty">No log lines in this window. <a href="{{.LogWidenURL}}">{{.LogCopy.Widen}}</a></div>{{end}}
</div>
+<p class="small" id="log-status" aria-live="polite"></p>
+{{if .LogRows}}
<p class="small">Times are UTC. Follow live with <code>carlos logs --app {{.AppName}} -f</code>.</p>
-<script>
-// Enhancement only: the pane is a scrolled region of plain lines, oldest
-// first. A terminal opens at its foot, so this puts the newest line in view
-// on load. With JS off the pane simply opens at the top.
-(function () {
- var t = document.getElementById("logterm");
- if (t) t.scrollTop = t.scrollHeight;
-})();
-</script>
{{else if not .LogsErr}}
-<p>No log lines in this window.</p>
<p class="small">An app's output appears here shortly after it runs; platform events
(wakes, restarts, failures) appear even when the app itself printed nothing. A
site served from files alone has no app output.</p>
{{end}}
+<script>
+(function () {
+ var form = document.getElementById("logs-filter");
+ var term = document.getElementById("logterm");
+ var follow = document.getElementById("log-follow");
+ var pause = document.getElementById("log-pause");
+ var status = document.getElementById("log-status");
+ if (!form || !term || !follow || !pause || !status) return;
+ var cursor = form.dataset.cursor || "";
+ var timer = 0;
+ var request = null;
+ var fetchTimeout = 0;
+ var timedOut = false;
+ var following = false;
+ var generation = 0;
+ var dirty = false;
+ var activeQuery = new URLSearchParams(new FormData(form));
+
+ function setFollowing(on) {
+ var moveFocus = document.activeElement === (on ? follow : pause);
+ following = on;
+ follow.hidden = on;
+ pause.hidden = !on;
+ var q = new URLSearchParams(activeQuery);
+ if (on) q.set("follow", "1"); else q.delete("follow");
+ history.replaceState(null, "", location.pathname + "?" + q.toString());
+ if (moveFocus) (on ? pause : follow).focus();
+ }
+ function addSpan(row, cls, value, prefix) {
+ if (!value) return;
+ if (prefix) row.appendChild(document.createTextNode(prefix));
+ var span = document.createElement("span");
+ span.className = cls;
+ span.textContent = value;
+ row.appendChild(span);
+ }
+ function append(line) {
+ var empty = document.getElementById("log-empty");
+ if (empty) empty.remove();
+ var row = document.createElement("div");
+ row.className = "tl" + (line.tone ? " tl-" + line.tone : "");
+ addSpan(row, "tl-t", line.time, "");
+ addSpan(row, "tl-s", line.stream, " ");
+ addSpan(row, "tl-h", line.host, " ");
+ addSpan(row, "tl-m", line.msg, " ");
+ addSpan(row, "tl-f", line.fields, "");
+ term.appendChild(row);
+ while (term.querySelectorAll(".tl").length > 200) term.querySelector(".tl").remove();
+ term.scrollTop = term.scrollHeight;
+ }
+ function clearLines() {
+ term.querySelectorAll(".tl").forEach(function (row) { row.remove(); });
+ }
+ function stop() {
+ generation++;
+ following = false;
+ if (timer) window.clearTimeout(timer);
+ timer = 0;
+ if (request) request.abort();
+ request = null;
+ if (fetchTimeout) window.clearTimeout(fetchTimeout);
+ fetchTimeout = 0;
+ timedOut = false;
+ term.setAttribute("aria-busy", "false");
+ setFollowing(false);
+ }
+ function poll() {
+ if (!following) return;
+ var thisGeneration = generation;
+ var q = new URLSearchParams(activeQuery);
+ var hadCursor = Boolean(cursor);
+ q.set("limit", "200");
+ if (cursor) q.set("cursor", cursor);
+ request = new AbortController();
+ timedOut = false;
+ var thisRequest = request;
+ fetchTimeout = window.setTimeout(function () {
+ if (following && thisGeneration === generation && request === thisRequest) {
+ timedOut = true;
+ thisRequest.abort();
+ }
+ }, 20000);
+ term.setAttribute("aria-busy", "true");
+ fetch(form.dataset.url + "?" + q.toString(), {signal: request.signal, headers: {"Accept": "application/json"}})
+ .then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
+ .then(function (data) {
+ if (!following || thisGeneration !== generation) return;
+ window.clearTimeout(fetchTimeout);
+ fetchTimeout = 0;
+ if (data.gap) {
+ cursor = "";
+ stop();
+ status.textContent = form.dataset.followError;
+ return;
+ }
+ if (!hadCursor) clearLines();
+ data.lines.forEach(append);
+ cursor = data.cursor || cursor;
+ status.textContent = form.dataset.refreshed + " " + new Date().toLocaleTimeString();
+ term.setAttribute("aria-busy", "false");
+ timer = window.setTimeout(poll, 2000);
+ })
+ .catch(function (err) {
+ window.clearTimeout(fetchTimeout);
+ fetchTimeout = 0;
+ if (err.name === "AbortError" && !timedOut) return;
+ if (!following || thisGeneration !== generation) return;
+ stop();
+ status.textContent = form.dataset.followError;
+ });
+ }
+ form.addEventListener("input", function () { dirty = true; if (following) stop(); });
+ form.addEventListener("change", function () { dirty = true; if (following) stop(); });
+ follow.addEventListener("click", function () {
+ if (dirty) {
+ var q = new URLSearchParams(new FormData(form));
+ q.set("follow", "1");
+ location.assign(location.pathname + "?" + q.toString());
+ return;
+ }
+ setFollowing(true);
+ poll();
+ });
+ pause.addEventListener("click", stop);
+ term.scrollTop = term.scrollHeight;
+ {{if .LogFollow}}setFollowing(true); poll();{{end}}
+})();
+</script>
{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/app_overview.html b/internal/console/templates/app_overview.html
index d51094d21..d595fec6d 100644
--- a/internal/console/templates/app_overview.html
+++ b/internal/console/templates/app_overview.html
@@ -2,28 +2,7 @@
{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
{{template "appnav" .}}
-{{if .Detail.AccountMismatch}}
-{{if .Detail.AllMismatched}}
-<p class="banner error">
- <strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from another account, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- Finish the move on this app's
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
- <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>
-{{else}}
-<p class="banner">
- {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
- read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
- follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- The rest deploy normally. Fix on
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
-</p>
-{{end}}
-{{end}}
+{{template "accountreviewbanner" .}}
{{with .Overview}}
<section class="pagehead" aria-label="App status">
<p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p>
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html
index c769d6994..2f093403a 100644
--- a/internal/console/templates/app_releases.html
+++ b/internal/console/templates/app_releases.html
@@ -7,28 +7,7 @@
<a href="{{.Nav}}/apps/{{.AppName}}/releases" aria-current="page">{{.DeploymentsCopy.History}}</a>
</nav>
<h2>{{.DeploymentsCopy.History}}</h2>
-{{if .Detail.AccountMismatch}}
-{{if .Detail.AllMismatched}}
-<p class="banner error">
- <strong>This app is not deploying.</strong> Every one of its addresses reads
- releases from another account, but this page writes them to
- <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
- nothing. Affected: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- Finish the move on this app's
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a> page.
- <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>
-{{else}}
-<p class="banner">
- {{len .Detail.Mismatched}} address{{if gt (len .Detail.Mismatched) 1}}es{{end}} on this app
- read{{if eq (len .Detail.Mismatched) 1}}s{{end}} releases from another account and won't
- follow promotes made here: {{range $i, $m := .Detail.Mismatched}}{{if $i}}, {{end}}<code>{{$m.Host}}</code> (reads <code>{{$m.Account}}</code>){{end}}.
- The rest deploy normally. Fix on
- <a href="{{.Nav}}/apps/{{.AppName}}/settings">Settings</a>.
-</p>
-{{end}}
-{{end}}
+{{template "accountreviewbanner" .}}
{{with .ReleaseSummary}}
<section class="pagehead" aria-label="Release status">
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 1e69e02b0..0845c1e13 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -140,6 +140,9 @@ console does not know{{else}}You are not a member of that account{{end}} —
the operator path is <code>console migrate-app --app {{.Detail.App}} --to
{{.Settings.MismatchTarget}}</code> on the console's box.</p>
{{end}}
+{{else if .AccountReview.NoInstances}}
+<h2>Account</h2>
+<p>{{.MismatchCopy.NoInstances}} <a href="{{.Nav}}/apps/{{.Detail.App}}/instances">Instances</a></p>
{{else}}{{if .IsOwner}}
<h2>Account</h2>
<p class="small">This app deploys from {{.Account.Name}}. Moving an app to a
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html
index 35eba307f..2c8be0b8b 100644
--- a/internal/console/templates/layout.html
+++ b/internal/console/templates/layout.html
@@ -21,6 +21,7 @@
--rule: #2c2836; --bad: #e07b76; --warn: #d9a94a; --good: #5fae7f;
}
}
+ [hidden] { display: none !important; }
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg); color: var(--ink);
@@ -166,10 +167,14 @@
the console's own output. The pane is a scrolled region in BOTH axes —
sideways because lines do not wrap, and down because 200 lines must not
turn the page itself into the scrollbar. */
+ #logs-filter { display: flex; flex-wrap: wrap; align-items: end; gap: 0.65rem; margin-bottom: 1rem; }
+ #logs-filter label { display: flex; flex-direction: column; gap: 0.25rem; max-width: 100%; }
+ #logs-filter select, #logs-filter input { max-width: 100%; margin: 0; }
+ #logs-filter select[name="host"] { max-width: 28rem; }
.term {
background: var(--accent-soft); border-radius: 0.5rem;
padding: 0.8rem 0 0.8rem 1rem; margin: 0 0 1rem;
- max-height: 70vh; overflow: auto;
+ min-height: 16rem; max-height: 70vh; overflow: auto;
font-family: var(--mono); font-size: 0.8rem; line-height: 1.55;
}
/* white-space:pre belongs to the LINE, not the pane: on the pane it would
diff --git a/internal/console/view.go b/internal/console/view.go
index ec65561ed..d96b7e768 100644
--- a/internal/console/view.go
+++ b/internal/console/view.go
@@ -106,19 +106,11 @@ func (v AppView) RollbackChannels() []ChannelView {
return out
}
-// MismatchedRoute is one route whose stamped account disagrees with the
-// account whose page is being viewed: host plus where it actually reads
-// releases from. The banner names these — a warning that says WHICH routes
-// is a diagnostic; one that says "this app" is an alarm, and on tito
-// (2026-08-10) it was a false one.
+// MismatchedRoute is a viewer-safe account-review candidate assembled by the
+// server after authorization. Assemble itself exposes owned routes only.
type MismatchedRoute struct{ Host, Account string }
-// AccountMismatch reports the half-migrated state that would otherwise be
-// silent: `carlos accounts migrate` re-stamped this app's routes onto another
-// account, and `console migrate-app` was never run, so the console promotes
-// into one account's bucket keys while every host reads another's. A promote
-// then flashes "Promoted" and deploys nothing, forever, with no error
-// anywhere. The page has to say so.
+// AccountMismatch reports whether the server found account-review candidates.
func (v AppView) AccountMismatch() bool { return len(v.Mismatched) > 0 }
// DomainView is one row of the app page's Domains table — the product
@@ -539,58 +531,22 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
if err != nil {
return nil, err
}
- // byApp is keyed on app NAME with no account filter, so two accounts that
- // happen to name an app the same way share these buckets — pre-existing
- // (Host/Version/Channel/Status already cross that boundary, which is why
- // the RouteAccount mismatch machinery below exists) and gated: the app
- // page runs session → membership → HasAppIn → byte-identical 404 before
- // reaching here, so this is not a non-member leak. Recorded because the
- // blast radius grew rather than the gate weakening (2026-08-02 review,
- // MINOR 8): the Environment chip now shows one tenant's bundle NAMES to
- // the other, and prodWiredCanaryHosts' stable-environment set reads
- // across the same boundary, so one tenant's stable route can make
- // another's canary badge prod-wired. Capabilities joined that list
- // (2026-08-03 review, I4): Caps is the ladder's "granted: host-role,
- // port:25" line, which says which privileged ports and which box
- // authority an operator has handed one tenant's route — the most
- // sensitive thing this bleed has carried so far. The fix is at this
- // source — filter by release.RouteAccount(rt) == acct — not further
- // downstream.
- //
- // That whole-view filter stays a separate change, because it also decides
- // what the half-migrated AccountMismatch banner reads from. Until then,
- // Caps ALONE is scoped here, at the one place a RouteView is built: a
- // route belonging to another account arrives with Caps blank, so the
- // ladder renders no granted line for it (ladder.go copies this field
- // verbatim, and `{{if .Caps}}` in ladder.html renders nothing for ""),
- // while every other cross-account field keeps behaving exactly as it did.
- // Scoping it HERE rather than in handleApp is not a style choice: Ladder
- // is assembled inside this function, so a post-Assemble blank in the
- // handler would leave every LadderRoute's copy untouched — which is the
- // only place Caps renders at all.
+ // Routes are scoped here, before any downstream view is assembled. App names
+ // are account-local, so grouping by name first would mix legitimate sibling
+ // apps and leak their host, environment, capability and health details into
+ // one another's pages. Account-review candidates are derived separately by
+ // the server, after it knows the viewer's memberships.
byApp := map[string][]RouteView{}
domainsByApp := map[string][]DomainView{}
- // What the ROUTES say each app's account is, at route granularity. A
- // disagreement with the account being viewed is the half-migrated state
- // (see AccountMismatch); platform-minted routes are excluded because
- // they are aliases that reconciliation re-stamps on its own schedule, so
- // a lagging one is noise rather than a stuck deploy. routableByApp is
- // the denominator AllMismatched needs: every non-platform route counted,
- // so "all of them mismatch" can be told apart from "some of them".
- mismatchedByApp := map[string][]MismatchedRoute{}
- routableByApp := map[string]int{}
for _, rt := range routes {
+ rtAcct := release.RouteAccount(rt)
+ if rtAcct != acct {
+ continue
+ }
ch := rt.Channel
if ch == "" {
ch = "stable" // registry contract: empty channel means stable
}
- if rt.App != "" && !rt.Platform {
- routableByApp[rt.App]++
- if a := release.RouteAccount(rt); a != acct {
- mismatchedByApp[rt.App] = append(mismatchedByApp[rt.App], MismatchedRoute{Host: rt.Host, Account: a})
- }
- }
- rtAcct := release.RouteAccount(rt)
// The scoped guard described above: grants render only on rows the
// viewing account owns. Fails closed — a route whose stamped account
// disagrees with the account being viewed (the half-migrated state)
@@ -625,11 +581,6 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
}
seen[app] = true
channels := assembleChannels(ctx, s, pub, acct, app, now)
- mm := mismatchedByApp[app]
- mismatch := ""
- if len(mm) > 0 {
- mismatch = mm[0].Account
- }
pv := assembleProduction(ctx, s, pub, acct, app, channels, byApp[app], now)
if pv.Err == "" && !pv.On {
// No production flag (or off): nothing bakes, so no estimate. An
@@ -654,11 +605,8 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin
// AppView.Pipeline's doc comment — so appPage (server.go) is
// where a single app's view gets pipeline-aware, and rebuilds
// Ladder from the corrected Channels once it has one.
- Ladder: assembleLadder(channels, byApp[app], nil, now),
- Production: pv,
- RouteAccount: mismatch,
- Mismatched: mm,
- AllMismatched: len(mm) > 0 && len(mm) == routableByApp[app],
+ Ladder: assembleLadder(channels, byApp[app], nil, now),
+ Production: pv,
})
}
return out, nil
diff --git a/internal/console/view_test.go b/internal/console/view_test.go
index efad811c0..3465c9795 100644
--- a/internal/console/view_test.go
+++ b/internal/console/view_test.go
@@ -344,17 +344,14 @@ func TestMismatchListsRoutes(t *testing.T) {
t.Fatal(err)
}
v := views[0]
- if len(v.Mismatched) != 1 || v.Mismatched[0].Host != "one.mismatchlist.test" || v.Mismatched[0].Account != "otheracct" {
- t.Fatalf("Mismatched = %+v, want exactly [{one.mismatchlist.test otheracct}]", v.Mismatched)
+ if len(v.Routes) != 2 {
+ t.Fatalf("Routes = %+v, want only the viewed account's two routes", v.Routes)
}
if v.AllMismatched {
t.Fatalf("AllMismatched = true, want false (2 of 3 routes agree)")
}
- if !v.AccountMismatch() {
- t.Fatal("AccountMismatch() = false, want true")
- }
- if v.RouteAccount != "otheracct" {
- t.Fatalf("RouteAccount = %q, want otheracct (first mismatched account)", v.RouteAccount)
+ if v.AccountMismatch() || v.RouteAccount != "" {
+ t.Fatalf("Assemble leaked cross-account diagnostics: %+v", v)
}
}
@@ -384,11 +381,8 @@ func TestMismatchAllRoutes(t *testing.T) {
t.Fatal(err)
}
v := views[0]
- if len(v.Mismatched) != 3 {
- t.Fatalf("Mismatched = %+v, want all 3 routes", v.Mismatched)
- }
- if !v.AllMismatched {
- t.Fatal("AllMismatched = false, want true (every route disagrees)")
+ if len(v.Routes) != 0 || v.AccountMismatch() || v.AllMismatched {
+ t.Fatalf("foreign routes leaked into view: %+v", v)
}
}