Compare commits

3 Commits
Author SHA1 Message Date
clawbot 48ecfaab34 Event log: show only the events with a failed or a pending delivery (closes #390)
check / check (push) Successful in 3m19s
The event log gains three links above the list: All, Failed (N) and
Pending (N), carried in a `show` query parameter; any other value shows
every event. Failed lists the events with a failed delivery, Pending
those with one pending or retrying, each once, under the full list's
50-row limit and order. A filtered list finds the matching deliveries
through `idx_deliveries_status`, looks up their events by ID and sorts
them, and reads the rows of only the events shown; its counts read the
deliveries alone. The line beside the heading says what a filter
counts, and Replay returns to the list it was pressed in. The README
and the comments naming the query parameters the service reads include
`show`.

Model: opus-5-5
2026-10-03 03:24:49 +00:00
clawbot 9079a3219d Show an event's entrypoint and request headers in the event log and on its page (closes #389)
check / check (push) Successful in 3m26s
The receiver stored each event's request headers and entrypoint, but no page showed them, so with several entrypoints the operator could not tell which sender sent an event. An expanded event in the event log, and the event's own page, now show the entrypoint by its description ("Entrypoint" without one, "deleted entrypoint" once deleted), never its URL, and the headers as one escaped block, sorted, whitespace kept. A resubmitted copy says the request it copies arrived there. The event log reads at most 32 KiB of headers per event, the body's limit, and links to the event's page beyond it. Both pages share one template, `event_request.html`.

Model: opus-5-5
2026-10-03 05:22:14 +02:00
clawbot b9ec91c0f7 Use one name for each thing the UI shows (closes #399)
check / check (push) Successful in 3m21s
The UI gave one thing several names. The `database` type is now Archive in the type list, on its badge and on the edit page, and its settings read "Archive expiry" and "Archive rotation" everywhere. The retry field is "Delivery attempts", with help text, errors and the target list saying it counts every attempt; a stored 0 shows as one attempt. The target list uses one capitalisation. The navbar says "Sign out", the sign-in page "Sign in", and the resubmit notice "webhook" instead of "source". The README follows. Stored values, their meaning, routes and form field names are unchanged.

Model: opus-5-5
2026-10-03 05:07:41 +02:00
38 changed files with 1175 additions and 160 deletions
+28 -24
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)),
and the proxy reaches it over loopback. A default that bound every
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
another host is therefore something you configure, not something you
get by default.
@@ -835,7 +835,7 @@ reports.
`-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
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
that.
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
optional HTTP target URL creates an `http` target named `HTTP`, and the
archive checkbox creates a `database` target named `Archive` whose
`expiry` is the pruning chosen beside it (never, 1h, 12h, 24h, 30d, 90d
or 365d) and whose `rotation` is the rotation chosen below that (none,
monthly, daily or hourly). Both are validated as on the add target form,
and the webhook and its targets are created together or not at all.
`expiry` is the archive expiry chosen beside it (never, 1h, 12h, 24h,
30d, 90d or 365d) and whose `rotation` is the rotation chosen below that
(none, monthly, daily or hourly). Both are validated as on the add
target form, and the webhook and its targets are created together or
not at all.
| Field | Type | Description |
| ---------------- | ------- | ----------- |
@@ -1707,7 +1708,7 @@ events should be forwarded.
| `type` | TargetType | One of: `http`, `slack`, `database`, `log` |
| `active` | boolean | Whether deliveries are enabled (default: true) |
| `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.
@@ -1736,7 +1737,9 @@ events should be forwarded.
expiry in plain units, such as "30 days", and the rotation. No external
delivery and no retries; an archive write failure fails the delivery.
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
for debugging.
@@ -1986,7 +1989,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
| Table | Columns | Serves |
| ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, the webhook page's statistics and target list and the webhook list, which count each target's deliveries by status and when they finished, and the event log, which lists and counts the events with a failed delivery or one pending or retrying |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
@@ -2588,9 +2591,10 @@ 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
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
lost: the only query parameters this service reads are the login page's
`next`, the page to return to, and `notice`, which names the line a page
shows after an action.
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
shows after an action, and the event log's `show`, which picks the events
it lists.
Client-supplied request content does not leave the host by the other
route either. The Sentry SDK attaches the request to every event it
@@ -2743,9 +2747,9 @@ wider than it:
| `... rate limit exceeded` (429) | `WARN` | path | yes, on the receiver |
| `auth middleware: unauthenticated request` | `DEBUG` | path, method | yes, by definition |
| `entrypoint not found` | `DEBUG` | entrypoint UUID | yes, on the receiver |
| `user not found` / `invalid password` | `DEBUG` | username | yes, on the login form |
| `login failure limit exceeded` (429) | `WARN` | path | yes, on the login form |
| `password verification capacity exhausted` | `WARN` | path | 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 sign-in 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
to diagnose a flood must not thereby hand the flood an unbounded write,
@@ -2793,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
this service's lookups miss by design on unauthenticated routes: the
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
`gorm.Open` in the service now installs the adapter in
`internal/gormlog` instead. It writes through the same `slog` logger as
@@ -3081,14 +3085,14 @@ abuse limit later; they are tracked as future work.
| 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 `/` |
| `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/logout` | Logout (destroys session) |
| `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` | 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` | Sign out (destroys session) |
#### Authenticated Endpoints
A logged-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
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 signing in
returns to the page that was asked for.
| Method | Path | Description |
@@ -3103,8 +3107,8 @@ returns to the page that was asked for.
| `GET` | `/hook/{id}/edit` | Edit webhook form |
| `POST` | `/hook/{id}/edit` | Edit webhook submission |
| `POST` | `/hook/{id}/delete` | Delete webhook |
| `GET` | `/hook/{id}/events` | Full Event Log |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
| `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, 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 |
| `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`) |
@@ -3437,7 +3441,7 @@ check, see [The login endpoint](#the-login-endpoint).
still evaluated, so roughly 27 guesses a second get through and the
admin password has to carry that load (see
[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):
sliding-window rate limiter, 5 POST attempts per minute per bucket.
It runs behind session auth, so only a client already holding a
@@ -149,6 +149,62 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
}
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the newest events with a delivery in
// given statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers). The lists must also reach
// the events table only by ID: from the matching deliveries, then from
// the newest of those events.
func TestEventLogFiltersUseTheStatusIndex(t *testing.T) {
t.Parallel()
mgr, lc := setupTestWebhookDBManager(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
webhookID := uuid.New().String()
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
rows []struct{ ID string }
count int64
)
matching := dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending)
newest := dry.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(50).
Select("events.id AS event_id")
// Each step of the plan is printed in braces, so these name the
// lookup that follows each scan.
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id").
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
// page's statistics (readEventStats in the handlers): deliveries in
// progress, each target's deliveries finished since a time, which must
+8 -7
View File
@@ -177,16 +177,17 @@ func httpConfigFields(t *database.Target) []ConfigField {
}
// 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 {
retries := strconv.Itoa(t.MaxRetries)
attempts := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{
Label: "Max Retries",
Value: retries,
Label: "Delivery attempts",
Value: attempts,
}
}
@@ -213,10 +214,10 @@ func databaseConfigFields(configJSON string) []ConfigField {
}
return []ConfigField{{
Label: "Archive Expiry",
Label: "Archive expiry",
Value: value,
}, {
Label: "Archive Rotation",
Label: "Archive rotation",
Value: rotation,
}}
}
+6 -6
View File
@@ -32,7 +32,7 @@ const (
viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)"
viewExpiryNever = "never"
viewMaxRetries = "Max Retries"
viewMaxRetries = "Delivery attempts"
)
func TestMaskedWebhookURL(t *testing.T) {
@@ -190,7 +190,7 @@ func TestNewTargetViews_Slack(t *testing.T) {
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "0 (fire-and-forget)",
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
},
fieldMap(view.Config),
)
@@ -258,7 +258,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t,
map[string]string{
"Destination URL": viewMaskedOrigin,
viewMaxRetries: "0 (fire-and-forget)",
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
},
fieldMap(view.Config),
)
@@ -329,8 +329,8 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal(
t,
map[string]string{
"Archive Expiry": tc.want,
"Archive Rotation": rotationNone,
"Archive expiry": tc.want,
"Archive rotation": rotationNone,
},
fieldMap(view.Config),
)
@@ -365,7 +365,7 @@ func TestNewTargetViews_DatabaseRotation(t *testing.T) {
})
assert.Equal(
t, want, fieldMap(view.Config)["Archive Rotation"],
t, want, fieldMap(view.Config)["Archive rotation"],
)
})
}
+1 -1
View File
@@ -45,7 +45,7 @@ func expiryShown(
require.Equal(t, http.StatusOK, w.Code)
return matched(
`Archive Expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
`Archive expiry:</span>\s*<span>([^<]*)</span>`, w.Body.String(),
)
}
+2 -2
View File
@@ -27,7 +27,7 @@ func rotationShown(
t.Helper()
return matched(
`Archive Rotation:</span>\s*<span>([^<]*)</span>`,
`Archive rotation:</span>\s*<span>([^<]*)</span>`,
renderedPage(t, env, webhookID),
)
}
@@ -211,7 +211,7 @@ func TestArchiveFileView_Rotated(t *testing.T) {
assert.Equal(t, "2.0 kB", view.Size)
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.
+1 -1
View File
@@ -268,7 +268,7 @@ func (h *Handlers) rejectLogin(
)))
h.renderLoginError(
w, r,
"Too many failed login attempts. Please try again later.",
"Too many failed sign-in attempts. Please try again later.",
http.StatusTooManyRequests,
)
}
+14 -4
View File
@@ -329,15 +329,25 @@ func replayBody(body string) *string {
}
// redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice.
// log it was triggered from, carrying the outcome as its notice. A
// Replay form carries the list it was pressed in as show, so a replay
// returns to the Failed or Pending list; a Resubmit form carries none,
// so a resubmit returns to the full log, where its new event is the
// newest.
func redirectToEventLog(
w http.ResponseWriter,
r *http.Request,
webhook database.Webhook,
code noticeCode,
) {
http.Redirect(
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
http.StatusSeeOther,
location := withNotice("/hook/"+webhook.ID+"/events", code)
show := r.PostFormValue(showParam)
if eventLogStatuses(show) != nil {
location += "&" + showParam + "=" + show
}
http.Redirect( //nolint:gosec // show is checked by eventLogStatuses
w, r, location, http.StatusSeeOther,
)
}
+61
View File
@@ -1,6 +1,7 @@
package handlers_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
@@ -471,6 +472,66 @@ func TestHandleDeliveryReplay_RefusesWhileEarlierReplayInFlight(
)
}
// TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn proves a
// Replay pressed in the Failed list carries that list in its form and
// returns to it, and that a show value the event log does not know
// returns to the full log.
func TestHandleDeliveryReplay_ReturnsToTheListItWasPressedIn(
t *testing.T,
) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
tgt := seedConfiguredTarget(
t, db, wh.ID, database.TargetTypeHTTP,
`{"url":"`+replayTargetURL+`"}`,
)
_, original := seedFailedDelivery(t, dbMgr, wh.ID, tgt.ID)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, h, sess, wh.ID, "?show=failed",
), `name="show" value="failed"`)
// The second replay is refused, as the first is still queued.
for _, tc := range []struct{ show, location string }{
{"failed", "/hook/" + wh.ID +
"/events?notice=replay-queued&show=failed"},
{"made-up", "/hook/" + wh.ID + "/events?notice=replay-in-flight"},
} {
req := postRequest(
"/hook/"+wh.ID+"/deliveries/"+original.ID+"/replay",
authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
),
map[string]string{
paramSourceID: wh.ID,
paramDeliveryID: original.ID,
},
)
req.Body = io.NopCloser(strings.NewReader("show=" + tc.show))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
h.HandleDeliveryReplay().ServeHTTP(w, req)
require.Equal(t, http.StatusSeeOther, w.Code, tc.show)
assert.Equal(t, tc.location, w.Header().Get("Location"), tc.show)
}
}
// TestHandleSourceLogs_RendersReplayControlAndBanner proves the action
// reaches the page it belongs on: a finished delivery renders a POST
// form carrying a CSRF token, and the outcome code a refusal redirects
+3 -1
View File
@@ -1,6 +1,7 @@
package handlers
import (
"math"
"net/http"
"github.com/go-chi/chi"
@@ -59,8 +60,9 @@ func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return
}
// The page shows every request header.
views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets,
w, r, webhookDB, webhook.ID, rows, targets, math.MaxInt,
)
if !ok {
return
+115 -9
View File
@@ -1,10 +1,15 @@
package handlers
import (
"encoding/json"
"net/http"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/database"
)
// 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 whatever the payload's encoding. Cutting in SQLite
// 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, " +
"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, " +
"length(cast(body as blob)) AS body_bytes"
// 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, " +
"resubmitted_from_id, " +
"resubmitted_from_id, entrypoint_id, headers, " +
"length(cast(headers as blob)) AS headers_bytes, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
@@ -40,6 +49,22 @@ type EventLogView struct {
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
// from, empty for an event that arrived on the receiver.
ResubmittedFromID string
@@ -60,27 +85,35 @@ func (v EventLogView) ResubmittedFrom() bool {
}
// eventLogRow is one row of the event log projection, or of
// eventColumns. In the event log its body column arrives
// already cut to the cap by SQLite, with the true size beside
// it.
// eventColumns. In the event log its headers and body columns
// arrive already cut to the cap by SQLite, each with its true
// size beside it.
type eventLogRow struct {
ID string
CreatedAt time.Time
Method string
ContentType string
ResubmittedFromID *string
EntrypointID string
Headers string
HeadersBytes int64
Body []byte
BodyBytes int64
}
// view projects a loaded row of the webhook's events for
// rendering.
func (r *eventLogRow) view(webhookID string) EventLogView {
// rendering. It shows the request headers when the row holds them
// whole and their text holds at most maxHeaderBytes.
func (r *eventLogRow) view(
webhookID string, maxHeaderBytes int,
) EventLogView {
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
headers, fit := requestHeaderLines(r.Headers, maxHeaderBytes)
return EventLogView{
ID: r.ID,
Method: r.Method,
@@ -90,10 +123,83 @@ func (r *eventLogRow) view(webhookID string) EventLogView {
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
Headers: strings.Join(headers, "\n"),
HeadersCut: !fit || r.HeadersBytes > int64(len(r.Headers)),
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
// byte-wise cut left incomplete, so a multi-byte rune severed
// at the cap does not surface as a mojibake tail.
+332
View File
@@ -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: &lt;b&gt;hi&lt;/b&gt;",
"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("&lt;", 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)
})
}
}
+1 -1
View File
@@ -75,7 +75,7 @@ func (s *Handlers) LoadEventLogViewsForTest(
webhook database.Webhook,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil,
w, newRequestForTest(), webhook, nil, nil,
)
return views
+4 -4
View File
@@ -167,12 +167,12 @@ func New(
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_row.html",
"delivery_attempts.html",
"source_logs.html", "event_request.html", "event_body.html",
"delivery_row.html", "delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_row.html",
"delivery_attempts.html",
"event_detail.html", "event_request.html", "event_body.html",
"delivery_row.html", "delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
+1 -1
View File
@@ -96,7 +96,7 @@ func noticeFor(r *http.Request) *notice {
},
resubmitNoTargets: {
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))]
if !ok {
+1 -1
View File
@@ -40,7 +40,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
data map[string]any
title string
}{
{"login.html", map[string]any{}, "Login - Webhooker"},
{"login.html", map[string]any{}, "Sign in - Webhooker"},
{"profile.html", map[string]any{}, "Profile - Webhooker"},
{"settings.html", map[string]any{}, "Settings - Webhooker"},
{"sources_list.html", map[string]any{}, "Webhooks - Webhooker"},
+7 -2
View File
@@ -233,8 +233,13 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "1 configured")
assert.NotContains(t, body, "sekrit")
assert.Contains(t, body, "Archive Expiry")
assert.Contains(t, body, "30 days")
// The database type is called an archive: on its badge, in the
// 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
// stored blob.
+147
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"time"
@@ -186,6 +187,152 @@ func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
assert.Contains(t, body, "50 most recent of 51 events")
}
// TestHandleSourceLogs_ShowsEventsByDeliveryStatus proves that the
// Failed list holds exactly the events with a failed delivery, the
// Pending list exactly those with a delivery pending or retrying, each
// once, and any other show value every event; that each link, and the
// line beside the heading, counts the events its list holds; and that
// the shown link is marked.
func TestHandleSourceLogs_ShowsEventsByDeliveryStatus(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
now := time.Now()
const (
failed = database.DeliveryStatusFailed
delivered = database.DeliveryStatusDelivered
pending = database.DeliveryStatusPending
retrying = database.DeliveryStatusRetrying
)
// Each event is named by its content type. The first failed and
// was then replayed and delivered. The second failed, and so did
// its replay, and the fifth has one delivery pending and another
// retrying: each must still be listed and counted once.
events := []struct {
contentType string
deliveries []database.DeliveryStatus
}{
{"application/x-failed", []database.DeliveryStatus{failed, delivered}},
{"application/x-failed-twice", []database.DeliveryStatus{failed, failed}},
{"application/x-pending", []database.DeliveryStatus{pending}},
{"application/x-retrying", []database.DeliveryStatus{retrying}},
{"application/x-pending-retrying", []database.DeliveryStatus{pending, retrying}},
{"application/x-delivered", []database.DeliveryStatus{delivered}},
{"application/x-no-delivery", nil},
}
all := make([]string, len(events))
for i, e := range events {
event := f.event(
t, e.contentType, "{}", now.Add(time.Duration(i)*time.Second),
)
for _, status := range e.deliveries {
f.delivery(t, event, target.ID, status)
}
all[i] = e.contentType
}
for _, tc := range []struct {
query string
current string
heading string
listed []string
}{
{"", "All", "7 total events", all},
{"?show=failed", "Failed (2)", "2 events with a failed delivery",
[]string{"application/x-failed", "application/x-failed-twice"}},
{"?show=pending", "Pending (3)",
"3 events with a delivery pending or retrying", []string{
"application/x-pending", "application/x-retrying",
"application/x-pending-retrying",
}},
{"?show=unknown", "All", "7 total events", all},
} {
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, tc.query,
)
// One row per listed event, so with each listed event shown
// no event is listed twice.
assert.Equal(t, len(tc.listed),
strings.Count(body, `role="button"`), tc.query)
for _, contentType := range all {
assert.Equal(t,
slices.Contains(tc.listed, contentType),
strings.Contains(body, ">"+contentType+"<"),
tc.query+" "+contentType)
}
assert.Contains(t, body, ">"+tc.heading+"<", tc.query)
assert.Contains(t, body, "Failed (2)", tc.query)
assert.Contains(t, body, "Pending (3)", tc.query)
assert.Equal(t, 1, strings.Count(body, "aria-current"), tc.query)
assert.Contains(t, body,
`aria-current="page">`+tc.current+"</a>", tc.query)
}
}
// TestHandleSourceLogs_FilteredListShowsFiftyNewest proves a filtered
// list holds the 50 newest matching events, as the full log does,
// while its link and heading count every matching event.
func TestHandleSourceLogs_FilteredListShowsFiftyNewest(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
base := time.Now().Add(-time.Hour)
for i := range 51 {
event := f.event(
t, fmt.Sprintf("application/x-failed-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
f.delivery(t, event, target.ID, database.DeliveryStatusFailed)
}
// The newest event has no failed delivery.
f.event(t, "application/x-no-delivery", "{}", time.Now())
body := renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-failed-00")
assert.NotContains(t, body, "application/x-no-delivery")
assert.Contains(t, body, "Failed (51)")
assert.Contains(t, body,
"50 most recent of 51 events with a failed delivery")
}
// TestHandleSourceLogs_EmptyFilteredList proves an empty filtered list
// says that no event matches rather than that none was recorded.
func TestHandleSourceLogs_EmptyFilteredList(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeLog)
f.delivery(
t, f.event(t, contentTypeJSON, "{}", time.Now()),
target.ID, database.DeliveryStatusDelivered,
)
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=failed",
), "No event has a failed delivery.")
assert.Contains(t, renderSourceLogsPageWithQuery(
t, f.h, f.sess, f.webhook.ID, "?show=pending",
), "No event has a delivery pending or retrying.")
}
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
// events in the log only the newest starts expanded.
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
+198 -25
View File
@@ -1110,6 +1110,15 @@ func (h *Handlers) ownedWebhook(
return webhook, true
}
// The event log's show query parameter and its two values: the events
// with a failed delivery, and those with a delivery still pending or
// retrying.
const (
showParam = "show"
showFailed = "failed"
showPending = "pending"
)
// HandleSourceLogs shows the request/response logs for a
// webhook.
func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
@@ -1129,17 +1138,36 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return
}
// Any other value of show lists every event, as no value
// does.
show := r.URL.Query().Get(showParam)
statuses := eventLogStatuses(show)
if statuses == nil {
show = ""
}
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets,
w, r, webhook, targets, statuses,
)
if !ok {
return
}
failed, pending, err := h.countFailedAndPendingEvents(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to count events", err)
return
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
tmplKeyWebhook: &webhook,
"Events": evts,
"TotalEvents": total,
"Show": show,
"FailedEvents": failed,
"PendingEvents": pending,
}
h.renderTemplate(w, r, "source_logs.html", data)
@@ -1201,9 +1229,10 @@ func (h *Handlers) loadTargetMap(
// loadEventsWithDeliveries loads the recentEventLimit newest events
// and their deliveries from the per-webhook database, and the total
// number of events stored. Events come back as capped projections
// rather than database.Event rows: see eventLogColumns for why the
// cut happens in SQL.
// number of events stored. Given delivery statuses, both cover only
// the events with a delivery in one of them. Events come back as
// capped projections rather than database.Event rows: see
// eventLogColumns for why the cut happens in SQL.
//
// The bool reports whether the load succeeded. It is false
// once this has answered the request with an error, and the
@@ -1213,6 +1242,7 @@ func (h *Handlers) loadEventsWithDeliveries(
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
statuses []database.DeliveryStatus,
) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true
@@ -1227,17 +1257,28 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false
}
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
rows, totalEvents, err := loadEventLogRows(
webhookDB, webhook.ID, statuses,
)
if err != nil {
h.serverError(w, r, "failed to load events", err)
return nil, 0, false
}
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
maxRenderedBodyBytes,
)
return result, totalEvents, ok
}
// 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
// the request with an error.
func (h *Handlers) eventLogViews(
@@ -1247,6 +1288,7 @@ func (h *Handlers) eventLogViews(
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
maxHeaderBytes int,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
@@ -1256,7 +1298,7 @@ func (h *Handlers) eventLogViews(
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view(webhookID)
result[i] = rows[i].view(webhookID, maxHeaderBytes)
eventIDs[i] = rows[i].ID
webhookDB.Where(
@@ -1290,11 +1332,25 @@ func (h *Handlers) eventLogViews(
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 {
result[i].Deliveries = h.newDeliveryViews(
eventDeliveries[i], targetMap, attempts,
)
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
@@ -1302,25 +1358,142 @@ func (h *Handlers) eventLogViews(
// loadEventLogRows reads the event log projection of the
// recentEventLimit newest events, newest first, and the total number
// of events stored.
// of events stored, both narrowed by statuses as eventsWithStatus
// narrows them.
func loadEventLogRows(
webhookDB *gorm.DB, webhookID string,
) ([]eventLogRow, int64) {
var totalEvents int64
webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&totalEvents)
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) ([]eventLogRow, int64, error) {
totalEvents, err := countEventsWithStatus(
webhookDB, webhookID, statuses,
)
if err != nil {
return nil, 0, err
}
var rows []eventLogRow
webhookDB.Model(&database.Event{}).Select(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhookID,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
err = eventsWithStatus(webhookDB, webhookID, statuses).Select(
eventLogColumns, maxRenderedBodyBytes, maxRenderedBodyBytes,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows).Error
return rows, totalEvents
return rows, totalEvents, err
}
// eventLogStatuses returns the delivery statuses the event log's show
// value lists events by, or nil for one that lists every event.
func eventLogStatuses(show string) []database.DeliveryStatus {
switch show {
case showFailed:
return []database.DeliveryStatus{database.DeliveryStatusFailed}
case showPending:
return []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
default:
return nil
}
}
// eventsWithStatus selects the webhook's events, or, given statuses,
// the recentEventLimit newest of those with at least one delivery in
// one of them.
//
// Given statuses, its cost follows the matching deliveries. SQLite
// never reorders a CROSS JOIN, so it reads each join's left side
// first: the distinct event IDs of the matching deliveries, through
// idx_deliveries_status; then each of those events by ID, sorted to
// keep the newest; then the rows of only the events kept, so no other
// event's body is read. With a plain "id IN (matching deliveries)"
// condition instead, SQLite, which keeps no statistics on these
// tables, walks every event newest first.
func eventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) *gorm.DB {
if statuses == nil {
return webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
)
}
matching := webhookDB.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", statuses)
newest := webhookDB.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(recentEventLimit).
Select("events.id AS event_id")
return webhookDB.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id")
}
// countEventsWithStatus counts the webhook's events with at least one
// delivery in one of the statuses, or every event when statuses is
// nil. Given statuses, it counts the distinct events of the matching
// deliveries and reads nothing but those deliveries, through
// idx_deliveries_status, where counting the events would read every
// event row. That is the same number, because retention deletes an
// event's deliveries with it.
func countEventsWithStatus(
webhookDB *gorm.DB,
webhookID string,
statuses []database.DeliveryStatus,
) (int64, error) {
var count int64
if statuses == nil {
err := webhookDB.Model(&database.Event{}).Where(
"webhook_id = ?", webhookID,
).Count(&count).Error
return count, err
}
err := webhookDB.Model(&database.Delivery{}).Distinct("event_id").
Where("status IN ?", statuses).Count(&count).Error
return count, err
}
// countFailedAndPendingEvents returns how many of the webhook's events
// the event log lists when it shows only those with a failed delivery,
// and when it shows only those with a delivery pending or retrying.
func (h *Handlers) countFailedAndPendingEvents(
webhookID string,
) (int64, int64, error) {
if !h.dbMgr.DBExists(webhookID) {
return 0, 0, nil
}
webhookDB, err := h.dbMgr.GetDB(webhookID)
if err != nil {
return 0, 0, err
}
failed, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showFailed),
)
if err != nil {
return 0, 0, err
}
pending, err := countEventsWithStatus(
webhookDB, webhookID, eventLogStatuses(showPending),
)
if err != nil {
return 0, 0, err
}
return failed, pending, nil
}
// resubmitCounts reports, for each of the page's events, how many
@@ -1749,7 +1922,7 @@ func (h *Handlers) setTargetFromForm(
// cannot destroy the count a target is delivering with.
maxRetries, err := parseMaxRetries(in.MaxRetries, target.MaxRetries)
if err != nil {
return "Invalid max retries: " + retriesErrorMessage(err), nil
return "Invalid delivery attempts: " + retriesErrorMessage(err), nil
}
target.Name = in.Name
+25 -1
View File
@@ -418,6 +418,30 @@ func TestHandleTargetEdit_PrefillsTheStoredValuesUnmasked(
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
// must not reach storage.
//
@@ -588,7 +612,7 @@ func TestHandleTargetEditSubmit_RefusedFormComesBack(t *testing.T) {
{
database.TargetTypeSlack,
"name=edited&url=" + editOriginalURL + "&max_retries=25",
"Invalid max retries",
"Invalid delivery attempts",
},
{
database.TargetTypeDatabase,
+4 -4
View File
@@ -44,10 +44,10 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
path := delivery.ArchivePath(dbMgr, wh, archive)
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, "not created yet")
assert.NotContains(t, body, "Archive Size:")
assert.NotContains(t, body, "Archive size:")
seedArchive(t, path, 1, 100)
@@ -58,7 +58,7 @@ func TestHandleSourceDetail_ShowsArchiveFile(t *testing.T) {
assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet")
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,
`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)
assert.Contains(t, body, filepath.Base(path))
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
+6 -5
View File
@@ -81,17 +81,18 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list := targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
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 "+
"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)
assert.Equal(t, 2, strings.Count(log, "t-http: waiting"))
assert.Contains(t, log, "t-http: delivered")
assert.Regexp(t, waiting+cooldownEnds, log)
assert.Contains(t, log, waiting+backoffEnds)
assert.NotContains(t, log, "retrying")
// No delivery shows as retrying; the Pending link's title says it.
assert.NotRegexp(t, `t-http: retrying|>retrying<`, log)
page := eventPage(t, h, sess, wh.ID, retrying.ID)
assert.Regexp(t, waiting+cooldownEnds, page)
@@ -105,7 +106,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
list = targetList(t, renderSourceDetailPage(t, h, sess, wh.ID))
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")
// Not the whole list: the add target form above the rows says UTC.
assert.NotContains(t, targetRow(list, "t-http", "t-log"), "UTC")
@@ -115,7 +116,7 @@ func TestPausedTarget_ShownUntilBreakerCloses(t *testing.T) {
breakers.Set(target.ID, delivery.CircuitClosed, 0)
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)
}
+3 -3
View File
@@ -25,14 +25,14 @@ var (
// errRetriesInvalid signals a max_retries form value that is not
// a non-negative whole number.
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
// whole number but above maxTargetRetries. It is distinguished
// from errRetriesInvalid so the message can name the ceiling
// 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.
@@ -82,7 +82,7 @@ func retriesErrorMessage(err error) string {
if errors.Is(err, errRetriesTooLarge) {
return errRetriesTooLarge.Error() +
": at most " + strconv.Itoa(maxTargetRetries) +
" retries"
" attempts"
}
return errRetriesInvalid.Error() +
+32 -7
View File
@@ -354,15 +354,14 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
// and target_http gives up once the attempt number reaches
// max_retries), and 0 is special-cased to a single fire-and-forget
// attempt with no circuit breaker.
const maxRetriesHelp = "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."
const maxRetriesHelp = "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."
// TestTargetFormMaxRetriesCopyMatchesBehaviour pins the max_retries
// help text on both the create form (the add-target form on the webhook
// detail page) and the edit form, so the copy cannot drift back to
// calling the number a retry count.
// label, "Delivery attempts", and help text on both the create form
// (the add-target form on the webhook detail page) and the edit form,
// so the copy cannot drift back to calling the number a retry count.
func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
t.Parallel()
@@ -393,6 +392,7 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
},
)
assert.Contains(t, createBody, "Delivery attempts:</label>")
assert.Contains(
t, createBody, maxRetriesHelp,
"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(
t, editBody, maxRetriesHelp,
"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>`)
}
+3 -2
View File
@@ -279,8 +279,9 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// this branch as bounded as the pattern branches below.
//
// Nothing debuggable is lost. The only query parameters the service
// reads are the login page's `next`, the page to return to, and
// `notice`, which names the line a page shows after an action. The
// reads are the login page's `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 it lists. The
// alternatives that would preserve more (a key count, a key
// allowlist) all require parsing an attacker-sized query on every
// request, which is work an unauthenticated client would then be
+23 -19
View File
@@ -318,31 +318,35 @@ const (
func checkAddEachTargetType(ctx context.Context, t *testing.T, url string) {
t.Helper()
// Each target type, with the fields its add target form submits, in
// page order. Only http and slack have a url field.
// Each target type, with the badge its targets are listed with and
// the fields its add target form submits, in page order. Only http
// and slack have a url field.
targetTypes := []struct {
name string
badge string
fields 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},
},
{
"slack", "csrf_token name type url max_retries",
"slack", "slack", "csrf_token name type url max_retries",
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"},
},
{"log", "csrf_token name type", nil},
{"log", "log", "csrf_token name type", nil},
}
for _, tt := range targetTypes {
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
// 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
// values, and checks that the section lists it with that type.
// values, and checks that the section lists it with badge.
func checkAddTarget(
ctx context.Context,
t *testing.T,
url, targetType string,
url, targetType, badge string,
fields []string,
values map[string]string,
) {
@@ -412,8 +416,8 @@ func checkAddTarget(
click(ctx, t, saveButton)
assert.Truef(t, shown(ctx, `//span[text()="`+name+
`"]/following-sibling::div/span[text()="`+targetType+`"]`),
"%s: the added target is not listed with its type", targetType)
`"]/following-sibling::div/span[text()="`+badge+`"]`),
"%s: the added target is not listed as %s", targetType, badge)
}
// 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,
"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"]`),
"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"]`),
"a database target added with daily is not listed as daily")
@@ -943,15 +947,15 @@ func checkEventSelection(
}
// checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the row of the newest event, the first after
// it, and that Enter then collapses that event, which starts expanded,
// and Space expands it again.
// page's Pending link, the last link above the list, reaches the row of
// the newest event, the first after it, and that Enter then collapses
// that event, which starts expanded, and Space expands it again.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
t.Helper()
back := `//a[contains(text(), "Back to")]`
pending := `//a[starts-with(text(), "Pending")]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
var focused string
@@ -959,12 +963,12 @@ func checkEventKeyboard(
require.NoError(t, chromedp.Run(
ctx,
loadPage(url),
chromedp.Focus(back, chromedp.BySearch),
chromedp.Focus(pending, chromedp.BySearch),
chromedp.KeyEvent(kb.Tab),
chromedp.Evaluate(`document.activeElement.textContent`, &focused),
))
require.Contains(t, focused, eventID,
"Tab from the Back link does not reach the event's row")
"Tab from the Pending link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
+3 -3
View File
@@ -72,7 +72,7 @@ func TestEventResubmit_SignedOutRequestsNeverReachTheRateLimit(
env.requireNotice(
t, env.post(path, csrfForm(token), cookies),
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(
t, env.post(path, csrfForm(token), cookies),
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,
),
intrudersLogs, "resubmit-no-targets",
"this source has no active targets", cookies,
"this webhook has no active targets", cookies,
)
}
+21 -1
View File
@@ -914,6 +914,26 @@ func TestPagesLogout_SaysSignedOut(t *testing.T) {
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 ---
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
@@ -1363,7 +1383,7 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
)
env.requireNotice(
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)
+2 -2
View File
@@ -219,8 +219,8 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
// sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that the only query
// parameters in this service's own URLs are the login page's `next`
// and `notice`, and that Referrer-Policy is set to
// parameters in this service's own URLs are the login page's `next`,
// `notice` and the event log's `show`, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool {
File diff suppressed because one or more lines are too long
+9
View File
@@ -50,6 +50,15 @@
</dl>
</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="p-4 border-b border-gray-200">
<h2 class="text-lg font-medium text-gray-900">Body</h2>
+22
View File
@@ -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}}
+2 -2
View File
@@ -1,6 +1,6 @@
{{template "base" .}}
{{define "title"}}Login - Webhooker{{end}}
{{define "title"}}Sign in - Webhooker{{end}}
{{define "content"}}
<div class="min-h-screen flex items-center justify-center py-12 px-4">
@@ -52,7 +52,7 @@
>
</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>
</div>
</div>
+2 -2
View File
@@ -32,7 +32,7 @@
{{if .CSRFToken}}
<form method="POST" action="/pages/logout" class="inline">
<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>
{{end}}
{{end}}
@@ -49,7 +49,7 @@
{{if .CSRFToken}}
<form method="POST" action="/pages/logout">
<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>
{{end}}
{{end}}
+10 -10
View File
@@ -159,7 +159,7 @@
<select x-ref="type" aria-label="Target type" class="input text-sm p-2 flex-1">
<option value="http">HTTP</option>
<option value="slack">Slack</option>
<option value="database">Database</option>
<option value="database">Archive</option>
<option value="log">Log</option>
</select>
<button type="button" @click="next" class="btn-primary text-sm">Next</button>
@@ -182,10 +182,10 @@
</div>
<div>
<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">
</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>
</template>
@@ -198,10 +198,10 @@
</div>
<div>
<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">
</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>
</template>
@@ -251,7 +251,7 @@
<div class="flex flex-wrap items-center justify-between gap-2 mb-1">
<span class="text-sm font-medium text-gray-900">{{.Name}}</span>
<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}}
<span class="badge-success">Active</span>
{{else}}
@@ -275,7 +275,7 @@
</div>
{{with .Paused}}
<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>
</div>
{{end}}
@@ -287,17 +287,17 @@
{{end}}
{{with .Archive}}
<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>
{{with .Note}}<span>({{.}})</span>{{end}}
</div>
{{if .Files}}
<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>
</div>
<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>
</div>
{{end}}
+14 -2
View File
@@ -8,7 +8,14 @@
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}{{end}}</span>
<!-- Under a filter, this counts the events the filter lists. -->
<span class="text-sm text-gray-500">{{if gt .TotalEvents (len .Events)}}{{len .Events}} most recent of {{.TotalEvents}} events{{else}}{{.TotalEvents}}{{if not .Show}} total{{end}} event{{if ne .TotalEvents 1}}s{{end}}{{end}}{{if eq .Show "failed"}} with a failed delivery{{else if eq .Show "pending"}} with a delivery pending or retrying{{end}}</span>
</div>
<!-- Plain links, so they work without the page's script library. The current one is marked. -->
<div class="mt-3 flex flex-wrap gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small{{if eq .Show ""}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show ""}} aria-current="page"{{end}}>All</a>
<a href="/hook/{{.Webhook.ID}}/events?show=failed" title="Events with at least one failed delivery" class="btn-small{{if eq .Show "failed"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "failed"}} aria-current="page"{{end}}>Failed ({{.FailedEvents}})</a>
<a href="/hook/{{.Webhook.ID}}/events?show=pending" title="Events with a delivery still pending or retrying" class="btn-small{{if eq .Show "pending"}} bg-primary-50 border-primary-500{{end}}"{{if eq .Show "pending"}} aria-current="page"{{end}}>Pending ({{.PendingEvents}})</a>
</div>
</div>
@@ -55,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>
</form>
</div>
<div class="mb-3">
{{template "event_request" .}}
</div>
{{template "event_body" .Body}}
{{if .Deliveries}}
@@ -73,6 +83,8 @@
{{if and .Status.Terminal (not .Target.Deleted)}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<!-- The list to return to. -->
<input type="hidden" name="show" value="{{$.Show}}">
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form>
{{end}}
@@ -89,7 +101,7 @@
</div>
</div>
{{else}}
<div class="p-12 text-center text-sm text-gray-500">No events recorded yet.</div>
<div class="p-12 text-center text-sm text-gray-500">{{if eq $.Show "failed"}}No event has a failed delivery.{{else if eq $.Show "pending"}}No event has a delivery pending or retrying.{{else}}No events recorded yet.{{end}}</div>
{{end}}
</div>
</div>
+2 -2
View File
@@ -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">
Archive
</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">
<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">
{{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
+5 -5
View File
@@ -7,7 +7,7 @@
<div class="mb-6">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<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 class="card p-6">
@@ -59,7 +59,7 @@
{{if eq .Target.Type "database"}}
<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">
{{range .ArchiveExpiryChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -69,7 +69,7 @@
</div>
<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">
{{range .ArchiveRotationChoices}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Label}}</option>
@@ -81,9 +81,9 @@
{{if or (eq .Target.Type "http") (eq .Target.Type "slack")}}
<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">
<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>
{{end}}