console: rate limit fields on the instance settings page (#358)
A Rate limit section beside the password gate: four number inputs and Save per instance, Reset to defaults, path rules listed read-only. The writer checks through checkRateLimit like the PATCH, keeps existing path rules on a set, and audits as the gate does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+409
−1
internal/console/appsettings.go+158 −1internal/console/appsettings_ratelimit_test.go+197 −0internal/console/claim_invariant_fixtures_test.go+9 −0internal/console/server.go+2 −0internal/console/templates/app_settings.html+42 −0internal/console/testdata/claim-routes.txt+1 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 3a0f37384..05f5a6ed6 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -36,6 +36,7 @@ import ( |
| "net/url" |
| "os" |
| "sort" |
| + "strconv" |
| "strings" |
| "time" |
| @@ -45,6 +46,7 @@ import ( |
| "github.com/carlosframework/platform/internal/mail" |
| "github.com/carlosframework/platform/internal/objstore" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -180,6 +182,28 @@ type SettingsView struct { |
| type GateRow struct { |
| Host string |
| Gated bool |
| + |
| + // The rate-limit section's view of the same record. The four numbers |
| + // are the owner's own settings as text, "" when unset, so an input's |
| + // value is empty and its placeholder shows the default. Paths are the |
| + // record's path rules, read-only here: the CLI edits them. |
| + BusyAt, Share, Rate, Burst string |
| + Paths []RateLimitPathRow |
| +} |
| + |
| +// RateLimitPathRow is one path rule on the rate-limit section: its prefix |
| +// and the numbers it sets, in share, rate, burst order, unset ones left out. |
| +type RateLimitPathRow struct { |
| + Prefix string |
| + Numbers string |
| +} |
| + |
| +// rateLimitField is a setting as an input's value: "" for unset. |
| +func rateLimitField(n int) string { |
| + if n == 0 { |
| + return "" |
| + } |
| + return strconv.Itoa(n) |
| } |
| // StoreCard is the Settings tab's view of apps/<acct>/<app>/store.json (issue |
| @@ -395,7 +419,21 @@ func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ( |
| } |
| rows := make([]GateRow, 0, len(recs)) |
| for _, rec := range recs { |
| - rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) |
| + row := GateRow{Host: rec.Host, Gated: rec.Gate != ""} |
| + if rl := rec.RateLimit; rl != nil { |
| + row.BusyAt, row.Share = rateLimitField(rl.BusyAt), rateLimitField(rl.ClientShare) |
| + row.Rate, row.Burst = rateLimitField(rl.Rate), rateLimitField(rl.Burst) |
| + for _, p := range rl.Paths { |
| + var nums []string |
| + for _, n := range []int{p.ClientShare, p.Rate, p.Burst} { |
| + if n != 0 { |
| + nums = append(nums, strconv.Itoa(n)) |
| + } |
| + } |
| + row.Paths = append(row.Paths, RateLimitPathRow{Prefix: p.Prefix, Numbers: strings.Join(nums, " ")}) |
| + } |
| + } |
| + rows = append(rows, row) |
| } |
| sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| return rows, "" |
| @@ -1004,6 +1042,125 @@ func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Password set on "+host+".") |
| } |
| +// The rate-limit form's flash lines, approved copy (ids ui.ratelimit.saved, |
| +// .cleared and .failed in the copy review of 2026-10-24, #358), with {host} |
| +// where the instance goes. A refusal over the plan's ceiling is not here: it |
| +// is checkRateLimit's own sentence, shown as it is. |
| +const ( |
| + rateLimitSavedCopy = "Rate limit saved on {host}." |
| + rateLimitClearedCopy = "Rate limit back to defaults on {host}." |
| + rateLimitFailedCopy = "Could not save the rate limit. Try again." |
| +) |
| + |
| +// handleAppRateLimit is the Settings page's rate-limit form: set the four |
| +// numbers on one instance host, or reset them. Member-gated like the gate and |
| +// the API PATCH it mirrors, with the gate's CSRF check, read-mutate-write |
| +// sequence and audit row. The numbers are checked by checkRateLimit, the |
| +// PATCH's own check, so both doors refuse the same values in the same |
| +// words. Path rules are not on the form and survive a set untouched; reset |
| +// drops them with the rest. |
| +func (s *Server) handleAppRateLimit(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, app, ok := s.accountApp(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 |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, appSettingsURL(acct.Sqid, app, "domains"), |
| + http.StatusSeeOther) |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + host := strings.TrimSpace(r.FormValue("host")) |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var nums ratelimit.Settings |
| + clearing := false |
| + switch r.FormValue("action") { |
| + case "clear": |
| + clearing = true |
| + case "set": |
| + // An empty input is unset, which is 0: the default applies. |
| + var vals [4]int |
| + for i, name := range []string{"busy_at", "client_share", "rate", "burst"} { |
| + v := strings.TrimSpace(r.FormValue(name)) |
| + if v == "" { |
| + continue |
| + } |
| + n, err := strconv.Atoi(v) |
| + if err != nil { |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + vals[i] = n |
| + } |
| + nums = ratelimit.Settings{BusyAt: vals[0], Rule: ratelimit.Rule{ClientShare: vals[1], Rate: vals[2], Burst: vals[3]}} |
| + default: |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: rate limit read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + if clearing { |
| + rec.RateLimit = nil |
| + } else { |
| + if rec.RateLimit != nil { |
| + nums.Paths = rec.RateLimit.Paths |
| + } |
| + if err := s.checkRateLimit(acct, nums); err != nil { |
| + if isRateLimitRefusal(err) { |
| + back("err", err.Error()) |
| + return |
| + } |
| + log.Printf("console: rate limit check %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + if nums.Canonical() == "" { |
| + rec.RateLimit = nil |
| + } else { |
| + rec.RateLimit = &nums |
| + } |
| + } |
| + if err := provision.ValidateShape(*rec); err != nil { |
| + log.Printf("console: rate limit shape %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| + log.Printf("console: rate limit write %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", rateLimitFailedCopy) |
| + return |
| + } |
| + if clearing { |
| + s.audit(acct, app, addr, "rate-limit", "rate limit cleared on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", strings.ReplaceAll(rateLimitClearedCopy, "{host}", host)) |
| + return |
| + } |
| + s.audit(acct, app, addr, "rate-limit", "rate limit set on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", strings.ReplaceAll(rateLimitSavedCopy, "{host}", host)) |
| +} |
| + |
| // handleAppDelete puts the app in the trash: the deletion-policy marker in |
| // the bucket, then this console's row — the same order and the same clock |
| // discipline as the API door (handleAPITrashApp), behind the account-owner |
diff --git a/internal/console/appsettings_ratelimit_test.go b/internal/console/appsettings_ratelimit_test.go| new file mode 100644 |
| index 000000000..d6fc09bee |
| --- /dev/null |
| +++ b/internal/console/appsettings_ratelimit_test.go |
| @@ -0,0 +1,197 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/ratelimit" |
| +) |
| + |
| +// rlSettingsFixture is the gate test's fixture with one instance, a signed-up |
| +// browser and its CSRF token. |
| +func rlSettingsFixture(t *testing.T, code string) (*consoleFixture, *http.Client, string) { |
| + t.Helper() |
| + fx, token := instanceFixture(t, code) |
| + letStateUpstreams(t, fx, "alice@keymail.dev") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, |
| + `{"host":"rl.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, code+"-browser") |
| + return fx, c, sessionToken(t, fx, c) |
| +} |
| + |
| +func rlSettingsRecord(t *testing.T, fx *consoleFixture) *provision.Record { |
| + t.Helper() |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "rl.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return rec |
| +} |
| + |
| +func rlForm(csrf, action string, kv ...string) url.Values { |
| + v := url.Values{"csrf": {csrf}, "host": {"rl.go.tito.io"}, "action": {action}} |
| + for i := 0; i+1 < len(kv); i += 2 { |
| + v.Set(kv[i], kv[i+1]) |
| + } |
| + return v |
| +} |
| + |
| +func TestAppSettingsRateLimitForm(t *testing.T) { |
| + fx, c, csrf := rlSettingsFixture(t, "code-settings-rl") |
| + page := fx.u("/apps/titogo/settings/domains") |
| + _, _, body := get(t, c, page) |
| + for _, want := range []string{ |
| + "<h2>Rate limit</h2>", |
| + "Turns away the addresses sending the most at once, and only while the instance is busy. The backstop is the exception: it stops a flood from one address at any time.", |
| + "Busy at (requests in flight)", |
| + "Per address while busy (requests in flight)", |
| + "Backstop (requests a second per address)", |
| + "Backstop burst", |
| + "Path rules, such as different numbers for /api/, are set with carlos ratelimit.", |
| + "Reset to defaults", |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("settings page lacks %q: %s", want, body) |
| + } |
| + } |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "500", "burst", "")) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") { |
| + t.Fatalf("set = %d -> %q", status, loc) |
| + } |
| + rec := rlSettingsRecord(t, fx) |
| + if rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, ratelimit.Settings{Rule: ratelimit.Rule{Rate: 500}}) { |
| + t.Fatalf("stored rate limit = %+v, want rate 500 only", rec.RateLimit) |
| + } |
| + _, _, body = get(t, c, page) |
| + if !strings.Contains(body, "Rate limit saved on rl.go.tito.io.") || !strings.Contains(body, `value="500"`) { |
| + t.Fatalf("after set: %s", body) |
| + } |
| + events, _ := fx.st.Events(OpsAccountID, "titogo", 20) |
| + var sawSet bool |
| + for _, e := range events { |
| + if e.Detail == "rate limit set on rl.go.tito.io (console)" { |
| + sawSet = true |
| + } |
| + } |
| + if !sawSet { |
| + t.Fatalf("no audit row for the set: %+v", events) |
| + } |
| + |
| + // All four fields filled in. |
| + postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "32", "client_share", "4", "rate", "100", "burst", "200")) |
| + want := ratelimit.Settings{BusyAt: 32, Rule: ratelimit.Rule{ClientShare: 4, Rate: 100, Burst: 200}} |
| + if rec := rlSettingsRecord(t, fx); rec.RateLimit == nil || !reflect.DeepEqual(*rec.RateLimit, want) { |
| + t.Fatalf("stored rate limit = %+v, want %+v", rec.RateLimit, want) |
| + } |
| + |
| + status, _, _ = postForm(t, c, fx.u("/apps/titogo/ratelimit"), rlForm(csrf, "clear")) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("clear = %d", status) |
| + } |
| + if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil { |
| + t.Fatalf("rate limit after clear = %+v, want nil", rec.RateLimit) |
| + } |
| + _, _, body = get(t, c, page) |
| + if !strings.Contains(body, "Rate limit back to defaults on rl.go.tito.io.") { |
| + t.Fatalf("after clear: %s", body) |
| + } |
| + events, _ = fx.st.Events(OpsAccountID, "titogo", 20) |
| + var sawClear bool |
| + for _, e := range events { |
| + if e.Detail == "rate limit cleared on rl.go.tito.io (console)" { |
| + sawClear = true |
| + } |
| + } |
| + if !sawClear { |
| + t.Fatalf("no audit row for the clear: %+v", events) |
| + } |
| +} |
| + |
| +// The page edits the four numbers and nothing else: path rules stay as the |
| +// CLI left them through a set, and only Reset drops them. |
| +func TestAppSettingsRateLimitKeepsPaths(t *testing.T) { |
| + fx, c, csrf := rlSettingsFixture(t, "code-settings-rl-paths") |
| + rec := rlSettingsRecord(t, fx) |
| + rec.RateLimit = &ratelimit.Settings{BusyAt: 10, Paths: []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Rate: 77}}}} |
| + _, etag, err := provision.GetRecordETag(context.Background(), fx.rel, "bab", "titogo", rec.Host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := provision.UpdateRecord(context.Background(), fx.rel, *rec, etag); err != nil { |
| + t.Fatal(err) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains")) |
| + if !strings.Contains(body, "/api/") || !strings.Contains(body, "77") { |
| + t.Fatalf("page does not list the path rule: %s", body) |
| + } |
| + |
| + postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "300", "burst", "")) |
| + want := ratelimit.Settings{Rule: ratelimit.Rule{Rate: 300}, |
| + Paths: []ratelimit.PathRule{{Prefix: "/api/", Rule: ratelimit.Rule{Rate: 77}}}} |
| + if got := rlSettingsRecord(t, fx); got.RateLimit == nil || !reflect.DeepEqual(*got.RateLimit, want) { |
| + t.Fatalf("stored = %+v, want %+v", got.RateLimit, want) |
| + } |
| + |
| + // Every field empty keeps the path rules too. |
| + postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "", "burst", "")) |
| + want = ratelimit.Settings{Paths: want.Paths} |
| + if got := rlSettingsRecord(t, fx); got.RateLimit == nil || !reflect.DeepEqual(*got.RateLimit, want) { |
| + t.Fatalf("stored = %+v, want %+v", got.RateLimit, want) |
| + } |
| + |
| + postForm(t, c, fx.u("/apps/titogo/ratelimit"), rlForm(csrf, "clear")) |
| + if got := rlSettingsRecord(t, fx); got.RateLimit != nil { |
| + t.Fatalf("clear left %+v", got.RateLimit) |
| + } |
| +} |
| + |
| +func TestAppSettingsRateLimitOverCeiling(t *testing.T) { |
| + fx, c, csrf := rlSettingsFixture(t, "code-settings-rl-ceiling") |
| + fx.srv.cfg.PlanCatalog = rlCatalog(t) |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "500", "burst", "")) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings/domains") { |
| + t.Fatalf("over-ceiling set = %d -> %q", status, loc) |
| + } |
| + _, _, body := get(t, c, fx.u("/apps/titogo/settings/domains")) |
| + if !strings.Contains(body, "The backstop rate can be at most 400 on this account's plan.") { |
| + t.Fatalf("page lacks the ceiling sentence: %s", body) |
| + } |
| + if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil { |
| + t.Fatalf("an over-ceiling value was stored: %+v", rec.RateLimit) |
| + } |
| + |
| + // At the ceiling is fine. |
| + postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm(csrf, "set", "busy_at", "", "client_share", "", "rate", "400", "burst", "")) |
| + if rec := rlSettingsRecord(t, fx); rec.RateLimit == nil || rec.RateLimit.Rate != 400 { |
| + t.Fatalf("stored = %+v, want rate 400", rec.RateLimit) |
| + } |
| +} |
| + |
| +func TestAppSettingsRateLimitNeedsCSRF(t *testing.T) { |
| + fx, c, _ := rlSettingsFixture(t, "code-settings-rl-csrf") |
| + status, _, body := postForm(t, c, fx.u("/apps/titogo/ratelimit"), |
| + rlForm("not-the-token", "set", "rate", "500")) |
| + if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") { |
| + t.Fatalf("got %d %s", status, body) |
| + } |
| + if rec := rlSettingsRecord(t, fx); rec.RateLimit != nil { |
| + t.Fatalf("a refused post stored %+v", rec.RateLimit) |
| + } |
| +} |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 7b2a81a7f..77e1de8f7 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -1174,6 +1174,15 @@ var claimFixtures = map[string]claimFixture{ |
| }}, |
| // Rename is refused for every app on an enabled console |
| // (claimUnsupported), recordless titogo included. |
| + "route POST /a/{sqid}/apps/{app}/ratelimit": {door: &claimDoor{ |
| + apps: claimRefs("bab/titogo"), |
| + setup: func(t *testing.T, cx *claimCase) { |
| + // The rate limit lives on an instance record, like the gate. |
| + cx.instanceWorld(t) |
| + cx.instance(t, `{"host":"acme.go.tito.io","region":"ie"}`) |
| + }, |
| + do: claimForm("/a/bab/apps/titogo/ratelimit", "host", "acme.go.tito.io", "action", "set", "rate", "500"), |
| + }}, |
| "route POST /a/{sqid}/apps/{app}/rename": {refused: &claimRefusalFixture{ |
| apps: claimRefs("bab/titogo", "+bab/titogo-renamed"), |
| do: claimRefusedForm("/a/bab/apps/titogo/rename", "to", "titogo-renamed", "confirmed", "1"), |
diff --git a/internal/console/server.go b/internal/console/server.go| index e7c940087..a4499cf04 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -1559,6 +1559,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| // The password-gate form (appsettings.go's handleAppGate): member-gated, |
| // unlike the owner-only writes above it. |
| s.registerRoute("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate) |
| + // The rate-limit form beside it (handleAppRateLimit): member-gated too. |
| + s.registerRoute("POST /a/{sqid}/apps/{app}/ratelimit", s.handleAppRateLimit) |
| // The Deploys card's form (appswap.go): member-gated, like the gate. |
| s.registerRoute("POST /a/{sqid}/apps/{app}/swap", s.handleAppSwap) |
| s.registerRoute("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename) |
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html| index 7c36cd1cb..ae0b0539b 100644 |
| --- a/internal/console/templates/app_settings.html |
| +++ b/internal/console/templates/app_settings.html |
| @@ -135,6 +135,48 @@ page asking for it and stay signed in for a week. It is set per instance.</p> |
| </div> |
| {{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}} |
| +<h2>Rate limit</h2> |
| +<p class="small">Turns away the addresses sending the most at once, and only while the instance is busy. The backstop is the exception: it stops a flood from one address at any time.</p> |
| +{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p> |
| +{{else if .Settings.Gates}} |
| +<div class="tablewrap"> |
| +<table> |
| + {{range $i, $g := .Settings.Gates}} |
| + <tr> |
| + <td><code>{{$g.Host}}</code></td> |
| + <td> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/ratelimit" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="set"> |
| + <label for="rl-busy-{{$i}}">Busy at (requests in flight)<span class="visually-hidden"> {{$g.Host}}</span></label> |
| + <input type="number" id="rl-busy-{{$i}}" name="busy_at" min="0" max="1000000" value="{{$g.BusyAt}}" placeholder="64"> |
| + <label for="rl-share-{{$i}}">Per address while busy (requests in flight)<span class="visually-hidden"> {{$g.Host}}</span></label> |
| + <input type="number" id="rl-share-{{$i}}" name="client_share" min="0" max="1000000" value="{{$g.Share}}" placeholder="16"> |
| + <label for="rl-rate-{{$i}}">Backstop (requests a second per address)<span class="visually-hidden"> {{$g.Host}}</span></label> |
| + <input type="number" id="rl-rate-{{$i}}" name="rate" min="0" max="1000000" value="{{$g.Rate}}" placeholder="300"> |
| + <label for="rl-burst-{{$i}}">Backstop burst<span class="visually-hidden"> {{$g.Host}}</span></label> |
| + <input type="number" id="rl-burst-{{$i}}" name="burst" min="0" max="1000000" value="{{$g.Burst}}" placeholder="1000"> |
| + <button type="submit" class="btn-sm">Save</button> |
| + </form> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/ratelimit" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="clear"> |
| + <button type="submit" class="btn-sm btn-danger">Reset to defaults</button> |
| + </form> |
| + {{if $g.Paths}} |
| + <p class="small">Path rules, such as different numbers for /api/, are set with carlos ratelimit.</p> |
| + <ul class="small">{{range $g.Paths}}<li><code>{{.Prefix}}</code> {{.Numbers}}</li>{{end}}</ul> |
| + {{end}} |
| + </td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Path rules, such as different numbers for /api/, are set with carlos ratelimit.</p> |
| +{{end}} |
| + |
| {{end}} |
| {{if eq .SettingsSection "general"}} |
| {{if and .Settings .Settings.MismatchTarget}} |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 1f17e0834..89c780455 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -138,6 +138,7 @@ route POST /a/{sqid}/apps/{app}/pipeline |
| route POST /a/{sqid}/apps/{app}/place |
| route POST /a/{sqid}/apps/{app}/production |
| route POST /a/{sqid}/apps/{app}/promote |
| +route POST /a/{sqid}/apps/{app}/ratelimit |
| route POST /a/{sqid}/apps/{app}/rename |
| route POST /a/{sqid}/apps/{app}/restart |
| route POST /a/{sqid}/apps/{app}/rollback |