Compare commits

1 Commits
Author SHA1 Message Date
sneak 92dbfc8996 Event log: show attempt and delivery times, label replays, zone event times (closes #386)
check / check (push) Successful in 3m19s
Each attempt shows when it was recorded and each delivery when it was
created, in the event log and on the event's page. The event log's
event times read as the recent events list does: how long ago, with
the full UTC time on hover.

A delivery records whether Replay created it, in a new replay column
added to the delivery model in place. Such a delivery is labelled a
replay in the event's summary line and in both pages' delivery lists.

Model: opus-5-5
2026-10-03 00:52:03 +00:00
22 changed files with 218 additions and 301 deletions
+13 -15
View File
@@ -1364,18 +1364,17 @@ an entrypoint's Edit button shows its edit form in place of its description and
hides until the form closes, Cancel hides the form and drops what was typed, as
does leaving the page and going back to it, and Save changes the description;
of the recent events on the webhook page only the newest starts expanded, each
expands and collapses, and Open leads to the event's own page; of the events in
the event log only the newest starts expanded, and an event there expands and
collapses when its row's caret or its ID is clicked, and from the keyboard, but
not when its ID is selected with the mouse, and a delivery's attempts inside it
expand and collapse; and at phone width the menu button opens and closes the
mobile menu. It also fails if the browser reports a console warning or error, an
uncaught exception, or anything the policy refused. `make check` and the image
build lint it but do not run it, and `make test` leaves it out (its file is
built only with the `browser` build tag). Run it with `make test-browser` after
changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
runs the test in a digest-pinned headless browser image, so the host needs no
browser.
expands and collapses, and Open leads to the event's own page; an event in the
event log expands and collapses when its row's caret or its ID is clicked, and
from the keyboard, but not when its ID is selected with the mouse, and a
delivery's attempts inside it expand and collapse; and at phone width the menu
button opens and closes the mobile menu. It also fails if the browser reports a
console warning or error, an uncaught exception, or anything the policy refused.
`make check` and the image build lint it but do not run it, and `make test`
leaves it out (its file is built only with the `browser` build tag). Run it with
`make test-browser` after changing `templates/` or `static/js/`: that builds
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser
image, so the host needs no browser.
The package's tarball is committed as `3p/alpinejs-csp-3.14.9.tgz`, byte for
byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -2543,9 +2542,8 @@ 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: `page`, on the authenticated pagination links, is the only query
parameter this service reads.
Client-supplied request content does not leave the host by the other
route either. The Sentry SDK attaches the request to every event it
+15 -5
View File
@@ -2,6 +2,7 @@ package handlers
import (
"net/http"
"strconv"
"github.com/go-chi/chi"
"gorm.io/gorm"
@@ -329,15 +330,24 @@ 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 and
// the page number the form submitted.
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,
)
dest := withNotice("/hook/"+webhook.ID+"/events", code)
// The page is read from the form rather than the query string:
// this is a POST, and its query string is what logs and Referer
// headers record.
if page := pageOrFirst(
r.PostFormValue("page"),
); page > 1 {
dest += "&page=" + strconv.Itoa(page)
}
http.Redirect(w, r, dest, http.StatusSeeOther)
}
+2 -12
View File
@@ -568,19 +568,9 @@ func TestHandleDeliveryReplay_LabelsTheReplay(t *testing.T) {
w = serveEventPage(t, h, sess, wh.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
// In each delivery list a row names the target, then the label if
// it is a replay, then its status: the replay is still pending, the
// original failed.
replayRow := tgt.Name + `</span> ` +
`<span class="text-xs text-gray-500">replay</span> ` +
`<span class="text-xs text-gray-400">pending</span>`
originalRow := tgt.Name + `</span> ` +
`<span class="text-xs text-red-600">failed</span>`
const label = `<span class="text-xs text-gray-500">replay</span>`
for _, page := range []string{eventLog, w.Body.String()} {
page = strings.Join(strings.Fields(page), " ")
assert.Contains(t, page, replayRow)
assert.Contains(t, page, originalRow)
assert.Equal(t, 1, strings.Count(page, label))
}
}
@@ -435,7 +435,9 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
}).Error)
}
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
views := h.LoadEventLogViewsForTest(
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1)
@@ -487,7 +489,9 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
stored := strings.Repeat("A", responseCap*4) + tail
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
views := h.LoadEventLogViewsForTest(
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1)
require.Len(t, views[0].Deliveries[0].Results, 1)
+3 -1
View File
@@ -75,7 +75,9 @@ func seedAndProject(
wh := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, wh.ID, body)
views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
views := h.LoadEventLogViewsForTest(
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1)
return views[0]
+8 -1
View File
@@ -51,6 +51,12 @@ const (
SidecarLeftMsgForTest = sidecarLeftMsg
)
// PageOrFirstForTest exposes pageOrFirst for use in the handlers_test
// package.
func PageOrFirstForTest(s string) int {
return pageOrFirst(s)
}
// DummyVerificationsForTest reports how many equivalent-cost
// verifications were charged for usernames that do not exist. It
// lets a test prove the anti-enumeration path ran without timing
@@ -73,9 +79,10 @@ func TrimPartialRuneForTest(b []byte) []byte {
func (s *Handlers) LoadEventLogViewsForTest(
w http.ResponseWriter,
webhook database.Webhook,
page int,
) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil,
w, newRequestForTest(), webhook, nil, page,
)
return views
+5 -6
View File
@@ -30,9 +30,10 @@ import (
const (
// maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20
// recentEventLimit is the number of most recent events that a
// webhook's page and its event log show.
// recentEventLimit is the number of recent events to show.
recentEventLimit = 50
// paginationPerPage is the number of items per page.
paginationPerPage = 25
// tmplKeyError is the template data key for an error message.
tmplKeyError = "Error"
@@ -165,12 +166,10 @@ 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_body.html", "delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_row.html",
"delivery_attempts.html",
"event_detail.html", "event_body.html", "delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
+1 -5
View File
@@ -57,11 +57,7 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
},
{
"source_logs.html",
map[string]any{
dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0),
},
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)},
"Full Event Log - orders - Webhooker",
},
{
-96
View File
@@ -275,99 +275,3 @@ func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
)
}
// TestHandleSourceDetail_DeletePromptsNameWhatIsLost checks that each
// delete prompt on the webhook page names the webhook, entrypoint or
// target and says what deleting it loses, that the webhook's gives its
// number of stored events (5 received, 2 removed by retention, so 3,
// the statistics pane's "Within retention" figure), and that an
// entrypoint with no description is named by its URL. The template
// writes the slashes after http: as \/, which the browser reads as /.
func TestHandleSourceDetail_DeletePromptsNameWhatIsLost(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)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
require.NoError(t, database.AddEventTotals(
webhookDB, database.EventTotals{Events: 5, EventsRemoved: 2},
))
unnamed := seedEntrypoint(t, db, wh.ID)
require.NoError(t, db.DB().Omit(clause.Associations).Create(
&database.Entrypoint{
WebhookID: wh.ID,
Path: "described-" + wh.ID,
Description: "Stripe",
Active: true,
},
).Error)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
`Delete webhook &quot;delete-me&quot;?\n\n`+
`This deletes its stored events (3) and their deliveries. `+
`Any archive files it wrote are kept.`)
assert.Contains(t, body,
`Delete entrypoint &quot;Stripe&quot;?\n\n`+
`Senders using its URL get an error from now on, `+
`and the URL cannot be restored.`)
assert.Contains(t, body,
`Delete entrypoint &quot;http:\/\/example.com/h/`+
unnamed.Path+`&quot;?`)
assert.Contains(t, body,
`Delete target &quot;t-log&quot;?\n\n`+
`Nothing more is delivered to it. `+
`Its past deliveries stay in the event log.`)
}
// TestHandleSourceDetail_DeletePromptKeepsQuotesInName checks that a
// webhook name with quotes, a backslash, a closing script tag and a
// newline reaches its delete prompt escaped for the script, which the
// browser reads back as the name typed: each quote and angle bracket
// as a \u escape, the slash as \/, the newline as \n and the backslash
// doubled. An unescaped newline would break the prompt's script, and
// the form would then submit without asking.
func TestHandleSourceDetail_DeletePromptKeepsQuotesInName(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID,
Name: "Bob's \"best\" \\ hook</script>\nline two",
}
require.NoError(
t, db.DB().Omit(clause.Associations).Create(wh).Error,
)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body,
"Delete webhook &quot;Bob\\u0027s \\u0022best\\u0022 \\\\ hook"+
"\\u003c\\/script\\u003e\\nline two&quot;?")
}
-55
View File
@@ -2,12 +2,9 @@ package handlers_test
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
@@ -156,55 +153,3 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
assert.Contains(t, body, tgt.Name)
assert.Contains(t, body, "delivered")
}
// TestHandleSourceLogs_ShowsFiftyNewestEvents proves the event log
// holds the 50 newest events, newest first, and not one more, and says
// how many events there are in all.
func TestHandleSourceLogs_ShowsFiftyNewestEvents(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
base := time.Now().Add(-time.Hour)
for i := range 51 {
f.event(
t, fmt.Sprintf("application/x-log-%02d", i), "{}",
base.Add(time.Duration(i)*time.Second),
)
}
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, 50, strings.Count(body, `role="button"`))
assert.NotContains(t, body, "application/x-log-00")
assert.Contains(t, body, "application/x-log-01")
assert.Less(
t,
strings.Index(body, "application/x-log-50"),
strings.Index(body, "application/x-log-49"),
)
assert.Contains(t, body, "50 most recent of 51 events")
}
// TestHandleSourceLogs_OnlyNewestStartsExpanded proves that of the
// events in the log only the newest starts expanded.
func TestHandleSourceLogs_OnlyNewestStartsExpanded(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
f.event(t, "application/x-older", "{}", now.Add(-time.Minute))
f.event(t, "application/x-newer", "{}", now)
body := renderSourceLogsPage(t, f.h, f.sess, f.webhook.ID)
assert.Equal(t, 1, strings.Count(body, " data-open>"))
open := strings.Index(body, " data-open>")
newer := strings.Index(body, "application/x-newer")
older := strings.Index(body, "application/x-older")
assert.Less(t, open, newer, "the newest event is not the open one")
assert.Less(t, newer, older)
}
+56 -18
View File
@@ -621,9 +621,8 @@ func (h *Handlers) renderSourceDetail(
// The host is the client's Host header, unvalidated. It is
// inert only because source_detail.html renders BaseURL as
// text, inside a <code> element and in an entrypoint's delete
// prompt; putting it in an href or any other URL context
// needs it constrained first.
// text inside a <code> element; putting it in an href or any
// other URL context needs it constrained first.
baseURL := scheme + "://" + r.Host
// The template calls Webhook methods, which take pointer
@@ -1122,17 +1121,30 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return
}
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets,
w, r, webhook, targets, page,
)
if !ok {
return
}
totalPages := int(total) / paginationPerPage
if int(total)%paginationPerPage != 0 {
totalPages++
}
data := map[string]any{
tmplKeyWebhook: &webhook,
"Events": evts,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
}
h.renderTemplate(w, r, "source_logs.html", data)
@@ -1192,11 +1204,15 @@ func (h *Handlers) loadTargetMap(
return targetMap, nil
}
// 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.
// parsePage extracts a page number from the query string.
func (h *Handlers) parsePage(r *http.Request) int {
return pageOrFirst(r.URL.Query().Get("page"))
}
// loadEventsWithDeliveries loads paginated events and their
// deliveries from the per-webhook database. 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
@@ -1206,6 +1222,7 @@ func (h *Handlers) loadEventsWithDeliveries(
r *http.Request,
webhook database.Webhook,
targetMap map[string]eventLogTarget,
page int,
) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true
@@ -1220,7 +1237,9 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false
}
rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
rows, totalEvents := loadEventLogRows(
webhookDB, webhook.ID, page,
)
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
@@ -1293,11 +1312,10 @@ func (h *Handlers) eventLogViews(
return result, true
}
// loadEventLogRows reads the event log projection of the
// recentEventLimit newest events, newest first, and the total number
// of events stored.
// loadEventLogRows reads one page of the event log projection, newest
// first, and the total number of events the pager counts against.
func loadEventLogRows(
webhookDB *gorm.DB, webhookID string,
webhookDB *gorm.DB, webhookID string, page int,
) ([]eventLogRow, int64) {
var totalEvents int64
@@ -1311,7 +1329,9 @@ func loadEventLogRows(
eventLogColumns, maxRenderedBodyBytes,
).Where(
"webhook_id = ?", webhookID,
).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
).Order("created_at DESC").Offset(
(page - 1) * paginationPerPage,
).Limit(paginationPerPage).Find(&rows)
return rows, totalEvents
}
@@ -1320,9 +1340,9 @@ func loadEventLogRows(
// events have been resubmitted from it.
//
// One grouped query covers the page rather than one query per event.
// The page shows at most recentEventLimit events, far below SQLite's
// bound parameter ceiling, so it needs no chunking as the delivery
// result load does.
// A page holds paginationPerPage ids, far below SQLite's bound
// parameter ceiling, so it needs no chunking as the delivery result
// load does.
func resubmitCounts(
webhookDB *gorm.DB, eventIDs []string,
) (map[string]int, error) {
@@ -1752,6 +1772,24 @@ func (h *Handlers) setTargetFromForm(
return "", nil
}
// pageOrFirst parses a paginated page number, answering 1 for
// anything empty, unparseable or out of range.
//
// Falling back rather than rejecting is correct here and only here:
// a page number is where to send the browser next, not configuration
// the operator is storing, and the actions that submit one have
// already completed by the time it is read — answering 400 would
// report a failure that did not happen. Anything an operator SETS
// must be validated instead; see parseMaxRetries.
func pageOrFirst(s string) int {
v, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil || v < 1 {
return 1
}
return v
}
// targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to setTargetFromForm,
// so neither can come to validate a target differently from the
+17
View File
@@ -383,3 +383,20 @@ func TestTargetRetries_CreateAndEditAgreeOnEveryCase(t *testing.T) {
)
}
}
// TestPageOrFirst_CoercesRatherThanRejects pins the one place a
// non-numeric form value legitimately falls back. A page number says
// where to send the browser after an action that has already
// happened, so it is not configuration and rejecting it would report
// a failure that did not occur.
func TestPageOrFirst_CoercesRatherThanRejects(t *testing.T) {
t.Parallel()
for _, s := range []string{"", "abc", "0", "-1", "2.7", " "} {
assert.Equal(t, 1, handlers.PageOrFirstForTest(s),
"%q should fall back to the first page", s)
}
assert.Equal(t, 4, handlers.PageOrFirstForTest("4"))
assert.Equal(t, 4, handlers.PageOrFirstForTest(" 4 "))
}
+9 -12
View File
@@ -21,7 +21,6 @@ import (
const (
dataKeyWebhook = "Webhook"
dataKeyError = "Error"
dataKeyEvents = "Events"
)
// testWebhookID is the identifier given to the webhook under test on
@@ -145,10 +144,9 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: source_detail.html calls
// Webhook.RetentionLabel, a pointer method. The webhook page only
// ranges over its lists, and a list left out renders as empty, so
// its lists are left out. The event log also counts its events, so
// it gets an empty list.
// Webhook.RetentionLabel, a pointer method. Both pages only range
// over their lists, and a list left out renders as empty, so the
// lists are left out.
webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID
@@ -171,7 +169,6 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0),
})
@@ -320,9 +317,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
"Entrypoints": handlers.NewEntrypointViews(
[]database.Entrypoint{entrypoint},
),
"Targets": delivery.NewTargetViews(nil),
dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com",
"Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{},
"BaseURL": "https://hooks.example.com",
})
assert.Contains(
@@ -387,9 +384,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
"Entrypoints": handlers.NewEntrypointViews(
[]database.Entrypoint{entrypoint},
),
"Targets": delivery.NewTargetViews(nil),
dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com",
"Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{},
"BaseURL": "https://hooks.example.com",
},
)
+6 -7
View File
@@ -278,13 +278,12 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// after the '?'. Keeping the path and dropping the query is what makes
// 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
// 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
// choosing for us.
// Nothing debuggable is lost. One route in the service reads a query
// parameter at all — `page`, on the authenticated pagination links in
// internal/handlers/source_management.go — and 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 choosing for us.
func concreteLogURL(r *http.Request) string {
path := r.URL.EscapedPath()
+18 -26
View File
@@ -765,15 +765,14 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body")
}
// checkEventLog loads the event log and checks that of its events only
// the newest, eventID, starts expanded: its row says so and its caret is
// turned up, and the log's last event, lastEventID, starts collapsed. In
// the newest event, clicking its delivery shows the delivery's attempts
// and clicking again hides them. Clicking the row's caret collapses the
// event, clicking it again expands it, clicking its ID collapses it and
// clicking the ID again expands it. While the event is collapsed the row
// says so and its caret is turned down. It then runs checkEventSelection
// on lastEventID and checkEventKeyboard on eventID.
// checkEventLog loads the event log and checks an event's row. Clicking
// its ID expands the event, and in there clicking its delivery shows the
// delivery's attempts and clicking again hides them. Clicking the row's
// caret collapses the event, clicking it again expands it, and clicking
// the ID again collapses it. While the event is expanded the row says so
// and its caret is turned up, and while it is collapsed neither. It then
// runs checkEventSelection on the log's last event, lastEventID, and
// checkEventKeyboard on eventID.
func checkEventLog(
ctx context.Context,
t *testing.T,
@@ -791,19 +790,20 @@ func checkEventLog(
caretUp := caret + `[contains(@class, "rotate-180")]`
caretDown := caret + `[not(contains(@class, "rotate-180"))]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
assert.True(t, hidden(ctx, expanded), "the event starts expanded")
click(ctx, t, id)
assert.True(t, shown(ctx, expanded),
"clicking the event's ID does not expand it")
assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
"the expanded event's row does not say it is expanded")
assert.True(t, shown(ctx, caretUp),
"the expanded event's caret does not turn up")
assert.True(t, hidden(ctx, lastExpanded),
"an older event starts expanded")
assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -829,11 +829,7 @@ func checkEventLog(
click(ctx, t, id)
assert.True(t, hidden(ctx, expanded),
"clicking the event's ID does not collapse it")
click(ctx, t, id)
assert.True(t, shown(ctx, expanded),
"clicking the event's ID again does not expand it")
"clicking the event's ID again does not collapse it")
checkEventSelection(ctx, t, url, lastEventID)
checkEventKeyboard(ctx, t, url, eventID)
@@ -882,11 +878,8 @@ func checkEventSelection(
`window.getSelection().removeAllRanges()`, nil,
)
// The newest event starts expanded, which can push this one below
// the short window, where the mouse cannot reach it.
require.NoError(t, chromedp.Run(
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
chromedp.ScrollIntoView(id, chromedp.BySearch),
))
selectText(ctx, t, id)
@@ -929,9 +922,8 @@ 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 Back link reaches the event's row, the first after it, and that
// Enter then expands the event and Space collapses it.
func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string,
) {
@@ -953,10 +945,10 @@ func checkEventKeyboard(
"Tab from the Back 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")
assert.True(t, shown(ctx, expanded), "Enter does not expand the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
assert.True(t, shown(ctx, expanded), "Space does not expand the event")
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event")
}
// selectText selects the text of the element matching an XPath
+11 -3
View File
@@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
// TestHook_LinksBetweenPages follows each link to a webhook page that
// the tests above do not: the navbar's "Webhooks" links, the back and
// Cancel links, the list's link to a webhook, and the "Full Event Log"
// link beside the recent events. Each must point where it should, and
// that page must render.
// Cancel links, the list's link to a webhook, the "Full Event Log"
// link beside the recent events, and the event log's page links. Each
// must point where it should, and that page must render.
func TestHook_LinksBetweenPages(t *testing.T) {
t.Parallel()
@@ -1392,6 +1392,12 @@ func TestHook_LinksBetweenPages(t *testing.T) {
wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID)
// The event log shows 25 events a page; one more gives it a second
// page, so it renders its Next and Previous links.
for range 26 {
env.seedEvent(t, wh.ID, "paged")
}
list := "/hooks"
newForm := list + "/new"
page := "/hook/" + wh.ID
@@ -1423,6 +1429,8 @@ func TestHook_LinksBetweenPages(t *testing.T) {
{targetEdit, back, page},
{targetEdit, cancel, page},
{events, back, page},
{events, `href="([^"]+)"[^>]*>Next &rarr;<`, events + "?page=2"},
{events + "?page=2", `href="([^"]+)"[^>]*>&larr; Previous<`, events + "?page=1"},
} {
got := env.urlFrom(t, link.from, link.pattern, cookies)
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
+2 -3
View File
@@ -218,9 +218,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
// on the reasoning that it is browser-set, that this service emits
// only ?page= in its own links, 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
-16
View File
@@ -1,16 +0,0 @@
{{define "delivery_row"}}
<!-- The line that heads a delivery, as handlers.DeliveryView holds it:
in the event log, inside the button that shows its attempts, and on
an event's own page. Spans only, since a button may hold no div. -->
<span class="flex flex-1 flex-wrap items-center justify-between gap-3">
<span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
</span>
</span>
{{end}}
+11 -1
View File
@@ -66,7 +66,17 @@
<div class="divide-y divide-gray-100">
{{range .Deliveries}}
<div class="p-4">
{{template "delivery_row" .}}
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
</span>
</div>
<div class="mt-2 space-y-2">
{{template "delivery_attempts" .}}
</div>
+3 -7
View File
@@ -20,11 +20,7 @@
<div class="flex gap-2">
<a href="/hook/{{.Webhook.ID}}/events" class="btn-secondary">Full Event Log</a>
<a href="/hook/{{.Webhook.ID}}/edit" class="btn-secondary">Edit</a>
<!-- The delete prompts are the browser's own, so they
work without the page's scripts. The template
escapes each name for the script, so a quote or a
backslash in it shows as typed. -->
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete webhook &quot;{{.Webhook.Name}}&quot;?\n\nThis deletes its stored events{{with .Stats}} ({{.WithinRetention.Events}}){{end}} and their deliveries. Any archive files it wrote are kept.')">
<form method="POST" action="/hook/{{.Webhook.ID}}/delete" onsubmit="return confirm('Delete this webhook and all its data?')">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<button type="submit" class="btn-danger">Delete</button>
</form>
@@ -87,7 +83,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete entrypoint &quot;{{if .Description}}{{.Description}}{{else}}{{$.BaseURL}}/h/{{.Path}}{{end}}&quot;?\n\nSenders using its URL get an error from now on, and the URL cannot be restored.')" class="inline">
<form method="POST" action="/hook/{{$.Webhook.ID}}/entrypoints/{{.ID}}/delete" onsubmit="return confirm('Delete this entrypoint?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>
@@ -253,7 +249,7 @@
{{if .Active}}Deactivate{{else}}Activate{{end}}
</button>
</form>
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete target &quot;{{.Name}}&quot;?\n\nNothing more is delivered to it. Its past deliveries stay in the event log.')" class="inline">
<form method="POST" action="/hook/{{$.Webhook.ID}}/targets/{{.ID}}/delete" onsubmit="return confirm('Delete this target?')" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<button type="submit" class="btn-small text-red-600" title="Delete">Delete</button>
</form>
+31 -9
View File
@@ -8,15 +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>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span>
</div>
</div>
<div class="card">
<div class="divide-y divide-gray-100">
<!-- Only the newest event starts expanded. -->
{{range $i, $event := .Events}}
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
{{range .Events}}
<div class="p-4" x-data="collapsible">
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. -->
<div role="button" tabindex="0" class="btn-small w-full flex flex-wrap justify-between gap-2" :aria-expanded="open" @mousedown="cancelPendingToggle" @click="toggleUnlessSelecting" @keydown.enter.prevent="toggle" @keydown.space.prevent="toggle">
<span class="flex flex-wrap items-center gap-3">
@@ -52,6 +51,7 @@
</div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form>
</div>
@@ -64,15 +64,24 @@
{{range .Deliveries}}
<div class="py-2" x-data="collapsible">
<div class="flex items-center gap-3">
<button type="button" class="btn-small flex-1 gap-3 text-left" @click="toggle">
{{template "delivery_row" .}}
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-2 text-left" @click="toggle">
<span class="flex flex-wrap items-center gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</span>
{{if .Replay}}<span class="text-xs text-gray-500">replay</span>{{end}}
<span class="text-xs {{if eq .Status "delivered"}}text-green-600{{else if eq .Status "failed"}}text-red-600{{else if eq .Status "retrying"}}text-yellow-600{{else}}text-gray-400{{end}}">{{with .Paused}}waiting: target paused after repeated failures, next try no earlier than {{.Until}} ({{.Relative}}){{else}}{{.Status}}{{end}}</span>
</span>
<span class="flex flex-wrap items-center gap-3">
<span class="text-xs text-gray-400" title="{{.CreatedUTC}}">created {{.Created}}</span>
<span class="text-xs text-gray-400">{{.AttemptCount}} attempt{{if ne .AttemptCount 1}}s{{end}}</span>
<svg class="w-3 h-3 text-gray-400 transition-transform" :class="caretClass" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</span>
</button>
{{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}}">
<input type="hidden" name="page" value="{{$.Page}}">
<button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form>
{{end}}
@@ -93,5 +102,18 @@
{{end}}
</div>
</div>
<!-- Pagination -->
{{if or .HasPrev .HasNext}}
<div class="flex justify-center gap-2 mt-6">
{{if .HasPrev}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.PrevPage}}" class="btn-secondary text-sm">&larr; Previous</a>
{{end}}
<span class="inline-flex items-center px-4 py-2 text-sm text-gray-500">Page {{.Page}} of {{.TotalPages}}</span>
{{if .HasNext}}
<a href="/hook/{{.Webhook.ID}}/events?page={{.NextPage}}" class="btn-secondary text-sm">Next &rarr;</a>
{{end}}
</div>
{{end}}
</div>
{{end}}