Event log: only the newest event expanded, and only the 50 most recent (closes #349) #488

Merged
clawbot merged 1 commits from issue-349-event-log-newest-fifty into next 2026-10-03 03:43:28 +02:00
17 changed files with 157 additions and 185 deletions
+15 -13
View File
@@ -1364,17 +1364,18 @@ 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 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; 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 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; an event in the expands and collapses, and Open leads to the event's own page; of the events in
event log expands and collapses when its row's caret or its ID is clicked, and the event log only the newest starts expanded, and an event there expands and
from the keyboard, but not when its ID is selected with the mouse, and a collapses when its row's caret or its ID is clicked, and from the keyboard, but
delivery's attempts inside it expand and collapse; and at phone width the menu not when its ID is selected with the mouse, and a delivery's attempts inside it
button opens and closes the mobile menu. It also fails if the browser reports a expand and collapse; and at phone width the menu button opens and closes the
console warning or error, an uncaught exception, or anything the policy refused. mobile menu. It also fails if the browser reports a console warning or error, an
`make check` and the image build lint it but do not run it, and `make test` uncaught exception, or anything the policy refused. `make check` and the image
leaves it out (its file is built only with the `browser` build tag). Run it with build lint it but do not run it, and `make test` leaves it out (its file is
`make test-browser` after changing `templates/` or `static/js/`: that builds built only with the `browser` build tag). Run it with `make test-browser` after
`Dockerfile.browser`, which runs the test in a digest-pinned headless browser changing `templates/` or `static/js/`: that builds `Dockerfile.browser`, which
image, so the host needs no browser. 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 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 byte as the npm registry publishes it. It is a dependency, not this repo's build
@@ -2536,8 +2537,9 @@ The query string is never logged; it is replaced by the fixed marker
`/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate `/.well-known/healthcheck` and `/s/*` answer 200 to anyone with no rate
limiter in front of them, so a query on a fixed 200 URL would otherwise limiter in front of them, so a query on a fixed 200 URL would otherwise
buy the same amplification as an invented path. Nothing debuggable is buy the same amplification as an invented path. Nothing debuggable is
lost: `page`, on the authenticated pagination links, is the only query lost: the only query parameters this service reads are the login page's
parameter this service reads. `next`, the page to return to, and `notice`, which names the line a page
shows after an action.
Client-supplied request content does not leave the host by the other Client-supplied request content does not leave the host by the other
route either. The Sentry SDK attaches the request to every event it route either. The Sentry SDK attaches the request to every event it
+5 -15
View File
@@ -2,7 +2,6 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"gorm.io/gorm" "gorm.io/gorm"
@@ -329,24 +328,15 @@ func replayBody(body string) *string {
} }
// redirectToEventLog redirects a replay or resubmit back to the event // redirectToEventLog redirects a replay or resubmit back to the event
// log it was triggered from, carrying the outcome as its notice and // log it was triggered from, carrying the outcome as its notice.
// the page number the form submitted.
func redirectToEventLog( func redirectToEventLog(
w http.ResponseWriter, w http.ResponseWriter,
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
code noticeCode, code noticeCode,
) { ) {
dest := withNotice("/hook/"+webhook.ID+"/events", code) http.Redirect(
w, r, withNotice("/hook/"+webhook.ID+"/events", code),
// The page is read from the form rather than the query string: http.StatusSeeOther,
// 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)
} }
@@ -435,9 +435,7 @@ func TestHandleSourceLogs_BoundsRenderedAttempts(t *testing.T) {
}).Error) }).Error)
} }
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
@@ -489,9 +487,7 @@ func TestHandleSourceLogs_BoundsOversizeResponse(t *testing.T) {
stored := strings.Repeat("A", responseCap*4) + tail stored := strings.Repeat("A", responseCap*4) + tail
seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored) seedFailedDeliveryWithResponse(t, dbMgr, wh.ID, tgt.ID, stored)
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
require.Len(t, views[0].Deliveries, 1) require.Len(t, views[0].Deliveries, 1)
require.Len(t, views[0].Deliveries[0].Results, 1) require.Len(t, views[0].Deliveries[0].Results, 1)
+1 -3
View File
@@ -75,9 +75,7 @@ func seedAndProject(
wh := seedWebhook(t, db) wh := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, wh.ID, body) seedEventWithBody(t, dbMgr, wh.ID, body)
views := h.LoadEventLogViewsForTest( views := h.LoadEventLogViewsForTest(httptest.NewRecorder(), *wh)
httptest.NewRecorder(), *wh, 1,
)
require.Len(t, views, 1) require.Len(t, views, 1)
return views[0] return views[0]
+1 -8
View File
@@ -51,12 +51,6 @@ const (
SidecarLeftMsgForTest = sidecarLeftMsg 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 // DummyVerificationsForTest reports how many equivalent-cost
// verifications were charged for usernames that do not exist. It // verifications were charged for usernames that do not exist. It
// lets a test prove the anti-enumeration path ran without timing // lets a test prove the anti-enumeration path ran without timing
@@ -79,10 +73,9 @@ func TrimPartialRuneForTest(b []byte) []byte {
func (s *Handlers) LoadEventLogViewsForTest( func (s *Handlers) LoadEventLogViewsForTest(
w http.ResponseWriter, w http.ResponseWriter,
webhook database.Webhook, webhook database.Webhook,
page int,
) []EventLogView { ) []EventLogView {
views, _, _ := s.loadEventsWithDeliveries( views, _, _ := s.loadEventsWithDeliveries(
w, newRequestForTest(), webhook, nil, page, w, newRequestForTest(), webhook, nil,
) )
return views return views
+2 -3
View File
@@ -30,10 +30,9 @@ import (
const ( const (
// maxBodyShift is the bit shift for 1 MB body limit. // maxBodyShift is the bit shift for 1 MB body limit.
maxBodyShift = 20 maxBodyShift = 20
// recentEventLimit is the number of recent events to show. // recentEventLimit is the number of most recent events that a
// webhook's page and its event log show.
recentEventLimit = 50 recentEventLimit = 50
// paginationPerPage is the number of items per page.
paginationPerPage = 25
// tmplKeyError is the template data key for an error message. // tmplKeyError is the template data key for an error message.
tmplKeyError = "Error" tmplKeyError = "Error"
+5 -1
View File
@@ -57,7 +57,11 @@ func TestEveryPageRendersItsOwnTitle(t *testing.T) {
}, },
{ {
"source_logs.html", "source_logs.html",
map[string]any{dataKeyWebhook: webhook, "TotalEvents": int64(0)}, map[string]any{
dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0),
},
"Full Event Log - orders - Webhooker", "Full Event Log - orders - Webhooker",
}, },
{ {
+55
View File
@@ -2,9 +2,12 @@ package handlers_test
import ( import (
"context" "context"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings"
"testing" "testing"
"time"
"github.com/go-chi/chi" "github.com/go-chi/chi"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -153,3 +156,55 @@ func TestHandleSourceLogs_MasksSlackWebhookURL(t *testing.T) {
assert.Contains(t, body, tgt.Name) assert.Contains(t, body, tgt.Name)
assert.Contains(t, body, "delivered") 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)
}
+15 -54
View File
@@ -1112,30 +1112,17 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
return return
} }
page := h.parsePage(r)
evts, total, ok := h.loadEventsWithDeliveries( evts, total, ok := h.loadEventsWithDeliveries(
w, r, webhook, targets, page, w, r, webhook, targets,
) )
if !ok { if !ok {
return return
} }
totalPages := int(total) / paginationPerPage
if int(total)%paginationPerPage != 0 {
totalPages++
}
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: &webhook, tmplKeyWebhook: &webhook,
"Events": evts, "Events": evts,
"Page": page,
"TotalPages": totalPages,
"TotalEvents": total, "TotalEvents": total,
"HasPrev": page > 1,
"HasNext": page < totalPages,
"PrevPage": page - 1,
"NextPage": page + 1,
} }
h.renderTemplate(w, r, "source_logs.html", data) h.renderTemplate(w, r, "source_logs.html", data)
@@ -1195,15 +1182,11 @@ func (h *Handlers) loadTargetMap(
return targetMap, nil return targetMap, nil
} }
// parsePage extracts a page number from the query string. // loadEventsWithDeliveries loads the recentEventLimit newest events
func (h *Handlers) parsePage(r *http.Request) int { // and their deliveries from the per-webhook database, and the total
return pageOrFirst(r.URL.Query().Get("page")) // number of events stored. Events come back as capped projections
} // rather than database.Event rows: see eventLogColumns for why the
// cut happens in SQL.
// 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 // The bool reports whether the load succeeded. It is false
// once this has answered the request with an error, and the // once this has answered the request with an error, and the
@@ -1213,7 +1196,6 @@ func (h *Handlers) loadEventsWithDeliveries(
r *http.Request, r *http.Request,
webhook database.Webhook, webhook database.Webhook,
targetMap map[string]eventLogTarget, targetMap map[string]eventLogTarget,
page int,
) ([]EventLogView, int64, bool) { ) ([]EventLogView, int64, bool) {
if !h.dbMgr.DBExists(webhook.ID) { if !h.dbMgr.DBExists(webhook.ID) {
return nil, 0, true return nil, 0, true
@@ -1228,9 +1210,7 @@ func (h *Handlers) loadEventsWithDeliveries(
return nil, 0, false return nil, 0, false
} }
rows, totalEvents := loadEventLogRows( rows, totalEvents := loadEventLogRows(webhookDB, webhook.ID)
webhookDB, webhook.ID, page,
)
result, ok := h.eventLogViews( result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap, w, r, webhookDB, webhook.ID, rows, targetMap,
@@ -1303,10 +1283,11 @@ func (h *Handlers) eventLogViews(
return result, true return result, true
} }
// loadEventLogRows reads one page of the event log projection, newest // loadEventLogRows reads the event log projection of the
// first, and the total number of events the pager counts against. // recentEventLimit newest events, newest first, and the total number
// of events stored.
func loadEventLogRows( func loadEventLogRows(
webhookDB *gorm.DB, webhookID string, page int, webhookDB *gorm.DB, webhookID string,
) ([]eventLogRow, int64) { ) ([]eventLogRow, int64) {
var totalEvents int64 var totalEvents int64
@@ -1320,9 +1301,7 @@ func loadEventLogRows(
eventLogColumns, maxRenderedBodyBytes, eventLogColumns, maxRenderedBodyBytes,
).Where( ).Where(
"webhook_id = ?", webhookID, "webhook_id = ?", webhookID,
).Order("created_at DESC").Offset( ).Order("created_at DESC").Limit(recentEventLimit).Find(&rows)
(page - 1) * paginationPerPage,
).Limit(paginationPerPage).Find(&rows)
return rows, totalEvents return rows, totalEvents
} }
@@ -1331,9 +1310,9 @@ func loadEventLogRows(
// events have been resubmitted from it. // events have been resubmitted from it.
// //
// One grouped query covers the page rather than one query per event. // One grouped query covers the page rather than one query per event.
// A page holds paginationPerPage ids, far below SQLite's bound // The page shows at most recentEventLimit events, far below SQLite's
// parameter ceiling, so it needs no chunking as the delivery result // bound parameter ceiling, so it needs no chunking as the delivery
// load does. // result load does.
func resubmitCounts( func resubmitCounts(
webhookDB *gorm.DB, eventIDs []string, webhookDB *gorm.DB, eventIDs []string,
) (map[string]int, error) { ) (map[string]int, error) {
@@ -1759,24 +1738,6 @@ func (h *Handlers) setTargetFromForm(
return "", nil 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 // targetFormInput carries the raw values of a target form. Both the
// create and the edit path fill one and hand it to setTargetFromForm, // create and the edit path fill one and hand it to setTargetFromForm,
// so neither can come to validate a target differently from the // so neither can come to validate a target differently from the
-17
View File
@@ -383,20 +383,3 @@ 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 "))
}
+12 -9
View File
@@ -21,6 +21,7 @@ import (
const ( const (
dataKeyWebhook = "Webhook" dataKeyWebhook = "Webhook"
dataKeyError = "Error" dataKeyError = "Error"
dataKeyEvents = "Events"
) )
// testWebhookID is the identifier given to the webhook under test on // testWebhookID is the identifier given to the webhook under test on
@@ -144,9 +145,10 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
t.Cleanup(app.RequireStop) t.Cleanup(app.RequireStop)
// A pointer, as in the handlers: source_detail.html calls // A pointer, as in the handlers: source_detail.html calls
// Webhook.RetentionLabel, a pointer method. Both pages only range // Webhook.RetentionLabel, a pointer method. The webhook page only
// over their lists, and a list left out renders as empty, so the // ranges over its lists, and a list left out renders as empty, so
// lists are left out. // its lists are left out. The event log also counts its events, so
// it gets an empty list.
webhook := &database.Webhook{Name: "wh", RetentionDays: 14} webhook := &database.Webhook{Name: "wh", RetentionDays: 14}
webhook.ID = testWebhookID webhook.ID = testWebhookID
@@ -169,6 +171,7 @@ func TestEventLogPageIsCalledFullEventLog(t *testing.T) {
logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{ logBody := renderPage(t, h, sess, "source_logs.html", map[string]any{
dataKeyWebhook: webhook, dataKeyWebhook: webhook,
dataKeyEvents: []handlers.EventLogView{},
"TotalEvents": int64(0), "TotalEvents": int64(0),
}) })
@@ -317,9 +320,9 @@ func TestEntrypointCopyButtonIsProgressiveEnhancement(t *testing.T) {
"Entrypoints": handlers.NewEntrypointViews( "Entrypoints": handlers.NewEntrypointViews(
[]database.Entrypoint{entrypoint}, []database.Entrypoint{entrypoint},
), ),
"Targets": delivery.NewTargetViews(nil), "Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{}, dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com", "BaseURL": "https://hooks.example.com",
}) })
assert.Contains( assert.Contains(
@@ -384,9 +387,9 @@ func TestTargetFormMaxRetriesCopyMatchesBehaviour(t *testing.T) {
"Entrypoints": handlers.NewEntrypointViews( "Entrypoints": handlers.NewEntrypointViews(
[]database.Entrypoint{entrypoint}, []database.Entrypoint{entrypoint},
), ),
"Targets": delivery.NewTargetViews(nil), "Targets": delivery.NewTargetViews(nil),
"Events": []database.Event{}, dataKeyEvents: []database.Event{},
"BaseURL": "https://hooks.example.com", "BaseURL": "https://hooks.example.com",
}, },
) )
+7 -6
View File
@@ -278,12 +278,13 @@ func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
// after the '?'. Keeping the path and dropping the query is what makes // after the '?'. Keeping the path and dropping the query is what makes
// this branch as bounded as the pattern branches below. // this branch as bounded as the pattern branches below.
// //
// Nothing debuggable is lost. One route in the service reads a query // Nothing debuggable is lost. The only query parameters the service
// parameter at all — `page`, on the authenticated pagination links in // reads are the login page's `next`, the page to return to, and
// internal/handlers/source_management.go — and the alternatives that // `notice`, which names the line a page shows after an action. The
// would preserve more (a key count, a key allowlist) all require // alternatives that would preserve more (a key count, a key
// parsing an attacker-sized query on every request, which is work an // allowlist) all require parsing an attacker-sized query on every
// unauthenticated client would then be choosing for us. // request, which is work an unauthenticated client would then be
// choosing for us.
func concreteLogURL(r *http.Request) string { func concreteLogURL(r *http.Request) string {
path := r.URL.EscapedPath() path := r.URL.EscapedPath()
+26 -18
View File
@@ -765,14 +765,15 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
"the event's own page does not show its body") "the event's own page does not show its body")
} }
// checkEventLog loads the event log and checks an event's row. Clicking // checkEventLog loads the event log and checks that of its events only
// its ID expands the event, and in there clicking its delivery shows the // the newest, eventID, starts expanded: its row says so and its caret is
// delivery's attempts and clicking again hides them. Clicking the row's // turned up, and the log's last event, lastEventID, starts collapsed. In
// caret collapses the event, clicking it again expands it, and clicking // the newest event, clicking its delivery shows the delivery's attempts
// the ID again collapses it. While the event is expanded the row says so // and clicking again hides them. Clicking the row's caret collapses the
// and its caret is turned up, and while it is collapsed neither. It then // event, clicking it again expands it, clicking its ID collapses it and
// runs checkEventSelection on the log's last event, lastEventID, and // clicking the ID again expands it. While the event is collapsed the row
// checkEventKeyboard on eventID. // says so and its caret is turned down. It then runs checkEventSelection
// on lastEventID and checkEventKeyboard on eventID.
func checkEventLog( func checkEventLog(
ctx context.Context, ctx context.Context,
t *testing.T, t *testing.T,
@@ -790,20 +791,19 @@ func checkEventLog(
caretUp := caret + `[contains(@class, "rotate-180")]` caretUp := caret + `[contains(@class, "rotate-180")]`
caretDown := caret + `[not(contains(@class, "rotate-180"))]` caretDown := caret + `[not(contains(@class, "rotate-180"))]`
expanded := `form[action$="/` + eventID + `/resubmit"]` expanded := `form[action$="/` + eventID + `/resubmit"]`
lastExpanded := `form[action$="/` + lastEventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]` deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]` attempt := `//span[text()="Attempt 1"]`
require.NoError(t, chromedp.Run(ctx, loadPage(url))) require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, hidden(ctx, expanded), "the event starts expanded") assert.True(t, shown(ctx, expanded), "the newest event starts collapsed")
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"]`), assert.True(t, shown(ctx, row+`[@aria-expanded="true"]`),
"the expanded event's row does not say it is expanded") "the expanded event's row does not say it is expanded")
assert.True(t, shown(ctx, caretUp), assert.True(t, shown(ctx, caretUp),
"the expanded event's caret does not turn up") "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") assert.True(t, hidden(ctx, attempt), "the delivery's attempts start shown")
@@ -829,7 +829,11 @@ func checkEventLog(
click(ctx, t, id) click(ctx, t, id)
assert.True(t, hidden(ctx, expanded), assert.True(t, hidden(ctx, expanded),
"clicking the event's ID again does not collapse it") "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")
checkEventSelection(ctx, t, url, lastEventID) checkEventSelection(ctx, t, url, lastEventID)
checkEventKeyboard(ctx, t, url, eventID) checkEventKeyboard(ctx, t, url, eventID)
@@ -878,8 +882,11 @@ func checkEventSelection(
`window.getSelection().removeAllRanges()`, nil, `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( require.NoError(t, chromedp.Run(
ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url), ctx, chromedp.EmulateViewport(shortWidth, shortHeight), loadPage(url),
chromedp.ScrollIntoView(id, chromedp.BySearch),
)) ))
selectText(ctx, t, id) selectText(ctx, t, id)
@@ -922,8 +929,9 @@ func checkEventSelection(
} }
// checkEventKeyboard loads the event log and checks that Tab from the // checkEventKeyboard loads the event log and checks that Tab from the
// page's Back link reaches the event's row, the first after it, and that // page's Back link reaches the row of the newest event, the first after
// Enter then expands the event and Space collapses it. // it, and that Enter then collapses that event, which starts expanded,
// and Space expands it again.
func checkEventKeyboard( func checkEventKeyboard(
ctx context.Context, t *testing.T, url, eventID string, ctx context.Context, t *testing.T, url, eventID string,
) { ) {
@@ -945,10 +953,10 @@ func checkEventKeyboard(
"Tab from the Back link does not reach the event's row") "Tab from the Back link does not reach the event's row")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter))) require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(kb.Enter)))
assert.True(t, shown(ctx, expanded), "Enter does not expand the event") assert.True(t, hidden(ctx, expanded), "Enter does not collapse the event")
require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" "))) require.NoError(t, chromedp.Run(ctx, chromedp.KeyEvent(" ")))
assert.True(t, hidden(ctx, expanded), "Space does not collapse the event") assert.True(t, shown(ctx, expanded), "Space does not expand the event")
} }
// selectText selects the text of the element matching an XPath // selectText selects the text of the element matching an XPath
+3 -11
View File
@@ -1379,9 +1379,9 @@ func TestHook_ResubmitFromEventLog(t *testing.T) {
// TestHook_LinksBetweenPages follows each link to a webhook page that // TestHook_LinksBetweenPages follows each link to a webhook page that
// the tests above do not: the navbar's "Webhooks" links, the back and // the tests above do not: the navbar's "Webhooks" links, the back and
// Cancel links, the list's link to a webhook, the "Full Event Log" // Cancel links, the list's link to a webhook, and the "Full Event Log"
// link beside the recent events, and the event log's page links. Each // link beside the recent events. Each must point where it should, and
// must point where it should, and that page must render. // that page must render.
func TestHook_LinksBetweenPages(t *testing.T) { func TestHook_LinksBetweenPages(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1392,12 +1392,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
wh := env.seedWebhook(t, userID) wh := env.seedWebhook(t, userID)
tgt := env.seedTarget(t, wh.ID) 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" list := "/hooks"
newForm := list + "/new" newForm := list + "/new"
page := "/hook/" + wh.ID page := "/hook/" + wh.ID
@@ -1429,8 +1423,6 @@ func TestHook_LinksBetweenPages(t *testing.T) {
{targetEdit, back, page}, {targetEdit, back, page},
{targetEdit, cancel, page}, {targetEdit, cancel, page},
{events, back, 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) got := env.urlFrom(t, link.from, link.pattern, cookies)
assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern) assert.Equal(t, link.want, got, "%s: %s", link.from, link.pattern)
+3 -2
View File
@@ -218,8 +218,9 @@ func keptSentryHeaders(headers map[string]string) map[string]string {
// sentryKeepsHeader reports whether a request header is routing or // sentryKeepsHeader reports whether a request header is routing or
// content metadata rather than client-chosen payload. Referer is kept // content metadata rather than client-chosen payload. Referer is kept
// on the reasoning that it is browser-set, that this service emits // on the reasoning that it is browser-set, that the only query
// only ?page= in its own links, and that Referrer-Policy is set to // parameters in this service's own URLs are the login page's `next`
// and `notice`, and that Referrer-Policy is set to
// strict-origin-when-cross-origin. X-Request-Id ties the event to the // strict-origin-when-cross-origin. X-Request-Id ties the event to the
// local access log line, which holds the rest of the detail. // local access log line, which holds the rest of the detail.
func sentryKeepsHeader(name string) bool { func sentryKeepsHeader(name string) bool {
File diff suppressed because one or more lines are too long
+4 -18
View File
@@ -8,14 +8,15 @@
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a> <a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<div class="flex justify-between items-center mt-2"> <div class="flex justify-between items-center mt-2">
<h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1> <h1 class="text-2xl font-medium text-gray-900">Full Event Log</h1>
<span class="text-sm text-gray-500">{{.TotalEvents}} total event{{if ne .TotalEvents 1}}s{{end}}</span> <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>
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<div class="divide-y divide-gray-100"> <div class="divide-y divide-gray-100">
{{range .Events}} <!-- Only the newest event starts expanded. -->
<div class="p-4" x-data="collapsible"> {{range $i, $event := .Events}}
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
<!-- Not a button element: browsers do not let a button's text be selected, and an event's ID must be. --> <!-- 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"> <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"> <span class="flex flex-wrap items-center gap-3">
@@ -51,7 +52,6 @@
</div> </div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <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> <button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form> </form>
</div> </div>
@@ -79,7 +79,6 @@
{{if and .Status.Terminal (not .Target.Deleted)}} {{if and .Status.Terminal (not .Target.Deleted)}}
<form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline"> <form method="POST" action="/hook/{{$.Webhook.ID}}/deliveries/{{.ID}}/replay" class="inline">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"> <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> <button type="submit" class="btn-small" title="Send this event to the target again">Replay</button>
</form> </form>
{{end}} {{end}}
@@ -100,18 +99,5 @@
{{end}} {{end}}
</div> </div>
</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> </div>
{{end}} {{end}}