Compare commits

1 Commits
Author SHA1 Message Date
clawbot 12deb0d79b Use one name for each thing the UI shows (closes #399)
check / check (push) Successful in 3m31s
The database target type is called an archive on its badge, in the add target form's type list and on its edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere, the new webhook page included. The retry field is labelled "Delivery attempts", with its help text and error messages to match, on both target forms and in the target list, where a stored 0 shows as one attempt. The target list's other labels take the same capitalisation. The navbar says "Sign out" and the sign-in page "Sign in", and the README follows. The resubmit notice says "webhook". The stored values (`database`, `max_retries`) and their meaning are unchanged.

Model: opus-5-5
2026-10-03 02:54:47 +00:00
7 changed files with 30 additions and 29 deletions
+10 -10
View File
@@ -313,7 +313,7 @@ itself; a production deployment puts a reverse proxy in front of it
[Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)), [Deployment behind a reverse proxy](#deployment-behind-a-reverse-proxy)),
and the proxy reaches it over loopback. A default that bound every and the proxy reaches it over loopback. A default that bound every
interface would leave that cleartext port answering the internet interface would leave that cleartext port answering the internet
alongside the proxy — the admin login form and the receiver, in the alongside the proxy — the admin sign-in form and the receiver, in the
clear, on a port nobody chose to publish. Reaching webhooker from clear, on a port nobody chose to publish. Reaching webhooker from
another host is therefore something you configure, not something you another host is therefore something you configure, not something you
get by default. get by default.
@@ -835,7 +835,7 @@ reports.
`-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy `-p 127.0.0.1:8080:8080`. Either way the port must reach the proxy
and nothing else; widen it only with a firewall or a publish and nothing else; widen it only with a firewall or a publish
address in front of it. A cleartext port answering the internet address in front of it. A cleartext port answering the internet
serves the admin login form and the unauthenticated receiver with serves the admin sign-in form and the unauthenticated receiver with
no TLS at all, and the proxy in front of it changes nothing about no TLS at all, and the proxy in front of it changes nothing about
that. that.
2. **Make sure the environment is not `dev` (leave 2. **Make sure the environment is not `dev` (leave
@@ -2591,7 +2591,7 @@ The query string is never logged; it is replaced by the fixed marker
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate `/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
limiter in front of them, so a query on a fixed 200 URL would otherwise limiter in front of them, so a query on a fixed 200 URL would otherwise
buy the same amplification as an invented path. Nothing debuggable is buy the same amplification as an invented path. Nothing debuggable is
lost: the only query parameters this service reads are the login page's lost: the only query parameters this service reads are the sign-in page's
`next`, the page to return to, and `notice`, which names the line a page `next`, the page to return to, and `notice`, which names the line a page
shows after an action. shows after an action.
@@ -2746,9 +2746,9 @@ wider than it:
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver | | `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition | | `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver | | `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form | | `user not found` / `invalid password` | `DEBUG` | username | yes, on the sign-in form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form | | `login failure limit exceeded` (429) | `WARN` | path | yes, on the sign-in form |
| `password verification capacity exhausted` | `WARN` | path | yes, on the login form | | `password verification capacity exhausted` | `WARN` | path | yes, on the sign-in form |
`DEBUG` being off by default is not a bound. An operator turning it on `DEBUG` being off by default is not a bound. An operator turning it on
to diagnose a flood must not thereby hand the flood an unbounded write, to diagnose a flood must not thereby hand the flood an unbounded write,
@@ -2796,7 +2796,7 @@ standard output on every statement that returned an error, including a
plain record-not-found, at a level no operator setting reached. Two of plain record-not-found, at a level no operator setting reached. Two of
this service's lookups miss by design on unauthenticated routes: the this service's lookups miss by design on unauthenticated routes: the
entrypoint lookup behind `/h/{uuid}` and the user lookup behind entrypoint lookup behind `/h/{uuid}` and the user lookup behind
the login form, whose path segment and submitted username the client the sign-in form, whose path segment and submitted username the client
picks outright. Every picks outright. Every
`gorm.Open` in the service now installs the adapter in `gorm.Open` in the service now installs the adapter in
`internal/gormlog` instead. It writes through the same `slog` logger as `internal/gormlog` instead. It writes through the same `slog` logger as
@@ -3090,8 +3090,8 @@ abuse limit later; they are tracked as future work.
#### Authenticated Endpoints #### Authenticated Endpoints
A logged-out `GET` of any of these is redirected to `/pages/login` with A signed-out `GET` of any of these is redirected to `/pages/login` with
its path and query as `next` when they fit in 2048 bytes, so logging in its path and query as `next` when they fit in 2048 bytes, so signing in
returns to the page that was asked for. returns to the page that was asked for.
| Method | Path | Description | | Method | Path | Description |
@@ -3440,7 +3440,7 @@ check, see [The login endpoint](#the-login-endpoint).
still evaluated, so roughly 27 guesses a second get through and the still evaluated, so roughly 27 guesses a second get through and the
admin password has to carry that load (see admin password has to carry that load (see
[The login endpoint](#the-login-endpoint)). `GET` requests to the [The login endpoint](#the-login-endpoint)). `GET` requests to the
login page are not limited sign-in page are not limited
- **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate): - **Password-change rate limiting** via [go-chi/httprate](https://github.com/go-chi/httprate):
sliding-window rate limiter, 5 POST attempts per minute per bucket. sliding-window rate limiter, 5 POST attempts per minute per bucket.
It runs behind session auth, so only a client already holding a It runs behind session auth, so only a client already holding a
+5 -4
View File
@@ -177,16 +177,17 @@ func httpConfigFields(t *database.Target) []ConfigField {
} }
// maxRetriesField describes a target's retry count, which lives // maxRetriesField describes a target's retry count, which lives
// on the target row rather than in its configuration blob. // on the target row rather than in its configuration blob. A
// stored 0 makes a single attempt, so it is shown as 1.
func maxRetriesField(t *database.Target) ConfigField { func maxRetriesField(t *database.Target) ConfigField {
retries := strconv.Itoa(t.MaxRetries) attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 { if t.MaxRetries == 0 {
retries += " (fire-and-forget)" attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
} }
return ConfigField{ return ConfigField{
Label: "Delivery attempts", Label: "Delivery attempts",
Value: retries, Value: attempts,
} }
} }
+2 -2
View File
@@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": slackMaskedURL,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "0 (fire-and-forget)", viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
+1 -1
View File
@@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
assert.Equal(t, "2.0 kB", view.Size) assert.Equal(t, "2.0 kB", view.Size)
page := targetList(t, renderedPage(t, env, webhook.ID)) page := targetList(t, renderedPage(t, env, webhook.ID))
assert.Contains(t, page, "Archive Size: 2.0 kB in 2 files") assert.Contains(t, page, "Archive size: 2.0 kB in 2 files")
} }
// renderedPage returns the webhook page. // renderedPage returns the webhook page.
+4 -4
View File
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive) path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID) body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive File:")) assert.Equal(t, 1, strings.Count(body, "Archive file:"))
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
seedArchive(t, path, 1, 100) seedArchive(t, path, 1, 100)
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet") assert.NotContains(t, body, "not created yet")
assert.Regexp(t, assert.Regexp(t,
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body, `Archive size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
) )
assert.Contains(t, body, assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`, `title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
@@ -69,7 +69,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
body = renderSourceDetailPage(t, h, sess, wh.ID) body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path)) assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet") assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:") assert.NotContains(t, body, "Archive size:")
} }
// targetList returns the text of the targets section in a rendered // targetList returns the text of the targets section in a rendered
+4 -4
View File
@@ -81,10 +81,10 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+ assert.Regexp(t, "t-http http Active Edit Deactivate Delete "+
"Deliveries Paused: after repeated failures, until "+cooldownEnds+ "Deliveries paused: after repeated failures, until "+cooldownEnds+
", then one waiting delivery is sent to test the target while "+ ", then one waiting delivery is sent to test the target while "+
"the others wait at least one more cooldown", list) "the others wait at least one more cooldown", list)
assert.Equal(t, 1, strings.Count(list, "Paused")) assert.Equal(t, 1, strings.Count(list, "Deliveries paused"))
log := renderSourceLogsPage(t, h, sess, wh.ID) log := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting")) assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
@@ -105,7 +105,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+ assert.Contains(t, list, "t-http http Active Edit Deactivate Delete "+
"Deliveries Paused: held while one delivery tests whether the "+ "Deliveries paused: held while one delivery tests whether the "+
"target has recovered") "target has recovered")
// Not the whole list: the add target form above the rows says UTC. // Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC") assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
@@ -115,7 +115,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
breakers.Set(target.ID, delivery.CircuitClosed, 0) breakers.Set(target.ID, delivery.CircuitClosed, 0)
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID)) list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
assert.NotContains(t, list, "Paused") assert.NotContains(t, list, "Deliveries paused")
assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff) assertRetryingNotWaiting(t, h, sess, wh.ID, retrying, backedOff)
} }
+4 -4
View File
@@ -275,7 +275,7 @@
</div> </div>
{{with .Paused}} {{with .Paused}}
<div class="text-xs text-yellow-600 mt-1"> <div class="text-xs text-yellow-600 mt-1">
<span class="font-medium">Deliveries Paused:</span> <span class="font-medium">Deliveries paused:</span>
<span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span> <span>{{if .Until}}after repeated failures, until {{.Until}} ({{.Relative}}), then one waiting delivery is sent to test the target while the others wait at least one more cooldown{{else}}held while one delivery tests whether the target has recovered{{end}}</span>
</div> </div>
{{end}} {{end}}
@@ -287,17 +287,17 @@
{{end}} {{end}}
{{with .Archive}} {{with .Archive}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive File:</span> <span class="font-medium text-gray-700">Archive file:</span>
<span class="break-all">{{.Name}}</span> <span class="break-all">{{.Name}}</span>
{{with .Note}}<span>({{.}})</span>{{end}} {{with .Note}}<span>({{.}})</span>{{end}}
</div> </div>
{{if .Files}} {{if .Files}}
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive Size:</span> <span class="font-medium text-gray-700">Archive size:</span>
<span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span> <span>{{.Size}}{{if gt .Files 1}} in {{.Files}} files{{end}}</span>
</div> </div>
<div class="text-xs text-gray-500 mt-1"> <div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last Written:</span> <span class="font-medium text-gray-700">Last written:</span>
<span title="{{.WrittenUTC}}">{{.Written}}</span> <span title="{{.WrittenUTC}}">{{.Written}}</span>
</div> </div>
{{end}} {{end}}