Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48ecfaab34 | ||
|
|
9079a3219d | ||
|
|
b9ec91c0f7 |
@@ -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
|
||||||
@@ -1617,10 +1617,11 @@ event routing.
|
|||||||
The new webhook form can also give the webhook its first targets: an
|
The new webhook form can also give the webhook its first targets: an
|
||||||
optional HTTP target URL creates an `http` target named `HTTP`, and the
|
optional HTTP target URL creates an `http` target named `HTTP`, and the
|
||||||
archive checkbox creates a `database` target named `Archive` whose
|
archive checkbox creates a `database` target named `Archive` whose
|
||||||
`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d
|
`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h,
|
||||||
or 365d) and whose `rotation` is the rotation chosen below that (none,
|
30d, 90d or 365d) and whose `rotation` is the rotation chosen below that
|
||||||
monthly, daily or hourly). Both are validated as on the add target form,
|
(none, monthly, daily or hourly). Both are validated as on the add
|
||||||
and the webhook and its targets are created together or not at all.
|
target form, and the webhook and its targets are created together or
|
||||||
|
not at all.
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ---------------- | ------- | ----------- |
|
| ---------------- | ------- | ----------- |
|
||||||
@@ -1707,7 +1708,7 @@ events should be forwarded.
|
|||||||
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
|
||||||
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
| `active` | boolean | Whether deliveries are enabled (default: true) |
|
||||||
| `config` | JSON text | Type-specific configuration |
|
| `config` | JSON text | Type-specific configuration |
|
||||||
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets |
|
| `max_retries` | integer | Total delivery attempts for `http` and `slack` targets, not retries on top of the first: 0 is a single fire-and-forget attempt with no retries and no circuit breaker, and a value of N makes N attempts in all, with exponential backoff and a per-target circuit breaker. Ignored by `database` and `log` targets. The web UI labels it Delivery attempts |
|
||||||
|
|
||||||
**Relations:** Belongs to Webhook. Has many Deliveries.
|
**Relations:** Belongs to Webhook. Has many Deliveries.
|
||||||
|
|
||||||
@@ -1736,7 +1737,9 @@ events should be forwarded.
|
|||||||
expiry in plain units, such as "30 days", and the rotation. No external
|
expiry in plain units, such as "30 days", and the rotation. No external
|
||||||
delivery and no retries; an archive write failure fails the delivery.
|
delivery and no retries; an archive write failure fails the delivery.
|
||||||
See the database target section under "Per-Webhook Event Databases"
|
See the database target section under "Per-Webhook Event Databases"
|
||||||
for the full semantics.
|
for the full semantics. The web UI calls this type an archive: its
|
||||||
|
badge, the add target form's type list and the target edit page say
|
||||||
|
so, and its settings are labelled Archive expiry and Archive rotation.
|
||||||
- **`log`** — Write the event to the application log (stdout). Useful
|
- **`log`** — Write the event to the application log (stdout). Useful
|
||||||
for debugging.
|
for debugging.
|
||||||
|
|
||||||
@@ -2588,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, `notice`, which names the line a page
|
`next`, the page to return to, `notice`, which names the line a page
|
||||||
shows after an action, and the event log's `show`, which picks the events
|
shows after an action, and the event log's `show`, which picks the events
|
||||||
it lists.
|
it lists.
|
||||||
@@ -2744,9 +2747,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,
|
||||||
@@ -2794,7 +2797,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
|
||||||
@@ -3082,14 +3085,14 @@ abuse limit later; they are tracked as future work.
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
| ------ | --------------- | ----------- |
|
| ------ | --------------- | ----------- |
|
||||||
| `GET` | `/pages/login` | Login page (not rate limited). Its `next` parameter names the page to return to after login; anything but a path on this site is replaced with `/` |
|
| `GET` | `/pages/login` | Sign-in page (not rate limited). Its `next` parameter names the page to return to after signing in; anything but a path on this site is replaced with `/` |
|
||||||
| `POST` | `/pages/login` | Login form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
| `POST` | `/pages/login` | Sign-in form submission. On success, redirects to the form's `next` when it is a path on this site, otherwise to `/`. Credentials are verified before any limit is consulted, so a correct password is never throttled; 5 FAILED attempts per minute per bucket per submitted username, then `429`. `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one (see [Rate Limiting](#rate-limiting)) |
|
||||||
| `POST` | `/pages/logout` | Logout (destroys session) |
|
| `POST` | `/pages/logout` | Sign out (destroys session) |
|
||||||
|
|
||||||
#### 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 |
|
||||||
@@ -3105,7 +3108,7 @@ returns to the page that was asked for.
|
|||||||
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
|
||||||
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
| `POST` | `/hook/{id}/delete` | Delete webhook |
|
||||||
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
|
| `GET` | `/hook/{id}/events` | Full Event Log. `?show=failed` lists only the events with a failed delivery, and `?show=pending` only those with a delivery pending or retrying |
|
||||||
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
|
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, the entrypoint it arrived at (for a resubmitted copy, the one the request it copies arrived at), its request headers, its whole body and every delivery of it |
|
||||||
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
| `GET` | `/hook/{id}/events/{eventID}/body` | Download an event's stored body. The pages show a body as text, cut at 32 KiB in the recent events and the event log, and leave a binary one out, so this is the only route that serves the stored bytes; it is offered wherever a body is cut or binary |
|
||||||
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/deliveries/{deliveryID}/replay` | Replay a finished delivery: creates a new delivery for the same event against the target's current configuration (30 per minute per bucket, then `429`) |
|
||||||
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
| `POST` | `/hook/{id}/events/{eventID}/resubmit` | Resubmit a stored event: creates a new event copying it and fans that out to every currently active target (30 per minute per bucket, then `429`) |
|
||||||
@@ -3438,7 +3441,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
|
||||||
|
|||||||
@@ -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: "Max Retries",
|
Label: "Delivery attempts",
|
||||||
Value: retries,
|
Value: attempts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,10 +214,10 @@ func databaseConfigFields(configJSON string) []ConfigField {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return []ConfigField{{
|
return []ConfigField{{
|
||||||
Label: "Archive Expiry",
|
Label: "Archive expiry",
|
||||||
Value: value,
|
Value: value,
|
||||||
}, {
|
}, {
|
||||||
Label: "Archive Rotation",
|
Label: "Archive rotation",
|
||||||
Value: rotation,
|
Value: rotation,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const (
|
|||||||
viewMaskedOrigin = viewExampleOrigin + "/..."
|
viewMaskedOrigin = viewExampleOrigin + "/..."
|
||||||
viewUnavailable = "(unavailable)"
|
viewUnavailable = "(unavailable)"
|
||||||
viewExpiryNever = "never"
|
viewExpiryNever = "never"
|
||||||
viewMaxRetries = "Max Retries"
|
viewMaxRetries = "Delivery attempts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMaskedWebhookURL(t *testing.T) {
|
func TestMaskedWebhookURL(t *testing.T) {
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
@@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) {
|
|||||||
assert.Equal(
|
assert.Equal(
|
||||||
t,
|
t,
|
||||||
map[string]string{
|
map[string]string{
|
||||||
"Archive Expiry": tc.want,
|
"Archive expiry": tc.want,
|
||||||
"Archive Rotation": rotationNone,
|
"Archive rotation": rotationNone,
|
||||||
},
|
},
|
||||||
fieldMap(view.Config),
|
fieldMap(view.Config),
|
||||||
)
|
)
|
||||||
@@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
assert.Equal(
|
assert.Equal(
|
||||||
t, want, fieldMap(view.Config)["Archive Rotation"],
|
t, want, fieldMap(view.Config)["Archive rotation"],
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ func expiryShown(
|
|||||||
require.Equal(t, http.StatusOK, w.Code)
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
return matched(
|
return matched(
|
||||||
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func rotationShown(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
return matched(
|
return matched(
|
||||||
`Archive Rotation:</span>\s*<span>([^<]*)</span>`,
|
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
|
||||||
renderedPage(t, env, webhookID),
|
renderedPage(t, env, webhookID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
|
|||||||
)))
|
)))
|
||||||
h.renderLoginError(
|
h.renderLoginError(
|
||||||
w, r,
|
w, r,
|
||||||
"Too many failed login attempts. Please try again later.",
|
"Too many failed sign-in attempts. Please try again later.",
|
||||||
http.StatusTooManyRequests,
|
http.StatusTooManyRequests,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The page shows every request header.
|
||||||
views, ok := h.eventLogViews(
|
views, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targets,
|
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
|
||||||
)
|
)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode/utf8"
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/dustin/go-humanize"
|
"github.com/dustin/go-humanize"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
// eventLogColumns is the event log's projection. The casts to
|
// eventLogColumns is the event log's projection. The casts to
|
||||||
@@ -12,16 +17,20 @@ import (
|
|||||||
// bytes rather than characters, so the cap bounds the page in
|
// bytes rather than characters, so the cap bounds the page in
|
||||||
// bytes whatever the payload's encoding. Cutting in SQLite
|
// bytes whatever the payload's encoding. Cutting in SQLite
|
||||||
// rather than in Go is the point of the projection — an
|
// rather than in Go is the point of the projection — an
|
||||||
// oversized body never becomes a Go string at all.
|
// oversized body or set of request headers never becomes a Go
|
||||||
|
// string at all.
|
||||||
const eventLogColumns = "id, created_at, method, content_type, " +
|
const eventLogColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, " +
|
||||||
|
"substr(cast(headers as blob), 1, ?) AS headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"substr(cast(body as blob), 1, ?) AS body, " +
|
"substr(cast(body as blob), 1, ?) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
// eventColumns is eventLogColumns for the event's own page, which
|
// eventColumns is eventLogColumns for the event's own page, which
|
||||||
// shows the whole body.
|
// shows the whole body and every request header.
|
||||||
const eventColumns = "id, created_at, method, content_type, " +
|
const eventColumns = "id, created_at, method, content_type, " +
|
||||||
"resubmitted_from_id, " +
|
"resubmitted_from_id, entrypoint_id, headers, " +
|
||||||
|
"length(cast(headers as blob)) AS headers_bytes, " +
|
||||||
"cast(body as blob) AS body, " +
|
"cast(body as blob) AS body, " +
|
||||||
"length(cast(body as blob)) AS body_bytes"
|
"length(cast(body as blob)) AS body_bytes"
|
||||||
|
|
||||||
@@ -40,6 +49,22 @@ type EventLogView struct {
|
|||||||
|
|
||||||
Body BodyView
|
Body BodyView
|
||||||
|
|
||||||
|
// Entrypoint names the entrypoint the event arrived at. A
|
||||||
|
// resubmitted copy, even a copy of a copy, did not arrive; it
|
||||||
|
// names the one the request it copies arrived at. The name is
|
||||||
|
// the entrypoint's description, "Entrypoint" when it has none,
|
||||||
|
// or "deleted entrypoint", never its URL, which is the
|
||||||
|
// entrypoint's secret.
|
||||||
|
Entrypoint string
|
||||||
|
|
||||||
|
// Headers is the event's request headers as text, one
|
||||||
|
// "Name: value" line per value, sorted by name. HeadersCut
|
||||||
|
// reports headers left out because they hold more than
|
||||||
|
// maxRenderedBodyBytes, stored or as text; only the event log
|
||||||
|
// leaves them out.
|
||||||
|
Headers string
|
||||||
|
HeadersCut bool
|
||||||
|
|
||||||
// ResubmittedFromID names the event this one was copied
|
// ResubmittedFromID names the event this one was copied
|
||||||
// from, empty for an event that arrived on the receiver.
|
// from, empty for an event that arrived on the receiver.
|
||||||
ResubmittedFromID string
|
ResubmittedFromID string
|
||||||
@@ -60,27 +85,35 @@ func (v EventLogView) ResubmittedFrom() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// eventLogRow is one row of the event log projection, or of
|
// eventLogRow is one row of the event log projection, or of
|
||||||
// eventColumns. In the event log its body column arrives
|
// eventColumns. In the event log its headers and body columns
|
||||||
// already cut to the cap by SQLite, with the true size beside
|
// arrive already cut to the cap by SQLite, each with its true
|
||||||
// it.
|
// size beside it.
|
||||||
type eventLogRow struct {
|
type eventLogRow struct {
|
||||||
ID string
|
ID string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Method string
|
Method string
|
||||||
ContentType string
|
ContentType string
|
||||||
ResubmittedFromID *string
|
ResubmittedFromID *string
|
||||||
|
EntrypointID string
|
||||||
|
Headers string
|
||||||
|
HeadersBytes int64
|
||||||
Body []byte
|
Body []byte
|
||||||
BodyBytes int64
|
BodyBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// view projects a loaded row of the webhook's events for
|
// view projects a loaded row of the webhook's events for
|
||||||
// rendering.
|
// rendering. It shows the request headers when the row holds them
|
||||||
func (r *eventLogRow) view(webhookID string) EventLogView {
|
// whole and their text holds at most maxHeaderBytes.
|
||||||
|
func (r *eventLogRow) view(
|
||||||
|
webhookID string, maxHeaderBytes int,
|
||||||
|
) EventLogView {
|
||||||
var from string
|
var from string
|
||||||
if r.ResubmittedFromID != nil {
|
if r.ResubmittedFromID != nil {
|
||||||
from = *r.ResubmittedFromID
|
from = *r.ResubmittedFromID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
|
||||||
|
|
||||||
return EventLogView{
|
return EventLogView{
|
||||||
ID: r.ID,
|
ID: r.ID,
|
||||||
Method: r.Method,
|
Method: r.Method,
|
||||||
@@ -90,10 +123,83 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
|
|||||||
Body: newBodyView(
|
Body: newBodyView(
|
||||||
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
|
||||||
),
|
),
|
||||||
|
Headers: strings.Join(headers, "\n"),
|
||||||
|
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
|
||||||
ResubmittedFromID: from,
|
ResubmittedFromID: from,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requestHeaderLines turns an event's stored request headers, the
|
||||||
|
// JSON the receiver writes, into one "Name: value" line per value,
|
||||||
|
// sorted by name. Headers that do not parse, as when the event log
|
||||||
|
// has cut them, show as none. It reports false, with no lines, when
|
||||||
|
// the lines, each with the newline that follows it, would hold more
|
||||||
|
// than maxBytes: a header sent many times is stored with its name
|
||||||
|
// once but shown with it on every line.
|
||||||
|
func requestHeaderLines(headersJSON string, maxBytes int) ([]string, bool) {
|
||||||
|
var headers http.Header
|
||||||
|
|
||||||
|
if json.Unmarshal([]byte(headersJSON), &headers) != nil {
|
||||||
|
return nil, true
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make([]string, 0, len(headers))
|
||||||
|
for name := range headers {
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(names)
|
||||||
|
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
size := 0
|
||||||
|
|
||||||
|
for _, name := range names {
|
||||||
|
for _, value := range headers[name] {
|
||||||
|
line := name + ": " + value
|
||||||
|
|
||||||
|
size += len(line) + len("\n")
|
||||||
|
if size > maxBytes {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypointNames maps each of the webhook's entrypoints to the name
|
||||||
|
// an event that arrived at it shows: its description, or "Entrypoint"
|
||||||
|
// when it has none, as the webhook page names it. A deleted
|
||||||
|
// entrypoint is left out.
|
||||||
|
func (h *Handlers) entrypointNames(
|
||||||
|
webhookID string,
|
||||||
|
) (map[string]string, error) {
|
||||||
|
var entrypoints []database.Entrypoint
|
||||||
|
|
||||||
|
err := h.db.DB().Where(
|
||||||
|
"webhook_id = ?", webhookID,
|
||||||
|
).Find(&entrypoints).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
names := make(map[string]string, len(entrypoints))
|
||||||
|
|
||||||
|
for i := range entrypoints {
|
||||||
|
name := entrypoints[i].Description
|
||||||
|
if name == "" {
|
||||||
|
name = "Entrypoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
names[entrypoints[i].ID] = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return names, nil
|
||||||
|
}
|
||||||
|
|
||||||
// trimPartialRune drops a trailing UTF-8 sequence that the
|
// trimPartialRune drops a trailing UTF-8 sequence that the
|
||||||
// byte-wise cut left incomplete, so a multi-byte rune severed
|
// byte-wise cut left incomplete, so a multi-byte rune severed
|
||||||
// at the cap does not surface as a mojibake tail.
|
// at the cap does not surface as a mojibake tail.
|
||||||
|
|||||||
@@ -0,0 +1,332 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// arrivedAt is how a page names the entrypoint an event arrived at.
|
||||||
|
func arrivedAt(name string) string {
|
||||||
|
return `Arrived at <span class="text-gray-900">` + name + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// copiedRequestArrivedAt is how a page names, for a resubmitted copy,
|
||||||
|
// the entrypoint the request it copies arrived at.
|
||||||
|
func copiedRequestArrivedAt(name string) string {
|
||||||
|
return `The request it copies arrived at <span class="text-gray-900">` +
|
||||||
|
name + `</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// headerBox is how a page shows an event's request header lines: as
|
||||||
|
// one block of text in a single box.
|
||||||
|
func headerBox(lines ...string) string {
|
||||||
|
return `<pre class="rounded-md border border-gray-200 bg-white p-2 ` +
|
||||||
|
`text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap ` +
|
||||||
|
`break-all">` + strings.Join(lines, "\n") + `</pre>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// showHeadersLink is the event log's link to an event's own page for
|
||||||
|
// request headers it leaves out.
|
||||||
|
func showHeadersLink(webhookID, eventID string) string {
|
||||||
|
return `<a href="/hook/` + webhookID + `/events/` + eventID +
|
||||||
|
`" class="btn-small">Show the request headers</a>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// entrypoint records one of the fixture webhook's entrypoints.
|
||||||
|
func (f *recentEventsFixture) entrypoint(
|
||||||
|
t *testing.T, description string,
|
||||||
|
) *database.Entrypoint {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
ep := &database.Entrypoint{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
Path: uuid.NewString(),
|
||||||
|
Description: description,
|
||||||
|
Active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, f.db.DB().Omit(clause.Associations).Create(ep).Error)
|
||||||
|
|
||||||
|
return ep
|
||||||
|
}
|
||||||
|
|
||||||
|
// eventAt records an event that arrived at the entrypoint with the
|
||||||
|
// given request headers, stored as JSON as the receiver stores them.
|
||||||
|
func (f *recentEventsFixture) eventAt(
|
||||||
|
t *testing.T,
|
||||||
|
ep *database.Entrypoint,
|
||||||
|
headersJSON string,
|
||||||
|
receivedAt time.Time,
|
||||||
|
) *database.Event {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
event := &database.Event{
|
||||||
|
WebhookID: f.webhook.ID,
|
||||||
|
EntrypointID: ep.ID,
|
||||||
|
Method: http.MethodPost,
|
||||||
|
Headers: headersJSON,
|
||||||
|
Body: "{}",
|
||||||
|
BodyBytes: 2,
|
||||||
|
ContentType: contentTypeJSON,
|
||||||
|
}
|
||||||
|
event.CreatedAt = receivedAt
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Omit(
|
||||||
|
clause.Associations,
|
||||||
|
).Create(event).Error)
|
||||||
|
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders proves two
|
||||||
|
// events that arrived at two entrypoints each show their own
|
||||||
|
// entrypoint and request headers, in the event log and on their own
|
||||||
|
// pages, with the headers sorted by name, escaped and keeping their
|
||||||
|
// whitespace, and never the entrypoint's URL.
|
||||||
|
func TestEventRequest_EachEventShowsItsOwnEntrypointAndHeaders(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
billing := f.entrypoint(t, "Billing sender")
|
||||||
|
unnamed := f.entrypoint(t, "")
|
||||||
|
|
||||||
|
// Stored in reverse name order.
|
||||||
|
older := f.eventAt(t, billing,
|
||||||
|
`{"X-Shop-Event":["order.created"],`+
|
||||||
|
`"User-Agent":["shop/1 build\t7"],"Accept":["*/*"]}`,
|
||||||
|
time.Now().Add(-time.Minute))
|
||||||
|
newer := f.eventAt(t, unnamed,
|
||||||
|
`{"X-Shop-Event":["order.paid"],"X-Note":["<b>hi</b>"]}`,
|
||||||
|
time.Now())
|
||||||
|
|
||||||
|
olderShows := func(t *testing.T, page string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Contains(t, page, arrivedAt("Billing sender"))
|
||||||
|
assert.Contains(t, page, headerBox(
|
||||||
|
"Accept: */*",
|
||||||
|
"User-Agent: shop/1 build\t7",
|
||||||
|
"X-Shop-Event: order.created",
|
||||||
|
), "headers are sorted by name")
|
||||||
|
assert.NotContains(t, page, "order.paid")
|
||||||
|
assert.NotContains(t, page, billing.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
newerShows := func(t *testing.T, page string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
assert.Contains(t, page, arrivedAt("Entrypoint"))
|
||||||
|
assert.Contains(t, page, headerBox(
|
||||||
|
"X-Note: <b>hi</b>",
|
||||||
|
"X-Shop-Event: order.paid",
|
||||||
|
))
|
||||||
|
assert.NotContains(t, page, "<b>hi</b>")
|
||||||
|
assert.NotContains(t, page, "order.created")
|
||||||
|
assert.NotContains(t, page, unnamed.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log lists the newer event first, so everything between
|
||||||
|
// the two events' first mentions belongs to the newer one.
|
||||||
|
_, rest, found := strings.Cut(renderSourceLogsPage(
|
||||||
|
t, f.h, f.sess, f.webhook.ID,
|
||||||
|
), newer.ID)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
newerPart, olderPart, found := strings.Cut(rest, older.ID)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
newerShows(t, newerPart)
|
||||||
|
olderShows(t, olderPart)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, newer.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
newerShows(t, w.Body.String())
|
||||||
|
|
||||||
|
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, older.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
olderShows(t, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_DeletedEntrypoint proves an event whose entrypoint
|
||||||
|
// has since been deleted says so in the event log and on its own page.
|
||||||
|
func TestEventRequest_DeletedEntrypoint(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Retired sender")
|
||||||
|
event := f.eventAt(t, ep, `{}`, time.Now())
|
||||||
|
|
||||||
|
require.NoError(t, f.db.DB().Delete(ep).Error)
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
assert.Contains(t, page, arrivedAt("deleted entrypoint"))
|
||||||
|
assert.NotContains(t, page, "Retired sender")
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), arrivedAt("deleted entrypoint"))
|
||||||
|
assert.NotContains(t, w.Body.String(), "Retired sender")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_ResubmittedCopy proves a resubmitted copy and a copy
|
||||||
|
// of that copy each say the request they copy arrived at the
|
||||||
|
// entrypoint, in the event log and on their own pages, and never that
|
||||||
|
// they did.
|
||||||
|
func TestEventRequest_ResubmittedCopy(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
original := f.eventAt(t, ep, `{}`, time.Now().Add(-2*time.Minute))
|
||||||
|
copied := f.eventAt(t, ep, `{}`, time.Now().Add(-time.Minute))
|
||||||
|
copyOfCopy := f.eventAt(t, ep, `{}`, time.Now())
|
||||||
|
|
||||||
|
require.NoError(t, f.webhookDB.Model(copied).Update(
|
||||||
|
"resubmitted_from_id", original.ID,
|
||||||
|
).Error)
|
||||||
|
require.NoError(t, f.webhookDB.Model(copyOfCopy).Update(
|
||||||
|
"resubmitted_from_id", copied.ID,
|
||||||
|
).Error)
|
||||||
|
|
||||||
|
// The log lists the newest event first, and each event's Resubmit
|
||||||
|
// form comes before its entrypoint, so cutting the page at the
|
||||||
|
// copy's and the original's forms leaves each event's entrypoint
|
||||||
|
// in its own part.
|
||||||
|
copyOfCopyPart, rest, found := strings.Cut(
|
||||||
|
renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID),
|
||||||
|
"/events/"+copied.ID+"/resubmit",
|
||||||
|
)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
copyPart, originalPart, found := strings.Cut(
|
||||||
|
rest, "/events/"+original.ID+"/resubmit",
|
||||||
|
)
|
||||||
|
require.True(t, found)
|
||||||
|
|
||||||
|
for _, part := range []string{copyOfCopyPart, copyPart} {
|
||||||
|
assert.Contains(t, part, copiedRequestArrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, part, arrivedAt("Billing sender"))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Contains(t, originalPart, arrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, originalPart,
|
||||||
|
copiedRequestArrivedAt("Billing sender"))
|
||||||
|
|
||||||
|
for _, event := range []*database.Event{copied, copyOfCopy} {
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(),
|
||||||
|
copiedRequestArrivedAt("Billing sender"))
|
||||||
|
assert.NotContains(t, w.Body.String(), arrivedAt("Billing sender"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_HeadersOverTheLimit proves the event log leaves out
|
||||||
|
// request headers that hold more than it shows of a body, whether
|
||||||
|
// stored or as lines, and links to the event's own page, which shows
|
||||||
|
// them all.
|
||||||
|
func TestEventRequest_HeadersOverTheLimit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// The receiver stores each "<" as six bytes of JSON, so this
|
||||||
|
// header is over the limit stored but not as a line.
|
||||||
|
const lessThans = bodyCap/6 + 1
|
||||||
|
|
||||||
|
// A header sent many times is stored with its name once, and
|
||||||
|
// shown with it on every line.
|
||||||
|
repeatedName := "X-Repeated-" + strings.Repeat("r", 1000)
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
headers http.Header
|
||||||
|
line string
|
||||||
|
}{
|
||||||
|
"stored": {
|
||||||
|
headers: http.Header{"X-Long": {strings.Repeat("<", lessThans)}},
|
||||||
|
line: "X-Long: " + strings.Repeat("<", lessThans),
|
||||||
|
},
|
||||||
|
"as lines": {
|
||||||
|
headers: http.Header{repeatedName: slices.Repeat([]string{""}, 41)},
|
||||||
|
line: repeatedName + ": ",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
headersJSON, err := json.Marshal(tc.headers)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
assert.Contains(t, page, showHeadersLink(f.webhook.ID, event.ID))
|
||||||
|
assert.NotContains(t, page, tc.line)
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
|
||||||
|
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), tc.line)
|
||||||
|
assert.NotContains(t, w.Body.String(), "Show the request headers")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventRequest_ManyShortHeaderLines proves that for many short
|
||||||
|
// request header lines the event log writes no more than its limit,
|
||||||
|
// apart from escaping: lines that fill the limit show as one block of
|
||||||
|
// text, and one line more is left out with a link to the event's own
|
||||||
|
// page.
|
||||||
|
func TestEventRequest_ManyShortHeaderLines(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Each "A: " line and the newline after it hold four bytes, so
|
||||||
|
// this many lines fill the limit exactly. Each line in its own
|
||||||
|
// element would make the page many times the limit.
|
||||||
|
const fill = bodyCap / len("A: \n")
|
||||||
|
|
||||||
|
tests := map[string]struct {
|
||||||
|
lines int
|
||||||
|
shown bool
|
||||||
|
}{
|
||||||
|
"filling the limit": {lines: fill, shown: true},
|
||||||
|
"one over the limit": {lines: fill + 1, shown: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, tc := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
headersJSON, err := json.Marshal(http.Header{
|
||||||
|
"A": slices.Repeat([]string{""}, tc.lines),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
f := newRecentEventsFixture(t)
|
||||||
|
ep := f.entrypoint(t, "Billing sender")
|
||||||
|
event := f.eventAt(t, ep, string(headersJSON), time.Now())
|
||||||
|
|
||||||
|
page := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
|
||||||
|
box := headerBox(slices.Repeat([]string{"A: "}, tc.lines)...)
|
||||||
|
link := showHeadersLink(f.webhook.ID, event.ID)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.shown, strings.Contains(page, box))
|
||||||
|
assert.Equal(t, !tc.shown, strings.Contains(page, link))
|
||||||
|
assert.Less(t, len(page), 4*bodyCap)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -167,12 +167,12 @@ func New(
|
|||||||
),
|
),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate(
|
"source_logs.html": parsePageTemplate(
|
||||||
"source_logs.html", "event_body.html", "delivery_row.html",
|
"source_logs.html", "event_request.html", "event_body.html",
|
||||||
"delivery_attempts.html",
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"event_detail.html": parsePageTemplate(
|
"event_detail.html": parsePageTemplate(
|
||||||
"event_detail.html", "event_body.html", "delivery_row.html",
|
"event_detail.html", "event_request.html", "event_body.html",
|
||||||
"delivery_attempts.html",
|
"delivery_row.html", "delivery_attempts.html",
|
||||||
),
|
),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
"error.html": parsePageTemplate("error.html"),
|
"error.html": parsePageTemplate("error.html"),
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
|
|||||||
},
|
},
|
||||||
resubmitNoTargets: {
|
resubmitNoTargets: {
|
||||||
Text: "Resubmitted: a new event was created, but this " +
|
Text: "Resubmitted: a new event was created, but this " +
|
||||||
"source has no active targets, so nothing was queued.",
|
"webhook has no active targets, so nothing was queued.",
|
||||||
},
|
},
|
||||||
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
}[noticeCode(r.URL.Query().Get(noticeParam))]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
|
|||||||
data map[string]any
|
data map[string]any
|
||||||
title string
|
title string
|
||||||
}{
|
}{
|
||||||
{"login.html", map[string]any{}, "Login - Webhooker"},
|
{"login.html", map[string]any{}, "Sign in - Webhooker"},
|
||||||
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
{"profile.html", map[string]any{}, "Profile - Webhooker"},
|
||||||
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
{"settings.html", map[string]any{}, "Settings - Webhooker"},
|
||||||
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
|
||||||
|
|||||||
@@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
|
|||||||
assert.Contains(t, body, "1 configured")
|
assert.Contains(t, body, "1 configured")
|
||||||
assert.NotContains(t, body, "sekrit")
|
assert.NotContains(t, body, "sekrit")
|
||||||
|
|
||||||
assert.Contains(t, body, "Archive Expiry")
|
// The database type is called an archive: on its badge, in the
|
||||||
assert.Contains(t, body, "30 days")
|
// add target form's type list and in its settings.
|
||||||
|
list := targetList(t, body)
|
||||||
|
assert.Contains(t, list, "t-database archive Active")
|
||||||
|
assert.Contains(t, list, "Archive expiry: 30 days")
|
||||||
|
assert.Contains(t, list, "Archive rotation: none")
|
||||||
|
assert.Contains(t, body, `<option value="database">Archive</option>`)
|
||||||
|
|
||||||
// An unknown type gets the neutral placeholder, never the
|
// An unknown type gets the neutral placeholder, never the
|
||||||
// stored blob.
|
// stored blob.
|
||||||
|
|||||||
@@ -1268,13 +1268,17 @@ func (h *Handlers) loadEventsWithDeliveries(
|
|||||||
|
|
||||||
result, ok := h.eventLogViews(
|
result, ok := h.eventLogViews(
|
||||||
w, r, webhookDB, webhook.ID, rows, targetMap,
|
w, r, webhookDB, webhook.ID, rows, targetMap,
|
||||||
|
maxRenderedBodyBytes,
|
||||||
)
|
)
|
||||||
|
|
||||||
return result, totalEvents, ok
|
return result, totalEvents, ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// eventLogViews projects loaded events for rendering, each with
|
// eventLogViews projects loaded events for rendering, each with
|
||||||
// its deliveries and how many times it has been resubmitted. Like
|
// its deliveries, how many times it has been resubmitted and the
|
||||||
|
// entrypoint it arrived at (for a resubmitted copy, the one the
|
||||||
|
// request it copies arrived at), and with its request headers only
|
||||||
|
// when their text holds at most maxHeaderBytes. Like
|
||||||
// loadEventsWithDeliveries, it reports false once it has answered
|
// loadEventsWithDeliveries, it reports false once it has answered
|
||||||
// the request with an error.
|
// the request with an error.
|
||||||
func (h *Handlers) eventLogViews(
|
func (h *Handlers) eventLogViews(
|
||||||
@@ -1284,6 +1288,7 @@ func (h *Handlers) eventLogViews(
|
|||||||
webhookID string,
|
webhookID string,
|
||||||
rows []eventLogRow,
|
rows []eventLogRow,
|
||||||
targetMap map[string]eventLogTarget,
|
targetMap map[string]eventLogTarget,
|
||||||
|
maxHeaderBytes int,
|
||||||
) ([]EventLogView, bool) {
|
) ([]EventLogView, bool) {
|
||||||
result := make([]EventLogView, len(rows))
|
result := make([]EventLogView, len(rows))
|
||||||
eventDeliveries := make([][]database.Delivery, len(rows))
|
eventDeliveries := make([][]database.Delivery, len(rows))
|
||||||
@@ -1293,7 +1298,7 @@ func (h *Handlers) eventLogViews(
|
|||||||
eventIDs := make([]string, len(rows))
|
eventIDs := make([]string, len(rows))
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
result[i] = rows[i].view(webhookID)
|
result[i] = rows[i].view(webhookID, maxHeaderBytes)
|
||||||
eventIDs[i] = rows[i].ID
|
eventIDs[i] = rows[i].ID
|
||||||
|
|
||||||
webhookDB.Where(
|
webhookDB.Where(
|
||||||
@@ -1327,11 +1332,25 @@ func (h *Handlers) eventLogViews(
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entrypoints, err := h.entrypointNames(webhookID)
|
||||||
|
if err != nil {
|
||||||
|
h.serverError(w, r, "failed to load entrypoints", err)
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
for i := range rows {
|
for i := range rows {
|
||||||
result[i].Deliveries = h.newDeliveryViews(
|
result[i].Deliveries = h.newDeliveryViews(
|
||||||
eventDeliveries[i], targetMap, attempts,
|
eventDeliveries[i], targetMap, attempts,
|
||||||
)
|
)
|
||||||
result[i].ResubmitCount = resubmits[rows[i].ID]
|
result[i].ResubmitCount = resubmits[rows[i].ID]
|
||||||
|
|
||||||
|
name, ok := entrypoints[rows[i].EntrypointID]
|
||||||
|
if !ok {
|
||||||
|
name = "deleted entrypoint"
|
||||||
|
}
|
||||||
|
|
||||||
|
result[i].Entrypoint = name
|
||||||
}
|
}
|
||||||
|
|
||||||
return result, true
|
return result, true
|
||||||
@@ -1356,7 +1375,7 @@ func loadEventLogRows(
|
|||||||
var rows []eventLogRow
|
var rows []eventLogRow
|
||||||
|
|
||||||
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
|
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
|
||||||
eventLogColumns, maxRenderedBodyBytes,
|
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
|
||||||
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
|
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
|
||||||
|
|
||||||
return rows, totalEvents, err
|
return rows, totalEvents, err
|
||||||
@@ -1903,7 +1922,7 @@ func (h *Handlers) setTargetFromForm(
|
|||||||
// cannot destroy the count a target is delivering with.
|
// cannot destroy the count a target is delivering with.
|
||||||
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "Invalid max retries: " + retriesErrorMessage(err), nil
|
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
target.Name = in.Name
|
target.Name = in.Name
|
||||||
|
|||||||
@@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
|
|||||||
assert.Contains(t, page, "original-name")
|
assert.Contains(t, page, "original-name")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleTargetEdit_CallsTheDatabaseTypeArchive pins the names the
|
||||||
|
// edit page of a database target gives its type and its settings to
|
||||||
|
// the ones its badge and the add target form use.
|
||||||
|
func TestHandleTargetEdit_CallsTheDatabaseTypeArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := setupSourceTest(t)
|
||||||
|
webhook := seedWebhookWithRetention(t, env.db, 30)
|
||||||
|
target := seedTarget(t, env.db, webhook.ID, database.TargetTypeDatabase)
|
||||||
|
|
||||||
|
w := serveTarget(
|
||||||
|
env, http.MethodGet,
|
||||||
|
"/hook/"+webhook.ID+"/targets/"+target.ID+"/edit",
|
||||||
|
nil,
|
||||||
|
)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
page := w.Body.String()
|
||||||
|
|
||||||
|
assert.Contains(t, page, "Type: archive.")
|
||||||
|
assert.Contains(t, page, `class="label">Archive expiry</label>`)
|
||||||
|
assert.Contains(t, page, `class="label">Archive rotation</label>`)
|
||||||
|
}
|
||||||
|
|
||||||
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
// TestHandleTargetEditSubmit_Rejects covers every submission that
|
||||||
// must not reach storage.
|
// must not reach storage.
|
||||||
//
|
//
|
||||||
@@ -588,7 +612,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
|
|||||||
{
|
{
|
||||||
database.TargetTypeSlack,
|
database.TargetTypeSlack,
|
||||||
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
"name=edited&url=" + editOriginalURL + "&max_retries=25",
|
||||||
"Invalid max retries",
|
"Invalid delivery attempts",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
database.TargetTypeDatabase,
|
database.TargetTypeDatabase,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -106,7 +106,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")
|
||||||
@@ -116,7 +116,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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ var (
|
|||||||
// errRetriesInvalid signals a max_retries form value that is not
|
// errRetriesInvalid signals a max_retries form value that is not
|
||||||
// a non-negative whole number.
|
// a non-negative whole number.
|
||||||
errRetriesInvalid = errors.New(
|
errRetriesInvalid = errors.New(
|
||||||
"retries must be a whole number of attempts",
|
"must be a whole number",
|
||||||
)
|
)
|
||||||
|
|
||||||
// errRetriesTooLarge signals a max_retries form value that is a
|
// errRetriesTooLarge signals a max_retries form value that is a
|
||||||
// whole number but above maxTargetRetries. It is distinguished
|
// whole number but above maxTargetRetries. It is distinguished
|
||||||
// from errRetriesInvalid so the message can name the ceiling
|
// from errRetriesInvalid so the message can name the ceiling
|
||||||
// instead of implying the input was not a number.
|
// instead of implying the input was not a number.
|
||||||
errRetriesTooLarge = errors.New("retries out of range")
|
errRetriesTooLarge = errors.New("out of range")
|
||||||
)
|
)
|
||||||
|
|
||||||
// parseMaxRetries interprets a max_retries form value.
|
// parseMaxRetries interprets a max_retries form value.
|
||||||
@@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string {
|
|||||||
if errors.Is(err, errRetriesTooLarge) {
|
if errors.Is(err, errRetriesTooLarge) {
|
||||||
return errRetriesTooLarge.Error() +
|
return errRetriesTooLarge.Error() +
|
||||||
": at most " + strconv.Itoa(maxTargetRetries) +
|
": at most " + strconv.Itoa(maxTargetRetries) +
|
||||||
" retries"
|
" attempts"
|
||||||
}
|
}
|
||||||
|
|
||||||
return errRetriesInvalid.Error() +
|
return errRetriesInvalid.Error() +
|
||||||
|
|||||||
@@ -354,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
|
|||||||
// and target_http gives up once the attempt number reaches
|
// and target_http gives up once the attempt number reaches
|
||||||
// max_retries), and 0 is special-cased to a single fire-and-forget
|
// max_retries), and 0 is special-cased to a single fire-and-forget
|
||||||
// attempt with no circuit breaker.
|
// attempt with no circuit breaker.
|
||||||
const maxRetriesHelp = "This is the total number of delivery attempts, " +
|
const maxRetriesHelp = "How many times each delivery is attempted in " +
|
||||||
"not retries on top of the first: a value of 3 makes three attempts " +
|
"all, the first attempt included. 0 means a single attempt with no " +
|
||||||
"in all. 0 means a single attempt with no retries and no circuit " +
|
"retries and no circuit breaker."
|
||||||
"breaker."
|
|
||||||
|
|
||||||
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
|
||||||
// help text on both the create form (the add-target form on the webhook
|
// label, "Delivery attempts", and help text on both the create form
|
||||||
// detail page) and the edit form, so the copy cannot drift back to
|
// (the add-target form on the webhook detail page) and the edit form,
|
||||||
// calling the number a retry count.
|
// so the copy cannot drift back to calling the number a retry count.
|
||||||
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -393,6 +392,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, createBody, "Delivery attempts:</label>")
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, createBody, maxRetriesHelp,
|
t, createBody, maxRetriesHelp,
|
||||||
"the add-target form must explain max_retries as total attempts",
|
"the add-target form must explain max_retries as total attempts",
|
||||||
@@ -419,8 +419,33 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert.Contains(t, editBody, `class="label">Delivery attempts</label>`)
|
||||||
assert.Contains(
|
assert.Contains(
|
||||||
t, editBody, maxRetriesHelp,
|
t, editBody, maxRetriesHelp,
|
||||||
"the target edit form must explain max_retries as total attempts",
|
"the target edit form must explain max_retries as total attempts",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateFormCallsTheDatabaseTargetAnArchive pins the names the new
|
||||||
|
// webhook page gives the database target its Archive checkbox creates,
|
||||||
|
// and that target's settings, to the ones the target forms use.
|
||||||
|
func TestCreateFormCallsTheDatabaseTargetAnArchive(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestApp(t, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
body := renderPage(t, h, sess, "sources_new.html", map[string]any{
|
||||||
|
dataKeyError: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Contains(t, body, "created with an archive target")
|
||||||
|
assert.Contains(t, body, `class="label">Archive expiry</label>`)
|
||||||
|
assert.Contains(t, body, `class="label">Archive rotation</label>`)
|
||||||
|
}
|
||||||
|
|||||||
@@ -318,31 +318,35 @@ const (
|
|||||||
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
// Each target type, with the fields its add target form submits, in
|
// Each target type, with the badge its targets are listed with and
|
||||||
// page order. Only http and slack have a url field.
|
// the fields its add target form submits, in page order. Only http
|
||||||
|
// and slack have a url field.
|
||||||
targetTypes := []struct {
|
targetTypes := []struct {
|
||||||
name string
|
name string
|
||||||
|
badge string
|
||||||
fields string
|
fields string
|
||||||
values map[string]string
|
values map[string]string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"http", "csrf_token name type url headers timeout max_retries",
|
"http", "http",
|
||||||
|
"csrf_token name type url headers timeout max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"slack", "csrf_token name type url max_retries",
|
"slack", "slack", "csrf_token name type url max_retries",
|
||||||
map[string]string{"url": publicTargetURL},
|
map[string]string{"url": publicTargetURL},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"database", "csrf_token name type expiry rotation",
|
"database", "archive", "csrf_token name type expiry rotation",
|
||||||
map[string]string{"expiry": "720h", "rotation": "daily"},
|
map[string]string{"expiry": "720h", "rotation": "daily"},
|
||||||
},
|
},
|
||||||
{"log", "csrf_token name type", nil},
|
{"log", "log", "csrf_token name type", nil},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range targetTypes {
|
for _, tt := range targetTypes {
|
||||||
checkAddTarget(
|
checkAddTarget(
|
||||||
ctx, t, url, tt.name, strings.Fields(tt.fields), tt.values,
|
ctx, t, url, tt.name, tt.badge,
|
||||||
|
strings.Fields(tt.fields), tt.values,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -353,11 +357,11 @@ func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
|
|||||||
// type's own fields in place of the choice, and the form then submits
|
// type's own fields in place of the choice, and the form then submits
|
||||||
// exactly fields, so a field another type uses, such as url, is absent;
|
// exactly fields, so a field another type uses, such as url, is absent;
|
||||||
// Cancel closes it again. It then adds a target of the type, filling in
|
// Cancel closes it again. It then adds a target of the type, filling in
|
||||||
// values, and checks that the section lists it with that type.
|
// values, and checks that the section lists it with badge.
|
||||||
func checkAddTarget(
|
func checkAddTarget(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
t *testing.T,
|
t *testing.T,
|
||||||
url, targetType string,
|
url, targetType, badge string,
|
||||||
fields []string,
|
fields []string,
|
||||||
values map[string]string,
|
values map[string]string,
|
||||||
) {
|
) {
|
||||||
@@ -412,8 +416,8 @@ func checkAddTarget(
|
|||||||
|
|
||||||
click(ctx, t, saveButton)
|
click(ctx, t, saveButton)
|
||||||
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
assert.Truef(t, shown(ctx, `//span[text()="`+name+
|
||||||
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
|
`"]/following-sibling::div/span[text()="`+badge+`"]`),
|
||||||
"%s: the added target is not listed with its type", targetType)
|
"%s: the added target is not listed as %s", targetType, badge)
|
||||||
}
|
}
|
||||||
|
|
||||||
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
// chooseTargetType clicks Add, picks targetType and clicks Next, and
|
||||||
@@ -466,10 +470,10 @@ func checkArchiveChoices(ctx context.Context, t *testing.T, url string) {
|
|||||||
assert.Equal(t, "none", startRotation,
|
assert.Equal(t, "none", startRotation,
|
||||||
"the add target form's archive rotation does not start on none")
|
"the add target form's archive rotation does not start on none")
|
||||||
|
|
||||||
assert.True(t, shown(ctx, row+`//span[text()="Archive Expiry:"]`+
|
assert.True(t, shown(ctx, row+`//span[text()="Archive expiry:"]`+
|
||||||
`/following-sibling::span[text()="30 days"]`),
|
`/following-sibling::span[text()="30 days"]`),
|
||||||
"a database target added with 720h is not listed as 30 days")
|
"a database target added with 720h is not listed as 30 days")
|
||||||
assert.True(t, shown(ctx, row+`//span[text()="Archive Rotation:"]`+
|
assert.True(t, shown(ctx, row+`//span[text()="Archive rotation:"]`+
|
||||||
`/following-sibling::span[text()="daily"]`),
|
`/following-sibling::span[text()="daily"]`),
|
||||||
"a database target added with daily is not listed as daily")
|
"a database target added with daily is not listed as daily")
|
||||||
|
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ func TestEventResubmit_RefusedWithoutAValidCSRFToken(t *testing.T) {
|
|||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, env.post(path, csrfForm(token), cookies),
|
t, env.post(path, csrfForm(token), cookies),
|
||||||
logsPath, "resubmit-no-targets",
|
logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ func TestEventResubmit_AnotherWebhooksEvent404s(t *testing.T) {
|
|||||||
csrfForm(token), cookies,
|
csrfForm(token), cookies,
|
||||||
),
|
),
|
||||||
intrudersLogs, "resubmit-no-targets",
|
intrudersLogs, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -914,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
|
|||||||
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
env.requireNotice(t, w, "/pages/login", "signed-out", "Signed out.", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSignInAndSignOutWording pins one wording for both: the sign-in
|
||||||
|
// page's button reads "Sign in" and the navbar's buttons "Sign out", as
|
||||||
|
// the sign-in page's heading, the error page's link and the notice
|
||||||
|
// after signing out do.
|
||||||
|
func TestSignInAndSignOutWording(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, env.get("/pages/login", nil).Body.String(), ">Sign in</button>",
|
||||||
|
)
|
||||||
|
|
||||||
|
userID, _ := env.seedUser(t, "reader", "somepassword")
|
||||||
|
page := env.get("/hooks", env.authCookies(t, userID, "reader")).Body.String()
|
||||||
|
|
||||||
|
assert.Equal(t, 2, strings.Count(page, ">Sign out</button>"),
|
||||||
|
"the desktop and the mobile navbar each say Sign out")
|
||||||
|
}
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
// --- /user/{username} group ---
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
||||||
@@ -1363,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
|
|||||||
)
|
)
|
||||||
env.requireNotice(
|
env.requireNotice(
|
||||||
t, w, logsPath, "resubmit-no-targets",
|
t, w, logsPath, "resubmit-no-targets",
|
||||||
"this source has no active targets", cookies,
|
"this webhook has no active targets", cookies,
|
||||||
)
|
)
|
||||||
|
|
||||||
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
webhookDB, err := env.dbMgr.GetDB(wh.ID)
|
||||||
|
|||||||
@@ -50,6 +50,15 @@
|
|||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="card mt-6">
|
||||||
|
<div class="p-4 border-b border-gray-200">
|
||||||
|
<h2 class="text-lg font-medium text-gray-900">Request</h2>
|
||||||
|
</div>
|
||||||
|
<div class="p-4">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card mt-6">
|
<div class="card mt-6">
|
||||||
<div class="p-4 border-b border-gray-200">
|
<div class="p-4 border-b border-gray-200">
|
||||||
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
<h2 class="text-lg font-medium text-gray-900">Body</h2>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{define "event_request"}}
|
||||||
|
<!-- The entrypoint an event arrived at and its request headers, as
|
||||||
|
handlers.EventLogView carries them: the same in the event log and
|
||||||
|
the event's own page. The entrypoint's URL is never shown. A
|
||||||
|
resubmitted copy, even a copy of a copy, did not arrive at an
|
||||||
|
entrypoint; the request it copies did. -->
|
||||||
|
<div class="space-y-2 text-xs">
|
||||||
|
{{if .ResubmittedFrom}}
|
||||||
|
<p class="text-gray-500">The request it copies arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">Arrived at <span class="text-gray-900">{{.Entrypoint}}</span></p>
|
||||||
|
{{end}}
|
||||||
|
{{if .HeadersCut}}
|
||||||
|
<p class="text-gray-500">The request headers are larger than the event log shows. <a href="{{.Body.EventURL}}" class="btn-small">Show the request headers</a></p>
|
||||||
|
{{else if .Headers}}
|
||||||
|
<p class="text-gray-500">Request headers</p>
|
||||||
|
<pre class="rounded-md border border-gray-200 bg-white p-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Headers}}</pre>
|
||||||
|
{{else}}
|
||||||
|
<p class="text-gray-500">No request headers.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
{{define "title"}}Login - Webhooker{{end}}
|
{{define "title"}}Sign in - Webhooker{{end}}
|
||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
<div class="min-h-screen flex items-center justify-center py-12 px-4">
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="submit" class="btn-primary w-full py-3">Sign In</button>
|
<button type="submit" class="btn-primary w-full py-3">Sign in</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout" class="inline">
|
<form method="POST" action="/pages/logout" class="inline">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-secondary">Logout</button>
|
<button type="submit" class="btn-secondary">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
<button type="submit" class="btn-secondary w-full">Logout</button>
|
<button type="submit" class="btn-secondary w-full">Sign out</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -159,7 +159,7 @@
|
|||||||
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
|
||||||
<option value="http">HTTP</option>
|
<option value="http">HTTP</option>
|
||||||
<option value="slack">Slack</option>
|
<option value="slack">Slack</option>
|
||||||
<option value="database">Database</option>
|
<option value="database">Archive</option>
|
||||||
<option value="log">Log</option>
|
<option value="log">Log</option>
|
||||||
</select>
|
</select>
|
||||||
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
|
||||||
@@ -182,10 +182,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Delivery attempts:</label>
|
||||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -198,10 +198,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex gap-2 items-center">
|
<div class="flex gap-2 items-center">
|
||||||
<label class="text-sm text-gray-700">Max retries:</label>
|
<label class="text-sm text-gray-700">Delivery attempts:</label>
|
||||||
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
<input type="number" name="max_retries" :value="maxRetries" placeholder="0" min="0" max="20" class="input text-sm w-24">
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
|
||||||
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="badge-info">{{.Type}}</span>
|
<span class="badge-info">{{if eq .Type "database"}}archive{{else}}{{.Type}}{{end}}</span>
|
||||||
{{if .Active}}
|
{{if .Active}}
|
||||||
<span class="badge-success">Active</span>
|
<span class="badge-success">Active</span>
|
||||||
{{else}}
|
{{else}}
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
{{template "event_request" .}}
|
||||||
|
</div>
|
||||||
{{template "event_body" .Body}}
|
{{template "event_body" .Body}}
|
||||||
|
|
||||||
{{if .Deliveries}}
|
{{if .Deliveries}}
|
||||||
|
|||||||
@@ -46,9 +46,9 @@
|
|||||||
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
<input type="checkbox" name="archive" value="on"{{if .Form.Archive}} checked{{end}} autocomplete="off" @change="toggle" class="h-4 w-4">
|
||||||
Archive
|
Archive
|
||||||
</label>
|
</label>
|
||||||
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with a database target that keeps a copy of every event.</p>
|
<p class="text-xs text-gray-500 mt-1">When checked, the webhook is created with an archive target that keeps a copy of every event.</p>
|
||||||
<div x-show="open" x-cloak class="mt-3">
|
<div x-show="open" x-cloak class="mt-3">
|
||||||
<label for="archive_expiry" class="label">Archive pruning</label>
|
<label for="archive_expiry" class="label">Archive expiry</label>
|
||||||
<select id="archive_expiry" name="archive_expiry" class="input">
|
<select id="archive_expiry" name="archive_expiry" class="input">
|
||||||
{{range .ArchiveExpiryChoices}}
|
{{range .ArchiveExpiryChoices}}
|
||||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
<a href="/hook/{{.Webhook.ID}}" class="btn-small">← Back to {{.Webhook.Name}}</a>
|
||||||
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
<h1 class="text-2xl font-medium text-gray-900 mt-2">Edit Target</h1>
|
||||||
<p class="text-sm text-gray-500 mt-1">Type: {{.Target.Type}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
<p class="text-sm text-gray-500 mt-1">Type: {{if eq .Target.Type "database"}}archive{{else}}{{.Target.Type}}{{end}}. A target's type cannot be changed; create a new target to deliver a different way.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card p-6">
|
<div class="card p-6">
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
|
|
||||||
{{if eq .Target.Type "database"}}
|
{{if eq .Target.Type "database"}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="expiry" class="label">Archive Expiry</label>
|
<label for="expiry" class="label">Archive expiry</label>
|
||||||
<select id="expiry" name="expiry" class="input">
|
<select id="expiry" name="expiry" class="input">
|
||||||
{{range .ArchiveExpiryChoices}}
|
{{range .ArchiveExpiryChoices}}
|
||||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="rotation" class="label">Archive Rotation</label>
|
<label for="rotation" class="label">Archive rotation</label>
|
||||||
<select id="rotation" name="rotation" class="input">
|
<select id="rotation" name="rotation" class="input">
|
||||||
{{range .ArchiveRotationChoices}}
|
{{range .ArchiveRotationChoices}}
|
||||||
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
|
||||||
@@ -81,9 +81,9 @@
|
|||||||
|
|
||||||
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="max_retries" class="label">Max retries</label>
|
<label for="max_retries" class="label">Delivery attempts</label>
|
||||||
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
|
<input type="number" id="max_retries" name="max_retries" value="{{.TargetForm.MaxRetries}}" min="0" max="20" class="input">
|
||||||
<p class="text-xs text-gray-500 mt-1">This is the total number of delivery attempts, not retries on top of the first: a value of 3 makes three attempts in all. 0 means a single attempt with no retries and no circuit breaker.</p>
|
<p class="text-xs text-gray-500 mt-1">How many times each delivery is attempted in all, the first attempt included. 0 means a single attempt with no retries and no circuit breaker.</p>
|
||||||
</div>
|
</div>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user