console: activity tab — the audit trail gets a page and a 50-row cap
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+50
−0
internal/console/server.go+18 −0internal/console/server_test.go+15 −0internal/console/templates/app_activity.html+17 −0
diff --git a/internal/console/server.go b/internal/console/server.go| index a20de8739..7e6e75d86 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -262,6 +262,7 @@ 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}/instances", s.handleAppInstances) |
| + s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| @@ -1380,9 +1381,26 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| s.render(w, http.StatusOK, "app_instances", p) |
| } |
| +// 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") |
| + if !ok { |
| + return |
| + } |
| + if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil { |
| + log.Printf("console: events %s: %v", p.AppName, err) |
| + } else { |
| + p.Events = events |
| + } |
| + s.render(w, http.StatusOK, "app_activity", 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 |
| + |
| // audit best-effort-records an owner action: a failed audit line is logged, |
| // never surfaced, and never fails the action it records. |
| func (s *Server) audit(acct *Account, app, addr, action, detail string) { |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 3a05a1476..6c76a994c 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -1063,6 +1063,21 @@ func TestRecentActivity(t *testing.T) { |
| } |
| } |
| +func TestActivityTab(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, body := get(t, c, fx.u("/apps/kass/activity")) |
| + if status != 200 { |
| + t.Fatalf("GET activity tab = %d", status) |
| + } |
| + for _, want := range []string{`aria-current="page">Activity</a>`, "CLI promotions don"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("activity tab missing %q", want) |
| + } |
| + } |
| + assertNonMember404(t, fx, "/apps/kass/activity") |
| +} |
| + |
| func TestPromotionsDisabledWithoutKey(t *testing.T) { |
| fx := newConsoleFixtureKeyed(t, false) |
| c := signInPaul(t, fx) |
diff --git a/internal/console/templates/app_activity.html b/internal/console/templates/app_activity.html| new file mode 100644 |
| index 000000000..814d9e917 |
| --- /dev/null |
| +++ b/internal/console/templates/app_activity.html |
| @@ -0,0 +1,17 @@ |
| +{{define "app_activity"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{template "appnav" .}} |
| + |
| +{{if .Events}} |
| +<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> |
| +{{else}}<p class="small">Nothing yet — promotes, rollbacks and canaries made in this console will show here.</p>{{end}} |
| +<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p> |
| +{{template "bottom" .}}{{end}} |