1 Commits
Author SHA1 Message Date
clawbot f346b69999 Give each event its own page and show bodies the same everywhere (closes #369)
check / check (push) Waiting to run
Each row of the recent events on the webhook page links to the
event's own page, /hook/{id}/events/{eventID}, and expands to show its
body; only the newest starts expanded. The event's page shows its
details, its whole body and every delivery with its attempts.

One renderer, newBodyView with templates/event_body.html, shows a body
in all three places: whole up to 32 KiB, cut there in the lists with a
link to the event's page, JSON pretty-printed, a body of more than 200
lines or 32 KiB in a scrolling box, and a body that is not text left
out beside its download link. A resubmitted copy links to its
original's page.

Model: opus-5-5
2026-10-02 17:10:02 +00:00
22 changed files with 959 additions and 170 deletions
+5 -2
View File
@@ -1327,7 +1327,9 @@ under the real policy and checks that: both add forms stay hidden until Add is
clicked; choosing Slack in the add target form leaves the HTTP fields out of
what it submits, also after leaving the page and going back to it, when the
browser restores the choice; the Copy button beside an entrypoint URL reads
"Copied" once clicked; an event expands and collapses, and so do a delivery's
"Copied" once clicked; 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 event log expands and collapses, and so do a delivery's
attempts inside it; 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
@@ -2889,7 +2891,8 @@ returns to the page that was asked for.
| `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}/body` | Download an event's full stored body. The log page renders each body only up to its cap, so this is the only route that serves a whole one; it is offered wherever a body is shown truncated |
| `GET` | `/hook/{id}/events/{eventID}` | One event's own page: its details, its whole body and every delivery of it |
| `GET` | `/hook/{id}/events/{eventID}/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`) |
| `POST` | `/hook/{id}/entrypoints` | Add entrypoint to webhook |
+4 -3
View File
@@ -22,9 +22,10 @@ import (
const eventBodyQuery = "SELECT cast(body as blob) " +
"FROM events WHERE id = ? AND webhook_id = ? AND deleted_at IS NULL"
// HandleEventBodyDownload serves one event's stored body in
// full, which the event log page cannot: it caps each rendered
// body at maxRenderedBodyBytes.
// HandleEventBodyDownload serves one event's stored body byte
// for byte, which the pages do not: they show it as escaped
// text, cut at maxRenderedBodyBytes in the lists of events, and
// leave a binary one out.
//
// The bytes are attacker-supplied — anyone who can reach the
// public receiver chooses them — and this route hands them back
+147
View File
@@ -0,0 +1,147 @@
package handlers
import (
"bytes"
"encoding/json"
"errors"
"io"
"unicode/utf8"
)
// maxRenderedBodyBytes is the most of one event's body that the
// recent events on a webhook's page and the event log show; a larger
// body is cut there and shown whole only on the event's own page.
// Bodies come from the unauthenticated receiver under its 1 MB cap,
// and renderTemplate buffers a whole page before writing it, so a list
// of events cannot show every body whole.
const maxRenderedBodyBytes = 32 << 10
// maxInlineBodyLines is the most lines a body is shown at its full
// height with. A body with more lines, or larger than
// maxRenderedBodyBytes, is shown in a box of fixed height that
// scrolls, so that it does not make the page huge.
const maxInlineBodyLines = 200
// maxIndentGrowth is how many times its size a JSON body may grow
// when it is pretty-printed; past that it is shown as received.
// Indenting grows with nesting depth as well as with size: 20 KB of
// nested brackets indents to some 200 MB.
const maxIndentGrowth = 4
// jsonIndent is the indent of a pretty-printed JSON body.
const jsonIndent = " "
// BodyView is an event's body as the pages show it. newBodyView
// decides it and templates/event_body.html shows it, the same way in
// the recent events on a webhook's page, in the event log and on the
// event's own page.
type BodyView struct {
// EventURL is the event's own page. The stored body downloads
// from EventURL/body.
EventURL string
// Text is the body as shown, pretty-printed when it is JSON.
Text string
// Size is the stored body's size in bytes, and ShownBytes how
// many of them Text holds when Cut.
Size int64
ShownBytes int
// Cut reports that Text is only the start of the body.
Cut bool
// Binary reports a body that is not text. It is not shown.
Binary bool
// Scroll reports a body to show in a box that scrolls.
Scroll bool
}
// newBodyView decides how to show an event's body. body is the
// stored body, or its first maxRenderedBodyBytes when only those were
// read, and size is the stored body's size.
func newBodyView(eventURL string, body []byte, size int64) BodyView {
v := BodyView{EventURL: eventURL, Size: size}
if size > int64(len(body)) {
v.Cut = true
body = trimPartialRune(body)
v.ShownBytes = len(body)
}
// html/template shows invalid UTF-8 and NUL bytes as replacement
// characters, so a body holding either is not text.
if !utf8.Valid(body) || bytes.IndexByte(body, 0) >= 0 {
v.Binary = true
return v
}
// A cut JSON document is no longer valid JSON.
if !v.Cut {
body = indentJSON(body)
}
lines := bytes.Count(body, []byte("\n")) + 1
v.Text = string(body)
v.Scroll = lines > maxInlineBodyLines || size > maxRenderedBodyBytes
return v
}
// indentJSON returns body pretty-printed when it is a JSON document,
// and unchanged when it is not or would grow more than
// maxIndentGrowth times.
func indentJSON(body []byte) []byte {
if !json.Valid(body) || !indentFits(body) {
return body
}
var out bytes.Buffer
err := json.Indent(&out, body, "", jsonIndent)
if err != nil {
return body
}
return out.Bytes()
}
// indentFits reports whether pretty-printing the JSON document body
// keeps it within maxIndentGrowth times its size. It adds up an upper
// bound instead of indenting: each token starts at most one line,
// indented once per enclosing object or array, and a key gains the
// space after its colon.
func indentFits(body []byte) bool {
limit := maxIndentGrowth * len(body)
size, depth := len(body), 0
dec := json.NewDecoder(bytes.NewReader(body))
// A number too large for a float64 is still valid JSON.
dec.UseNumber()
for size <= limit {
tok, err := dec.Token()
if errors.Is(err, io.EOF) {
return true
}
if err != nil {
return false
}
switch tok {
case json.Delim('{'), json.Delim('['):
depth++
case json.Delim('}'), json.Delim(']'):
depth--
}
size += len("\n") + depth*len(jsonIndent) + len(" ")
}
return false
}
+110
View File
@@ -0,0 +1,110 @@
package handlers_test
import (
"strings"
"testing"
"github.com/stretchr/testify/assert"
"sneak.berlin/go/webhooker/internal/handlers"
)
// bodyView is how the pages would show body, stored whole.
func bodyView(body string) handlers.BodyView {
return handlers.NewBodyViewForTest([]byte(body), int64(len(body)))
}
// lines is n lines of text, without a newline after the last.
func lines(n int) string {
return strings.TrimSuffix(strings.Repeat("line\n", n), "\n")
}
// TestNewBodyView_FormatsValidJSON proves a JSON body is shown
// pretty-printed, whatever its content type, with its keys in
// the order they arrived.
func TestNewBodyView_FormatsValidJSON(t *testing.T) {
t.Parallel()
v := bodyView(`{"b":1,"a":[true,null,"x"],"c":{}}`)
assert.Equal(t, []string{
`{`,
` "b": 1,`,
` "a": [`,
` true,`,
` null,`,
` "x"`,
` ],`,
` "c": {}`,
`}`,
}, strings.Split(v.Text, "\n"))
assert.False(t, v.Scroll)
}
// TestNewBodyView_InvalidJSONAsReceived proves a body that is not
// a JSON document is shown exactly as it arrived.
func TestNewBodyView_InvalidJSONAsReceived(t *testing.T) {
t.Parallel()
for _, body := range []string{
`{"a":1,`,
`{"a":1} {"b":2}`,
"plain text\n indented",
} {
assert.Equal(t, body, bodyView(body).Text)
}
}
// TestNewBodyView_DeepJSONAsReceived proves a JSON body that
// indenting would grow out of all proportion is shown as it
// arrived. 10 KB of nested arrays would indent to some 50 MB.
func TestNewBodyView_DeepJSONAsReceived(t *testing.T) {
t.Parallel()
body := strings.Repeat("[", 5000) + strings.Repeat("]", 5000)
assert.Equal(t, body, bodyView(body).Text)
}
// TestNewBodyView_ScrollsPast200Lines proves a body is shown at
// its full height up to 200 lines and in the scrolling box past
// them, counting the lines after formatting.
func TestNewBodyView_ScrollsPast200Lines(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(lines(200)).Scroll)
assert.True(t, bodyView(lines(201)).Scroll)
// One line as received, 201 once formatted: the brackets and
// 199 elements.
numbers := "[" + strings.TrimSuffix(strings.Repeat("1,", 199), ",") + "]"
assert.NotContains(t, numbers, "\n")
assert.True(t, bodyView(numbers).Scroll)
}
// TestNewBodyView_LargeBodyScrolls proves a body larger than the
// cap of the lists of events is shown in the scrolling box
// however few lines it has, on the event's own page as in the
// lists.
func TestNewBodyView_LargeBodyScrolls(t *testing.T) {
t.Parallel()
assert.False(t, bodyView(strings.Repeat("x", bodyCap)).Scroll)
assert.True(t, bodyView(strings.Repeat("x", bodyCap+1)).Scroll)
}
// TestNewBodyView_BinaryNotShown proves a body that is not text
// is never shown, since html/template would turn it into
// replacement characters.
func TestNewBodyView_BinaryNotShown(t *testing.T) {
t.Parallel()
for _, body := range []string{"\xff\xfe\xfd", "a\x00b"} {
v := bodyView(body)
assert.True(t, v.Binary)
assert.Empty(t, v.Text)
}
assert.False(t, bodyView("snow "+snowman).Binary)
}
+74
View File
@@ -0,0 +1,74 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi"
"sneak.berlin/go/webhooker/internal/database"
)
// HandleEventDetail shows one event on its own page: its details,
// its whole body and every delivery of it. The page reads the
// event's body whole, which the receiver caps at 1 MB.
func (h *Handlers) HandleEventDetail() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
webhook, ok := h.ownedWebhook(w, r)
if !ok {
return
}
if !h.dbMgr.DBExists(webhook.ID) {
h.renderError(w, r, http.StatusNotFound)
return
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to get webhook database", err)
return
}
var rows []eventLogRow
err = webhookDB.Model(&database.Event{}).
Select(eventColumns).
Where(
"id = ? AND webhook_id = ?",
chi.URLParam(r, "eventID"), webhook.ID,
).
Limit(1).
Find(&rows).Error
if err != nil {
h.serverError(w, r, "failed to load event", err)
return
}
if len(rows) == 0 {
h.renderError(w, r, http.StatusNotFound)
return
}
targets, err := h.loadTargetMap(webhook.ID)
if err != nil {
h.serverError(w, r, "failed to load targets", err)
return
}
views, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targets,
)
if !ok {
return
}
h.renderTemplate(w, r, "event_detail.html", map[string]any{
tmplKeyWebhook: &webhook,
"Event": views[0],
})
}
}
+152
View File
@@ -0,0 +1,152 @@
package handlers_test
import (
"context"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// serveEventPage runs the real event page handler as the test user
// for the given webhook and event ids.
func serveEventPage(
t *testing.T,
h *handlers.Handlers,
sess *session.Session,
webhookID, eventID string,
) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodGet,
"/hook/"+webhookID+"/events/"+eventID,
nil,
)
for _, c := range authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
) {
req.AddCookie(c)
}
rctx := chi.NewRouteContext()
rctx.URLParams.Add(paramSourceID, webhookID)
rctx.URLParams.Add(paramEventID, eventID)
req = req.WithContext(
context.WithValue(req.Context(), chi.RouteCtxKey, rctx),
)
w := httptest.NewRecorder()
h.HandleEventDetail().ServeHTTP(w, req)
return w
}
// TestHandleEventDetail_ShowsEventWholeWithDeliveries proves the
// event's page shows its details, its whole body even past the cap
// of the lists of events, pretty-printed and in the scrolling box,
// and each delivery with its status and attempts.
func TestHandleEventDetail_ShowsEventWholeWithDeliveries(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
target := seedTarget(t, f.db, f.webhook.ID, database.TargetTypeHTTP)
const sentinel = "TAIL-SENTINEL-5b2e"
body := `{"pad":"` + strings.Repeat("x", 2*bodyCap) +
`","tail":"` + sentinel + `"}`
event := f.event(t, contentTypeJSON, body, time.Now())
f.attempt(t, f.delivery(
t, event, target.ID, database.DeliveryStatusFailed,
), http.StatusBadGateway, time.Second)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, event.ID)
require.Equal(t, http.StatusOK, w.Code)
page := w.Body.String()
assert.Contains(t, page, event.ID)
assert.Contains(t, page, contentTypeJSON)
assert.Contains(t, page, strconv.Itoa(len(body))+" bytes")
assert.Contains(t, page, "{\n &#34;pad&#34;: &#34;xxx")
assert.Contains(t, page, "&#34;tail&#34;: &#34;"+sentinel+"&#34;\n}")
assert.Contains(t, page, `style="max-height: 32rem; overflow-y: auto"`)
assert.NotContains(t, page, "Showing the first")
assert.Contains(t, page, target.Name)
assert.Contains(t, page, ">failed</span>")
assert.Contains(t, page, "Status: 502")
}
// TestHandleEventDetail_ResubmitLinks proves a resubmitted copy's
// page links to its original's page, and the original's page says
// it was resubmitted.
func TestHandleEventDetail_ResubmitLinks(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
original := f.event(t, contentTypeJSON, "{}", time.Now())
cp := &database.Event{
WebhookID: f.webhook.ID,
Method: http.MethodPost,
Body: "{}",
ContentType: contentTypeJSON,
ResubmittedFromID: &original.ID,
}
require.NoError(t, f.webhookDB.Omit(clause.Associations).Create(cp).Error)
w := serveEventPage(t, f.h, f.sess, f.webhook.ID, cp.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(
t, w.Body.String(),
`href="/hook/`+f.webhook.ID+`/events/`+original.ID+`"`,
)
w = serveEventPage(t, f.h, f.sess, f.webhook.ID, original.ID)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "as 1 new event<")
}
// TestHandleEventDetail_UnknownEventNotFound proves the page is a
// 404 for an event that does not exist and for one that belongs to
// another webhook.
func TestHandleEventDetail_UnknownEventNotFound(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)
mine := seedWebhook(t, db)
theirs := seedWebhook(t, db)
seedEventWithBody(t, dbMgr, mine.ID, "{}")
elsewhere := seedEventWithBody(t, dbMgr, theirs.ID, "{}")
for _, id := range []string{"no-such-event", elsewhere.ID} {
w := serveEventPage(t, h, sess, mine.ID, id)
assert.Equal(t, http.StatusNotFound, w.Code, id)
}
}
+24 -51
View File
@@ -5,14 +5,6 @@ import (
"unicode/utf8"
)
// maxRenderedBodyBytes caps how many bytes of a stored event
// body reach the event log page. Bodies come from the
// unauthenticated receiver under the 1 MB ingest cap and
// renderTemplate buffers a whole page before writing it, so
// an uncapped page of paginationPerPage events is tens of
// megabytes of resident memory per concurrent viewer.
const maxRenderedBodyBytes = 8192
// eventLogColumns is the event log's projection. The casts to
// blob are load-bearing: they make substr and length count
// bytes rather than characters, so the cap bounds the page in
@@ -24,27 +16,23 @@ const eventLogColumns = "id, created_at, method, content_type, " +
"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.
const eventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, " +
"cast(body as blob) AS body, " +
"length(cast(body as blob)) AS body_bytes"
// EventLogView is the display-safe projection of an event for
// the event log page, alongside DeliveryView and TargetView.
// It carries a capped body plus the true stored size, so the
// page can mark a body as truncated without ever holding the
// whole thing.
// the event log page and the event's own page, alongside
// DeliveryView and TargetView.
type EventLogView struct {
ID string
CreatedAt time.Time
Method string
ContentType string
// Body holds at most maxRenderedBodyBytes bytes of the
// stored body.
Body string
// BodyBytes is the true size of the stored body.
BodyBytes int64
// BodyTruncated reports that the stored body was larger
// than the cap, so the page owes the reader a marker.
BodyTruncated bool
Body BodyView
// ResubmittedFromID names the event this one was copied
// from, empty for an event that arrived on the receiver.
@@ -65,16 +53,10 @@ func (v EventLogView) ResubmittedFrom() bool {
return v.ResubmittedFromID != ""
}
// BodyShownBytes is how many body bytes the page is actually
// rendering, which the truncation marker reports beside the
// true size.
func (v EventLogView) BodyShownBytes() int {
return len(v.Body)
}
// eventLogRow is one row of the event log projection. Its
// body column arrives already cut to the cap by SQLite, with
// the true size beside it.
// 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.
type eventLogRow struct {
ID string
CreatedAt time.Time
@@ -85,31 +67,22 @@ type eventLogRow struct {
BodyBytes int64
}
// view projects a loaded row for rendering.
func (r *eventLogRow) view() EventLogView {
body := r.Body
truncated := r.BodyBytes > int64(len(body))
// Only a cut body can have been left mid-sequence by
// this query. A whole body is passed through exactly as
// stored, however malformed.
if truncated {
body = trimPartialRune(body)
}
// view projects a loaded row of the webhook's events for
// rendering.
func (r *eventLogRow) view(webhookID string) EventLogView {
var from string
if r.ResubmittedFromID != nil {
from = *r.ResubmittedFromID
}
return EventLogView{
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: string(body),
BodyBytes: r.BodyBytes,
BodyTruncated: truncated,
ID: r.ID,
CreatedAt: r.CreatedAt,
Method: r.Method,
ContentType: r.ContentType,
Body: newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, r.BodyBytes,
),
ResubmittedFromID: from,
}
}
+31 -21
View File
@@ -16,7 +16,7 @@ import (
"sneak.berlin/go/webhooker/internal/session"
)
// bodyCap is the number of body bytes the event log page is
// bodyCap is the number of body bytes the lists of events are
// allowed to render for one event.
const bodyCap = handlers.MaxRenderedBodyBytesForTest
@@ -85,7 +85,7 @@ func seedAndProject(
// TestHandleSourceLogs_BoundsOversizeBody proves the rendered
// page is bounded by the cap rather than by the stored payload:
// the body here is 64 times the cap, and the ingest path would
// the body here is 16 times the cap, and the ingest path would
// accept twice as much again.
func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
t.Parallel()
@@ -123,7 +123,7 @@ func TestHandleSourceLogs_BoundsOversizeBody(t *testing.T) {
// The marker states the true stored size, not the cut one.
assert.Contains(
t, page,
"showing "+strconv.Itoa(bodyCap)+
"Showing the first "+strconv.Itoa(bodyCap)+
" of "+strconv.Itoa(storedBytes)+" bytes",
)
}
@@ -152,34 +152,35 @@ func TestHandleSourceLogs_SmallBodyRendersWhole(t *testing.T) {
page := renderSourceLogsPage(t, h, sess, wh.ID)
assert.Contains(t, page, "&#34;kept&#34;")
assert.NotContains(t, page, "Body truncated for display")
assert.NotContains(t, page, "Showing the first")
}
// TestEventLogView_CutMidRune proves a multi-byte rune severed
// by the byte-wise cut is dropped rather than surfaced as a
// mojibake tail.
// mojibake tail, which would also make the text look binary.
func TestEventLogView_CutMidRune(t *testing.T) {
t.Parallel()
body := strings.Repeat(snowman, 4096)
body := strings.Repeat(snowman, bodyCap)
view := seedAndProject(t, body)
// bodyCap bytes hold bodyCap/3 whole snowmen and two bytes
// of the next one; those two are dropped.
whole := bodyCap / len(snowman)
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(body)), view.BodyBytes)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body)
assert.True(t, utf8.ValidString(view.Body))
assert.LessOrEqual(t, len(view.Body), bodyCap)
assert.True(t, view.Body.Cut)
assert.False(t, view.Body.Binary)
assert.Equal(t, int64(len(body)), view.Body.Size)
assert.Equal(t, strings.Repeat(snowman, whole), view.Body.Text)
assert.True(t, utf8.ValidString(view.Body.Text))
assert.Equal(t, len(view.Body.Text), view.Body.ShownBytes)
assert.LessOrEqual(t, view.Body.ShownBytes, bodyCap)
}
// TestEventLogView_BinaryBodyLeftAsStored proves a binary
// payload is passed through byte for byte. Its tail is invalid
// UTF-8 however the cut falls, so repairing it would misreport
// what the sender delivered.
func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
// TestEventLogView_BinaryBodyNotShown proves a body that is not
// text is left out rather than shown as replacement characters,
// whether it is cut or not.
func TestEventLogView_BinaryBodyNotShown(t *testing.T) {
t.Parallel()
raw := make([]byte, bodyCap+808)
@@ -188,12 +189,21 @@ func TestEventLogView_BinaryBodyLeftAsStored(t *testing.T) {
raw[i] = 0x80 | byte(i%0x40)
}
view := seedAndProject(t, string(raw))
for name, body := range map[string][]byte{
"cut": raw,
"whole": raw[:2048],
"NUL": []byte("text\x00text"),
} {
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.True(t, view.BodyTruncated)
assert.Equal(t, int64(len(raw)), view.BodyBytes)
assert.Equal(t, string(raw[:bodyCap]), view.Body)
assert.False(t, utf8.ValidString(view.Body))
view := seedAndProject(t, string(body))
assert.True(t, view.Body.Binary)
assert.Empty(t, view.Body.Text)
assert.Equal(t, int64(len(body)), view.Body.Size)
})
}
}
// TestTrimPartialRune covers the distinction the cut repair
+8 -2
View File
@@ -19,10 +19,16 @@ func (s *Handlers) SetLogForTest(log *slog.Logger) {
s.log = log
}
// MaxRenderedBodyBytesForTest exposes the event log's body cap
// to the handlers_test package.
// MaxRenderedBodyBytesForTest exposes the body cap of the lists
// of events to the handlers_test package.
const MaxRenderedBodyBytesForTest = maxRenderedBodyBytes
// NewBodyViewForTest exposes newBodyView for use in the
// handlers_test package.
func NewBodyViewForTest(body []byte, size int64) BodyView {
return newBodyView("/hook/w/events/e", body, size)
}
// MaxRenderedResponseBytesForTest exposes the event log's
// delivery response cap to the handlers_test package.
const MaxRenderedResponseBytesForTest = maxRenderedResponseBytes
+18 -11
View File
@@ -149,16 +149,23 @@ func New(
// Parse all page templates once at startup
s.templates = map[string]*template.Template{
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate("source_logs.html"),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
"login.html": parsePageTemplate("login.html"),
"profile.html": parsePageTemplate("profile.html"),
"settings.html": parsePageTemplate("settings.html"),
"sources_list.html": parsePageTemplate("sources_list.html"),
"sources_new.html": parsePageTemplate("sources_new.html"),
"source_detail.html": parsePageTemplate(
"source_detail.html", "webhook_stats.html", "event_body.html",
),
"source_edit.html": parsePageTemplate("source_edit.html"),
"source_logs.html": parsePageTemplate(
"source_logs.html", "event_body.html", "delivery_attempts.html",
),
"event_detail.html": parsePageTemplate(
"event_detail.html", "event_body.html", "delivery_attempts.html",
),
"target_edit.html": parsePageTemplate("target_edit.html"),
"error.html": parsePageTemplate("error.html"),
}
lc.Append(fx.Hook{
@@ -385,7 +392,7 @@ func (s *Handlers) pageData(
// partial body and the status before a mid-render error can be
// reported, leaving no way to serve a 500. Buffering makes a page's
// rendered size resident memory per concurrent viewer, so every page
// owes it a bound: the event log caps each stored body at
// owes it a bound: the lists of events cap each stored body at
// maxRenderedBodyBytes for exactly this reason.
func (s *Handlers) executeTemplate(
w http.ResponseWriter,
+20 -8
View File
@@ -12,11 +12,12 @@ import (
)
// recentEventColumns is the recent events list's projection. It
// leaves out the body, for the reason maxRenderedBodyBytes gives,
// and reads its size from body_bytes, recorded when the event was
// reads the body cut to maxRenderedBodyBytes, as eventLogColumns
// does, and its size from body_bytes, recorded when the event was
// stored.
const recentEventColumns = "id, created_at, method, content_type, " +
"resubmitted_from_id, body_bytes"
"resubmitted_from_id, body_bytes, " +
"substr(cast(body as blob), 1, ?) AS body"
// recentAttemptColumns is the part of a recorded attempt the list
// uses. The event log's deliveryResultColumns also reads response
@@ -50,6 +51,9 @@ type RecentEventView struct {
// unless the webhook has exactly one HTTP target.
Status string
StatusClass string
// Body is what the row shows when it is expanded.
Body BodyView
}
// recentEventRow is one row of recentEventColumns.
@@ -60,6 +64,7 @@ type recentEventRow struct {
ContentType string
ResubmittedFromID *string
BodyBytes uint64
Body []byte
}
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
@@ -100,7 +105,7 @@ func loadRecentEvents(
var rows []recentEventRow
err := webhookDB.Model(&database.Event{}).
Select(recentEventColumns).
Select(recentEventColumns, maxRenderedBodyBytes).
Where("webhook_id = ?", webhookID).
Order("created_at DESC").
Limit(recentEventLimit).
@@ -145,7 +150,7 @@ func loadRecentEvents(
views := make([]RecentEventView, len(rows))
for i := range rows {
views[i] = rows[i].view(
byEvent[rows[i].ID], attempts, statusTargetID,
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
)
}
@@ -182,14 +187,20 @@ func loadRecentAttempts(
return byDelivery, nil
}
// view projects a loaded row for rendering. deliveries is the
// event's deliveries, oldest first, and attempts their recorded
// attempts keyed by delivery ID.
// view projects a loaded row of the webhook's events for
// rendering. deliveries is the event's deliveries, oldest first,
// and attempts their recorded attempts keyed by delivery ID.
func (r *recentEventRow) view(
webhookID string,
deliveries []database.Delivery,
attempts map[string][]recentAttemptRow,
statusTargetID string,
) RecentEventView {
//nolint:gosec // body_bytes is at most the receiver's 1 MB cap
body := newBodyView(
"/hook/"+webhookID+"/events/"+r.ID, r.Body, int64(r.BodyBytes),
)
v := RecentEventView{
Method: r.Method,
ContentType: r.ContentType,
@@ -197,6 +208,7 @@ func (r *recentEventRow) view(
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
Size: humanize.Bytes(r.BodyBytes),
ProcessingTime: processingTime(deliveries, attempts),
Body: body,
}
if r.ResubmittedFromID != nil {
+68
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -301,6 +302,73 @@ func TestHandleSourceDetail_NoStatusWithoutSingleHTTPTarget(
}
}
// TestHandleSourceDetail_RecentEventsLinkAndExpand proves each row
// links to its event's own page and expands to show its body, and
// that only the newest row starts expanded.
func TestHandleSourceDetail_RecentEventsLinkAndExpand(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
older := f.event(
t, contentTypeJSON, `{"which":"older"}`, now.Add(-time.Minute),
)
newer := f.event(t, contentTypeJSON, `{"which":"newer"}`, now)
body := f.render(t)
for _, e := range []*database.Event{older, newer} {
assert.Contains(
t, body, `href="/hook/`+f.webhook.ID+`/events/`+e.ID+`"`,
)
}
assert.Equal(t, 2, strings.Count(body, `<div x-show="open" x-cloak class="mt-3">`))
assert.Equal(t, 1, strings.Count(body, " data-open>"))
open := strings.Index(body, " data-open>")
newerBody := strings.Index(body, "&#34;which&#34;: &#34;newer&#34;")
olderBody := strings.Index(body, "&#34;which&#34;: &#34;older&#34;")
assert.Less(t, open, newerBody, "the newest row is not the open one")
assert.Less(t, newerBody, olderBody)
}
// TestHandleSourceDetail_RecentEventBodyCut proves a body up to
// the cap is shown whole and pretty-printed, and a larger one only
// its first bodyCap bytes, as received, with links to the whole
// body on the event's page and to the download.
func TestHandleSourceDetail_RecentEventBodyCut(t *testing.T) {
t.Parallel()
f := newRecentEventsFixture(t)
now := time.Now()
// A JSON document of n bytes.
document := func(n int) string {
return `{"pad":"` + strings.Repeat("x", n-len(`{"pad":""}`)) + `"}`
}
whole := f.event(
t, contentTypeJSON, document(bodyCap), now.Add(-time.Minute),
)
cut := f.event(t, contentTypeJSON, document(bodyCap+1), now)
body := f.render(t)
eventURL := `href="/hook/` + f.webhook.ID + `/events/`
assert.Equal(t, 1, strings.Count(body, "{\n &#34;pad&#34;: "))
assert.Contains(t, body, "{&#34;pad&#34;:&#34;xxx")
assert.Contains(
t, body,
"Showing the first "+strconv.Itoa(bodyCap)+" of "+
strconv.Itoa(bodyCap+1)+" bytes, unformatted.",
)
assert.Contains(t, body, eventURL+cut.ID+`/body"`)
assert.NotContains(t, body, eventURL+whole.ID+`/body"`)
}
// TestHandleWebhook_RecordsBodySize proves the receiver records the
// body's size in bytes, not characters, with the event it stores.
func TestHandleWebhook_RecordsBodySize(t *testing.T) {
+29 -12
View File
@@ -1022,10 +1022,10 @@ func (h *Handlers) HandleSourceLogs() http.HandlerFunc {
// view, which renders its target as a blank name.
//
// This map is historical display only. It is built for the event
// log page and reaches nothing but DeliveryView.Target: the
// target list on the source detail page, the edit form and the
// replay path each resolve targets themselves, and a deleted row
// is refused there as before.
// log and an event's own page, and reaches nothing but
// DeliveryView.Target: the target list on the source detail page,
// the edit form and the replay path each resolve targets
// themselves, and a deleted row is refused there as before.
func (h *Handlers) loadTargetMap(
webhookID string,
) (map[string]eventLogTarget, error) {
@@ -1081,10 +1081,8 @@ func (h *Handlers) loadEventsWithDeliveries(
targetMap map[string]eventLogTarget,
page int,
) ([]EventLogView, int64, bool) {
var result []EventLogView
if !h.dbMgr.DBExists(webhook.ID) {
return result, 0, true
return nil, 0, true
}
webhookDB, err := h.dbMgr.GetDB(webhook.ID)
@@ -1100,7 +1098,26 @@ func (h *Handlers) loadEventsWithDeliveries(
webhookDB, webhook.ID, page,
)
result = make([]EventLogView, len(rows))
result, ok := h.eventLogViews(
w, r, webhookDB, webhook.ID, rows, targetMap,
)
return result, totalEvents, ok
}
// eventLogViews projects loaded events for rendering, each with
// its deliveries and how many times it has been resubmitted. Like
// loadEventsWithDeliveries, it reports false once it has answered
// the request with an error.
func (h *Handlers) eventLogViews(
w http.ResponseWriter,
r *http.Request,
webhookDB *gorm.DB,
webhookID string,
rows []eventLogRow,
targetMap map[string]eventLogTarget,
) ([]EventLogView, bool) {
result := make([]EventLogView, len(rows))
eventDeliveries := make([][]database.Delivery, len(rows))
var deliveryIDs []string
@@ -1108,7 +1125,7 @@ func (h *Handlers) loadEventsWithDeliveries(
eventIDs := make([]string, len(rows))
for i := range rows {
result[i] = rows[i].view()
result[i] = rows[i].view(webhookID)
eventIDs[i] = rows[i].ID
webhookDB.Where(
@@ -1130,7 +1147,7 @@ func (h *Handlers) loadEventsWithDeliveries(
w, r, "failed to load delivery attempts", err,
)
return nil, 0, false
return nil, false
}
resubmits, err := resubmitCounts(webhookDB, eventIDs)
@@ -1139,7 +1156,7 @@ func (h *Handlers) loadEventsWithDeliveries(
w, r, "failed to count event resubmissions", err,
)
return nil, 0, false
return nil, false
}
for i := range rows {
@@ -1149,7 +1166,7 @@ func (h *Handlers) loadEventsWithDeliveries(
result[i].ResubmitCount = resubmits[rows[i].ID]
}
return result, totalEvents, true
return result, true
}
// loadEventLogRows reads one page of the event log projection, newest
+46 -1
View File
@@ -39,6 +39,9 @@ const (
// the mobile menu button instead of the navigation links.
phoneWidth = 390
phoneHeight = 844
// olderBody is the body of the event received before the newest.
olderBody = "the older event"
)
// TestAlpineRunsUnderTheSecurityPolicy loads the webhook page and the
@@ -63,6 +66,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
Active: true,
},
).Error)
env.seedEvent(t, webhook.ID, olderBody)
event := env.seedEvent(t, webhook.ID, `{"hello":"browser"}`)
target := env.seedTarget(t, webhook.ID)
dlv := env.seedFailedDelivery(t, webhook.ID, event.ID, target.ID)
@@ -86,6 +90,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
checkAddForms(ctx, t, page)
checkTargetType(ctx, t, page+"/events")
checkCopy(ctx, t, page)
checkRecentEvents(ctx, t, page)
checkEventLog(ctx, t, page+"/events", event.ID, target.Name)
checkMobileMenu(ctx, t, page)
@@ -363,6 +368,46 @@ func checkCopy(ctx context.Context, t *testing.T, url string) {
`clicking Copy does not show "Copied"`)
}
// checkRecentEvents loads a webhook page and checks that of its recent
// events only the newest starts expanded, showing its body, that
// clicking the older one's row expands it and clicking again collapses
// it, and that clicking the newest one's row collapses it. It then
// follows the newest one's Open link to the event's own page, which
// shows the body.
func checkRecentEvents(ctx context.Context, t *testing.T, url string) {
t.Helper()
// The newest event's body is pretty-printed JSON. Each row's
// toggle is the button in the element that holds its state.
newest := `//pre[contains(., '"hello": "browser"')]`
older := `//pre[text()="` + olderBody + `"]`
toggle := `/ancestor::div[@x-data][1]//button`
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
assert.True(t, shown(ctx, newest), "the newest event starts collapsed")
assert.True(t, hidden(ctx, older), "an older event starts expanded")
click(ctx, t, older+toggle)
assert.True(t, shown(ctx, older), "clicking an event does not expand it")
click(ctx, t, older+toggle)
assert.True(t, hidden(ctx, older),
"clicking an event again does not collapse it")
click(ctx, t, newest+toggle)
assert.True(t, hidden(ctx, newest),
"clicking the newest event does not collapse it")
require.NoError(t, chromedp.Run(ctx, loadPage(url)))
click(ctx, t, newest+`/ancestor::div[@x-data][1]//a[text()="Open"]`)
assert.True(t, shown(ctx, `//h2[text()="Body"]`),
"Open does not lead to the event's own page")
assert.True(t, shown(ctx, newest),
"the event's own page does not show its body")
}
// checkEventLog loads the event log and checks that clicking an event's
// row expands it, that in there clicking its delivery shows the
// delivery's attempts and clicking again hides them, and that clicking
@@ -375,7 +420,7 @@ func checkEventLog(
// The event's row shows its ID, and its Resubmit form is in the part
// that expands. The delivery's row there shows the target's name.
eventRow := `//span[text()="` + eventID + `"]`
expanded := `form[action$="/resubmit"]`
expanded := `form[action$="/` + eventID + `/resubmit"]`
deliveryRow := `//span[text()="` + targetName + `"]`
attempt := `//span[text()="Attempt 1"]`
+6 -5
View File
@@ -252,11 +252,12 @@ func (s *Server) setupSourceRoutes() {
r.Post("/edit", s.h.HandleSourceEditSubmit())
r.Post("/delete", s.h.HandleSourceDelete())
r.Get("/events", s.h.HandleSourceLogs())
// The log page renders each body only up to its cap, so
// this is the only route that serves a whole one. It
// belongs to this group for its RequireAuth and
// NoCache; see HandleEventBodyDownload for the headers
// that keep the bytes it returns inert.
r.Get("/events/{eventID}", s.h.HandleEventDetail())
// The pages show a body as escaped text and leave a
// binary one out, so this is the only route that serves
// the stored bytes. It belongs to this group for its
// RequireAuth and NoCache; see HandleEventBodyDownload for
// the headers that keep the bytes it returns inert.
r.Get(
"/events/{eventID}/body",
s.h.HandleEventBodyDownload(),
+43
View File
@@ -364,6 +364,7 @@ func (e *testEnv) seedEvent(
WebhookID: webhookID,
Method: http.MethodPost,
Body: body,
BodyBytes: int64(len(body)),
ContentType: "application/octet-stream",
}
@@ -1247,6 +1248,48 @@ func TestHook_LinksBetweenPages(t *testing.T) {
}
}
// TestEventPage_OpenedFromRecentEvents follows the Open link of a
// row in the recent events on the webhook page through the
// production router to the event's own page, which shows the body
// and links back. Another user gets a 404 at the same URL, and a
// logged-out request is sent to log in.
func TestEventPage_OpenedFromRecentEvents(t *testing.T) {
t.Parallel()
env := newTestEnv(t)
ownerID, _ := env.seedUser(t, "owner", "somepassword")
cookies := env.authCookies(t, ownerID, "owner")
wh := env.seedWebhook(t, ownerID)
evt := env.seedEvent(t, wh.ID, "OWNERS-PAYLOAD-3e9d")
page := "/hook/" + wh.ID
path := env.urlFrom(t, page, `href="([^"]+)"[^>]*>Open<`, cookies)
require.Equal(t, page+"/events/"+evt.ID, path)
w := env.get(path, cookies)
require.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
assert.Equal(
t, page,
env.urlFrom(t, path, `href="([^"]+)"[^>]*>&larr; Back to `, cookies),
)
intruderID, _ := env.seedUser(t, "intruder", "somepassword")
w = env.get(path, env.authCookies(t, intruderID, "intruder"))
assert.Equal(t, http.StatusNotFound, w.Code)
assert.NotContains(t, w.Body.String(), "OWNERS-PAYLOAD-3e9d")
anon := env.get(path, nil)
assert.Equal(t, http.StatusSeeOther, anon.Code)
assert.Equal(
t, "/pages/login?next="+url.QueryEscape(path),
anon.Header().Get("Location"),
)
}
// TestSourceLogs_TruncationLinkDownloadsTheBody walks the whole
// feature the way a user does: render the event log page through
// the production router, take the download URL out of the markup
+6 -1
View File
@@ -70,10 +70,15 @@ document.addEventListener("alpine:init", function () {
"use strict";
// Something a click shows and hides: the mobile menu, an add form,
// an event in the event log, a delivery's attempts.
// an event in the event log or in the recent events, a delivery's
// attempts. It starts hidden, or shown when its element has the
// data-open attribute.
window.Alpine.data("collapsible", function () {
return {
open: false,
init() {
this.open = this.$root.hasAttribute("data-open");
},
toggle() {
this.open = !this.open;
},
+32
View File
@@ -0,0 +1,32 @@
{{define "delivery_attempts"}}
<!-- A delivery's recorded attempts, as handlers.DeliveryView holds
them: in the event log and on an event's own page. -->
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}
{{range .Results}}
<div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span>
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
</div>
{{if .Error}}
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
{{end}}
{{if .ResponseBody}}
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
{{end}}
{{if .ResponseTruncated}}
{{if .ResponseSizeKnown}}
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
{{else}}
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
{{end}}
{{end}}
</div>
{{else}}
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
{{end}}
{{end}}
+15
View File
@@ -0,0 +1,15 @@
{{define "event_body"}}
<!-- An event's body, as handlers.BodyView describes it: the same in
the recent events on the webhook page, the event log and the
event's own page. -->
{{if .Binary}}
<p class="text-xs text-gray-500">This body is binary ({{.Size}} bytes) and is not shown. <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{else if .Text}}
<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"{{if .Scroll}} style="max-height: 32rem; overflow-y: auto"{{end}}>{{.Text}}</pre>
{{if .Cut}}
<p class="mt-2 text-xs text-gray-500">Showing the first {{.ShownBytes}} of {{.Size}} bytes, unformatted. <a href="{{.EventURL}}" class="btn-small">Show the whole body</a> <a href="{{.EventURL}}/body" class="btn-small">Download the body</a></p>
{{end}}
{{else}}
<p class="text-xs text-gray-500">No body.</p>
{{end}}
{{end}}
+87
View File
@@ -0,0 +1,87 @@
{{template "base" .}}
{{define "title"}}Event - {{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<div class="max-w-6xl mx-auto px-6 py-8">
<div class="mb-6">
<div class="flex flex-wrap gap-2">
<a href="/hook/{{.Webhook.ID}}" class="btn-small">&larr; Back to {{.Webhook.Name}}</a>
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div>
<h1 class="text-2xl font-medium text-gray-900 mt-2">Event</h1>
</div>
{{with .Event}}
<div class="card p-4">
<dl class="space-y-2 text-sm">
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">ID</dt>
<dd class="font-mono text-gray-900 break-all">{{.ID}}</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Received</dt>
<dd class="text-gray-900">{{.CreatedAt.UTC.Format "2006-01-02 15:04:05"}} UTC</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Method</dt>
<dd><span class="badge-info">{{.Method}}</span></dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Content type</dt>
<dd class="text-gray-900 break-all">{{.ContentType}}</dd>
</div>
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Body size</dt>
<dd class="text-gray-900">{{.Body.Size}} bytes</dd>
</div>
{{if .ResubmittedFrom}}
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted from</dt>
<dd><a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a></dd>
</div>
{{end}}
{{if .ResubmitCount}}
<div class="flex flex-wrap gap-2">
<dt class="w-32 flex-shrink-0 text-gray-500">Resubmitted</dt>
<dd class="text-gray-900">as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}</dd>
</div>
{{end}}
</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">Body</h2>
</div>
<div class="p-4">
{{template "event_body" .Body}}
</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">Deliveries</h2>
</div>
<div class="divide-y divide-gray-100">
{{range .Deliveries}}
<div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<span class="text-sm text-gray-700">{{.Target.DisplayName}}</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}}">{{.Status}}</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>
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No deliveries.</div>
{{end}}
</div>
</div>
{{end}}
</div>
{{end}}
+31 -20
View File
@@ -191,26 +191,37 @@
<a href="/hook/{{.Webhook.ID}}/events" class="btn-small">Full Event Log</a>
</div>
<div class="divide-y divide-gray-100">
{{range .Events}}
<div class="p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
{{if .ResubmittedFromID}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}}
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span title="Body size">{{.Size}}</span>
{{if .ProcessingTime}}
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
{{end}}
{{if .Status}}
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
{{end}}
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
</div>
<!-- Each row expands to show its body; only the newest
starts expanded. -->
{{range $i, $event := .Events}}
<div class="p-4" x-data="collapsible"{{if eq $i 0}} data-open{{end}}>
<div class="flex flex-wrap items-center gap-3">
<button type="button" class="btn-small flex-1 flex-wrap justify-between gap-3 text-left" @click="toggle">
<span class="flex flex-wrap items-center gap-3">
<span class="badge-info">{{.Method}}</span>
<span class="text-sm text-gray-500 break-all">{{.ContentType}}</span>
{{if .ResubmittedFromID}}
<span class="text-xs text-gray-500" title="This event is a copy of {{.ResubmittedFromID}}">resubmitted copy</span>
{{end}}
</span>
<span class="flex flex-wrap items-center gap-3 text-xs text-gray-400">
<span title="Body size">{{.Size}}</span>
{{if .ProcessingTime}}
<span title="Processing time: how long the slowest delivery took, from being queued to its last attempt">{{.ProcessingTime}}</span>
{{end}}
{{if .Status}}
<span class="font-medium {{.StatusClass}}" title="HTTP status from the HTTP target">{{.Status}}</span>
{{end}}
<span title="{{.ReceivedUTC}}">{{.Received}}</span>
<svg class="w-4 h-4 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>
<a href="{{.Body.EventURL}}" class="btn-small">Open</a>
</div>
<div x-show="open" x-cloak class="mt-3">
{{template "event_body" .Body}}
</div>
</div>
{{else}}
+3 -33
View File
@@ -44,7 +44,7 @@
<div x-show="open" x-cloak class="mt-3 p-3 bg-gray-50 rounded-md">
<div class="mb-3 flex flex-wrap items-center justify-between gap-2">
<div class="text-xs text-gray-500">
{{if .ResubmittedFrom}}Resubmitted from event <span class="font-mono">{{.ResubmittedFromID}}</span>.{{end}}
{{if .ResubmittedFrom}}Resubmitted from event <a href="/hook/{{$.Webhook.ID}}/events/{{.ResubmittedFromID}}" class="btn-small font-mono">{{.ResubmittedFromID}}</a>.{{end}}
{{if .ResubmitCount}}Resubmitted as {{.ResubmitCount}} new event{{if ne .ResubmitCount 1}}s{{end}}.{{end}}
</div>
<form method="POST" action="/hook/{{$.Webhook.ID}}/events/{{.ID}}/resubmit" class="inline">
@@ -53,10 +53,7 @@
<button type="submit" class="btn-small" title="Submit this event again as a new event, to every currently active target">Resubmit</button>
</form>
</div>
<pre class="text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.Body}}</pre>
{{if .BodyTruncated}}
<p class="mt-2 text-xs text-gray-500">Body truncated for display: showing {{.BodyShownBytes}} of {{.BodyBytes}} bytes. The stored body is unchanged &mdash; <a href="/hook/{{$.Webhook.ID}}/events/{{.ID}}/body" class="btn-small">download the full body</a>.</p>
{{end}}
{{template "event_body" .Body}}
{{if .Deliveries}}
<div class="mt-4 border-t border-gray-200 pt-3">
@@ -87,34 +84,7 @@
</div>
<div x-show="open" x-cloak class="mt-2 space-y-2">
{{if .AttemptsOmitted}}
<p class="text-xs text-gray-500">{{.AttemptsOmitted}} attempt{{if ne .AttemptsOmitted 1}}s{{end}} omitted between the first and last shown.</p>
{{end}}
{{range .Results}}
<div class="rounded-md bg-white border border-gray-200 p-2">
<div class="flex flex-wrap items-center gap-3 text-xs">
<span class="text-gray-500">Attempt {{.AttemptNum}}</span>
<span class="{{if .Success}}text-green-600{{else}}text-red-600{{end}}">{{if .Success}}success{{else}}failure{{end}}</span>
<span class="text-gray-500">Status: {{if .HasStatusCode}}{{.StatusCode}}{{else}}&mdash; (no response){{end}}</span>
<span class="text-gray-500">Duration: {{.DurationMS}} ms</span>
</div>
{{if .Error}}
<p class="mt-2 text-xs text-red-700 break-all">Error: {{.Error}}</p>
{{end}}
{{if .ResponseBody}}
<pre class="mt-2 text-xs text-gray-700 overflow-x-auto whitespace-pre-wrap break-all">{{.ResponseBody}}</pre>
{{end}}
{{if .ResponseTruncated}}
{{if .ResponseSizeKnown}}
<p class="mt-1 text-xs text-gray-500">Response truncated for display: showing {{.ResponseShownBytes}} of {{.ResponseBytes}} bytes.</p>
{{else}}
<p class="mt-1 text-xs text-gray-500">Showing {{.ResponseShownBytes}} of the {{.ResponseBytes}} recorded bytes. The response reached the recording limit, so the remote may have sent more that was never stored.</p>
{{end}}
{{end}}
</div>
{{else}}
<p class="text-xs text-gray-500">No attempts recorded yet.</p>
{{end}}
{{template "delivery_attempts" .}}
</div>
</div>
{{end}}