Merge branch 'console-ux-10-actions' into main
8 files changed,
+277
−15
internal/console/apicli.go+1 −1internal/console/browserrestart.go+144 −0internal/console/browserrestart_test.go+88 −0internal/console/overview.go+11 −8internal/console/server.go+22 −6internal/console/templates/app_instance.html+5 −0internal/console/templates/app_instances.html+4 −0internal/console/view.go+2 −0
CI failed — run details
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index f57ae5bc2..fb43a2099 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -1125,7 +1125,7 @@ func (s *Server) handleAPIRestart(w http.ResponseWriter, r *http.Request) { |
| } |
| ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| defer cancel() |
| - if err := release.TouchRestartGen(ctx, s.cfg.Releases, acct.Sqid, app); err != nil { |
| + if err := s.requestAppRestart(ctx, acct.Sqid, app); err != nil { |
| // Logged, not returned: a store error carries bucket names, endpoints |
| // and request ids. The caller gets the fixed sentence every other |
| // bucket failure here gets, and a retry is safe — the touch is |
diff --git a/internal/console/browserrestart.go b/internal/console/browserrestart.go| new file mode 100644 |
| index 000000000..1429f13f6 |
| --- /dev/null |
| +++ b/internal/console/browserrestart.go |
| @@ -0,0 +1,144 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "crypto/subtle" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +type BrowserRestartCopy struct { |
| + AppTitle, AppLine, HostTitle, HostLine string |
| + Button, Success, Audit string |
| + RateLimited, Failed string |
| +} |
| + |
| +var approvedBrowserRestartCopy = BrowserRestartCopy{ |
| + AppTitle: "Restart all app instances?", |
| + AppLine: "Request a restart for this app's running instances. Sleeping instances stay asleep.", |
| + HostTitle: "Restart {host}?", |
| + HostLine: "Only {host} receives a restart request. If it is asleep, it remains asleep.", |
| + Button: "Restart", |
| + Success: "Restart requested for {target}.", |
| + Audit: "Restart requested for {target}.", |
| + RateLimited: "Too many restart requests. Try again later.", |
| + Failed: "The restart request failed. Try again.", |
| +} |
| + |
| +func (s *Server) requestAppRestart(ctx context.Context, acct, app string) error { |
| + return release.TouchRestartGen(ctx, s.cfg.Releases, acct, app) |
| +} |
| + |
| +func (s *Server) handleAppRestart(w http.ResponseWriter, r *http.Request) { |
| + s.handleBrowserRestart(w, r, "", false) |
| +} |
| + |
| +func (s *Server) handleInstanceRestart(w http.ResponseWriter, r *http.Request) { |
| + s.handleBrowserRestart(w, r, strings.TrimSpace(r.PathValue("host")), true) |
| +} |
| + |
| +func (s *Server) handleBrowserRestart(w http.ResponseWriter, r *http.Request, host string, scoped bool) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + app := r.PathValue("app") |
| + has, err := s.store.HasAppIn(acct.ID, app) |
| + if err != nil { |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.") |
| + return |
| + } |
| + if !has { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if scoped && host == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var scope release.RestartScope |
| + if host != "" { |
| + if s.cfg.Registry == "" { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: browser restart registry: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: browser restart routes: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + found := false |
| + for _, rt := range routes { |
| + if rt.Host == host && rt.App == app && release.RouteAccount(rt) == acct.Sqid && release.Adoptable(rt) { |
| + found = true |
| + break |
| + } |
| + } |
| + if !found { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + scope = release.RestartScope{Kind: release.RestartScopeHost, Value: host} |
| + } |
| + words := approvedBrowserRestartCopy |
| + if !confirmed(r) { |
| + cv := &ConfirmView{Action: r.URL.Path, Fields: [][2]string{{"csrf", raw}}, CancelTab: "instances"} |
| + if host == "" { |
| + cv.Title, cv.Lines, cv.Button = words.AppTitle, []string{words.AppLine}, words.Button |
| + } else { |
| + cv.Title = strings.ReplaceAll(words.HostTitle, "{host}", host) |
| + cv.Lines, cv.Button = []string{strings.ReplaceAll(words.HostLine, "{host}", host)}, words.Button |
| + } |
| + s.renderConfirm(w, r, addr, raw, acct, role, app, cv) |
| + return |
| + } |
| + if !s.allowWrite(Principal{Kind: PrincipalUser, Address: addr}, time.Now()) { |
| + s.setFlash(w, "err", words.RateLimited) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther) |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + s.setFlash(w, "err", words.Failed) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + var restartErr error |
| + if host == "" { |
| + restartErr = s.requestAppRestart(ctx, acct.Sqid, app) |
| + } else { |
| + restartErr = s.writeRestartScope(ctx, acct.Sqid, app, scope) |
| + } |
| + if restartErr != nil { |
| + log.Printf("console: browser restart %s/%s/%s: %v", acct.Sqid, app, host, restartErr) |
| + s.setFlash(w, "err", words.Failed) |
| + } else { |
| + target := app |
| + if host != "" { |
| + target = host |
| + } |
| + detail := strings.ReplaceAll(words.Audit, "{target}", target) |
| + success := strings.ReplaceAll(words.Success, "{target}", target) |
| + s.audit(acct, app, addr, "restart", detail) |
| + s.bumpSerial(ctx) |
| + s.setFlash(w, "ok", success) |
| + } |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/instances", http.StatusSeeOther) |
| +} |
diff --git a/internal/console/browserrestart_test.go b/internal/console/browserrestart_test.go| new file mode 100644 |
| index 000000000..3d506a76c |
| --- /dev/null |
| +++ b/internal/console/browserrestart_test.go |
| @@ -0,0 +1,88 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "net/url" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +func TestBrowserAppRestartUsesSharedGeneration(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {csrf}}) |
| + if status != http.StatusOK { |
| + t.Fatalf("confirm = %d", status) |
| + } |
| + if restartGenEtag(t, fx, "kass") != "" || len(scopeLog(t, fx, "kass").Requests) != 0 { |
| + t.Fatal("confirmation page wrote restart state") |
| + } |
| + status, loc, _ := postGated(t, fx, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/kass/instances") { |
| + t.Fatalf("restart = %d -> %q", status, loc) |
| + } |
| + if restartGenEtag(t, fx, "kass") == "" { |
| + t.Fatal("browser restart did not touch app generation") |
| + } |
| + if got := serial(t, fx); got != 1 { |
| + t.Fatalf("serial = %d", got) |
| + } |
| +} |
| + |
| +func TestBrowserInstanceRestartIsExactAndNeverWidens(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "sleep.kass.test", App: "kass", Account: "bab", Kind: "instance", Status: "ready", Hibernate: true}) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + endpoint := fx.u("/apps/kass/instances/sleep.kass.test/restart") |
| + status, _, _ := postGated(t, fx, c, endpoint, url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("restart = %d", status) |
| + } |
| + if restartGenEtag(t, fx, "kass") != "" { |
| + t.Fatal("instance restart widened into app generation") |
| + } |
| + log := scopeLog(t, fx, "kass") |
| + if len(log.Requests) != 1 || log.Requests[0].Scope.Kind != release.RestartScopeHost || log.Requests[0].Scope.Value != "sleep.kass.test" { |
| + t.Fatalf("scope log = %+v", log) |
| + } |
| + reg, _ := registry.Open(fx.regPath) |
| + routes, _ := reg.List() |
| + for _, rt := range routes { |
| + if rt.Host == "sleep.kass.test" && !rt.Hibernate { |
| + t.Fatal("restart request woke sleeping route") |
| + } |
| + } |
| +} |
| + |
| +func TestBrowserInstanceRestartRefusesUnaddressableHosts(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.addRoute(t, registry.Route{Host: "foreign.test", App: "kass", Account: "elsewhere", Kind: "instance", Status: "ready"}) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + for _, host := range []string{"missing.test", "foreign.test", "docs.kass.carlosapp.dev", "%20", "%09", "%20%20"} { |
| + status, _, body := postGated(t, fx, c, fx.u("/apps/kass/instances/"+host+"/restart"), url.Values{"csrf": {csrf}, "confirmed": {"1"}}) |
| + if status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Errorf("%s = %d %q", host, status, body) |
| + } |
| + } |
| + if restartGenEtag(t, fx, "kass") != "" || len(scopeLog(t, fx, "kass").Requests) != 0 { |
| + t.Fatal("refusal wrote restart state") |
| + } |
| +} |
| + |
| +func TestBrowserRestartRequiresCSRF(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/restart"), url.Values{"csrf": {"wrong"}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("csrf = %d", status) |
| + } |
| + status, _, _ = postForm(t, c, fx.u("/apps/no-such-app/restart"), url.Values{"csrf": {"wrong"}}) |
| + if status != http.StatusForbidden { |
| + t.Fatalf("csrf before app membership = %d", status) |
| + } |
| +} |
diff --git a/internal/console/overview.go b/internal/console/overview.go| index 5a635f281..7cacc9c11 100644 |
| --- a/internal/console/overview.go |
| +++ b/internal/console/overview.go |
| @@ -227,7 +227,9 @@ type InstanceRow struct { |
| // HealthWord is the Status enum + hibernate + reporting folded into one |
| // plain word — the column's whole job. |
| - HealthWord string |
| + HealthWord string |
| + Restartable bool |
| + RestartURL string |
| } |
| // assembleInstanceRows joins the app's routes with the sweep's readings, in |
| @@ -249,13 +251,14 @@ func assembleInstanceRows(view *AppView, acct *Account, metrics []InstanceMetric |
| status = "ready" // registry contract: empty status means ready |
| } |
| rows = append(rows, InstanceRow{ |
| - Host: rt.Host, |
| - Channel: rt.Channel, |
| - Version: rt.Version, |
| - Kind: rt.Kind, |
| - Status: status, |
| - Hibernate: rt.Hibernate, |
| - Metrics: byHost[rt.Host], |
| + Host: rt.Host, |
| + Channel: rt.Channel, |
| + Version: rt.Version, |
| + Kind: rt.Kind, |
| + Status: status, |
| + Hibernate: rt.Hibernate, |
| + Metrics: byHost[rt.Host], |
| + Restartable: rt.Restartable, |
| HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting(), |
| expectsReadings(rt.Provisioned, rt.Unit, rt.Backing)), |
| }) |
diff --git a/internal/console/server.go b/internal/console/server.go| index 0f8dcbd5a..ece9fcf7b 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1117,6 +1117,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| 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("POST /a/{sqid}/apps/{app}/restart", s.handleAppRestart) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/instances/{host}/restart", s.handleInstanceRestart) |
| 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 |
| @@ -1308,11 +1310,12 @@ type page struct { |
| InstanceListURL string |
| DeploymentTargets []deploymentTarget |
| - Title string |
| - Address string // signed-in address, "" when signed out |
| - CSRF string // the session token, double-submitted on the signout POST |
| - Error string |
| - Welcome bool // one-shot "account created" banner |
| + Title string |
| + Address string // signed-in address, "" when signed out |
| + CSRF string // the session token, double-submitted on the signout POST |
| + Error string |
| + Welcome bool // one-shot "account created" banner |
| + BrowserRestartCopy BrowserRestartCopy |
| AccountReview AccountReview |
| MismatchCopy MismatchCopy |
| @@ -1460,7 +1463,8 @@ type page struct { |
| Flow FlowView |
| // App Instances: the one table — routes joined with their readings. |
| - InstanceRows []InstanceRow |
| + InstanceRows []InstanceRow |
| + CanRestartApp bool |
| // App Instances: the search/health-filter/cursor controls (Task 8) — |
| // InstancePage is the computed page (rows, counts, links, pager), |
| @@ -1630,6 +1634,7 @@ type appRow struct { |
| } |
| func (s *Server) render(w http.ResponseWriter, status int, name string, p page) { |
| + p.BrowserRestartCopy = approvedBrowserRestartCopy |
| p.MismatchCopy = approvedMismatchCopy |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.WriteHeader(status) |
| @@ -2868,8 +2873,19 @@ func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) { |
| p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct) |
| q := parseInstanceQuery(r.URL.Query()) |
| rows := assembleInstanceRows(p.Detail, acct, p.AppMetrics) |
| + for _, row := range rows { |
| + if row.Restartable { |
| + p.CanRestartApp = true |
| + break |
| + } |
| + } |
| pg := pageInstanceRows(rows, q, instancePageSize) |
| p.InstanceRows = pg.Rows |
| + for i := range p.InstanceRows { |
| + if p.InstanceRows[i].Restartable { |
| + p.InstanceRows[i].RestartURL = p.Base + "/apps/" + url.PathEscape(p.AppName) + "/instances/" + url.PathEscape(p.InstanceRows[i].Host) + "/restart" |
| + } |
| + } |
| p.InstancePage = pg |
| p.InstanceQuery = q |
| p.InstanceChannels = instanceChannels(rows) |
diff --git a/internal/console/templates/app_instance.html b/internal/console/templates/app_instance.html| index 11d699189..6fa8d7e0c 100644 |
| --- a/internal/console/templates/app_instance.html |
| +++ b/internal/console/templates/app_instance.html |
| @@ -5,6 +5,11 @@ |
| <p><a href="{{.InstanceListURL}}">Instances</a></p> |
| {{with .InstanceDetail}} |
| <h2 class="instance-address">{{.Row.Host}} <a class="instance-open" href="https://{{.Row.Host}}" aria-label="{{$.InstanceCopy.Open}} {{.Row.Host}}">{{icon "external-link"}}</a></h2> |
| +<p><a href="{{$.Nav}}/apps/{{$.AppName}}/logs?host={{.Row.Host}}">Logs</a></p> |
| +{{if .Row.Restartable}}<form method="post" action="{{$.Base}}/apps/{{$.AppName}}/instances/{{.Row.Host}}/restart"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <button type="submit" class="btn-sm">{{$.BrowserRestartCopy.Button}}</button> |
| +</form>{{end}} |
| <dl class="instance-facts"> |
| <dt>Health</dt><dd>{{.Row.HealthWord}}</dd> |
| <dt>{{$.InstanceCopy.LastReading}}</dt><dd>{{if .Row.Metrics.ObservedAt.IsZero}}—{{else}}<time title="{{fmtTime .Row.Metrics.ObservedAt}}">{{ago .Row.Metrics.ObservedAt}}</time>{{end}}</dd> |
diff --git a/internal/console/templates/app_instances.html b/internal/console/templates/app_instances.html| index f212f47d7..184e7e952 100644 |
| --- a/internal/console/templates/app_instances.html |
| +++ b/internal/console/templates/app_instances.html |
| @@ -4,6 +4,10 @@ |
| {{template "appnav" .}} |
| <h2>Instances</h2> |
| +{{if .CanRestartApp}}<form method="post" action="{{.Base}}/apps/{{.AppName}}/restart" class="inline"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <button type="submit" class="btn-sm">{{.BrowserRestartCopy.Button}}</button> |
| +</form>{{end}} |
| {{if or .InstanceRows .InstanceQuery.Q .InstanceQuery.Health .InstanceQuery.Channel}} |
| <form method="get" class="listctl"> |
| <input type="search" name="q" value="{{.InstanceQuery.Q}}" placeholder="Filter addresses" aria-label="Filter addresses"> |
diff --git a/internal/console/view.go b/internal/console/view.go| index d96b7e768..12200bf19 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -318,6 +318,7 @@ type CanaryView struct { |
| type RouteView struct { |
| Host, Kind, Status, Version, Channel string |
| Hibernate bool |
| + Restartable bool |
| // Account is what the ROUTE says its account is (release.RouteAccount: |
| // the stamped account, or the ops account when unstamped). Routes are |
| // grouped by app NAME here, and app names are unique per account rather |
| @@ -567,6 +568,7 @@ func Assemble(regPath string, s release.Store, pub ed25519.PublicKey, acct strin |
| Environment: rt.Environment, |
| Caps: capsFor, |
| Unit: rt.Unit, Backing: rt.Backing, Provisioned: rt.Provisioned, |
| + Restartable: release.Adoptable(rt), |
| }) |
| domainsByApp[rt.App] = append(domainsByApp[rt.App], domainView(rt, dns)) |
| } |