check / check (push) Waiting to run
Each event now has its own page at /hook/ID/events/EVENTID, behind the login, showing its details, its whole body and every delivery; a resubmitted copy links to its original's page. The recent events on the webhook page link there and expand to show their bodies, only the newest expanded on load. One renderer and one template show a body the same way in the recent events, the event log and the event's page: whole up to 32 KiB, cut there in the two lists with links to the event's page and the download; JSON pretty-printed unless that would grow it past four times plus 1 KiB; over 200 lines in a scrolling box; a body holding NUL or control characters treated as binary and never dumped raw. Model: opus-5-5
306 lines
8.1 KiB
Go
306 lines
8.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"slices"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/dustin/go-humanize"
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// recentEventColumns is the recent events list's projection. It
|
|
// 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, " +
|
|
"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
|
|
// bodies, which the list does not show.
|
|
const recentAttemptColumns = "delivery_id, status_code, created_at"
|
|
|
|
// RecentEventView is one row of the recent events list on a
|
|
// webhook's page.
|
|
type RecentEventView struct {
|
|
Method string
|
|
ContentType string
|
|
|
|
// ResubmittedFromID names the event this one was copied from,
|
|
// empty for an event that arrived on the receiver.
|
|
ResubmittedFromID string
|
|
|
|
// Received is how long ago the event arrived, and ReceivedUTC
|
|
// the full timestamp the page shows on hover.
|
|
Received string
|
|
ReceivedUTC string
|
|
|
|
// Size is the size of the stored body.
|
|
Size string
|
|
|
|
// ProcessingTime is how long the event's slowest delivery
|
|
// took; see processingTime.
|
|
ProcessingTime string
|
|
|
|
// Status is what the webhook's HTTP target answered, and
|
|
// StatusClass its colour; see targetStatus. Both are empty
|
|
// 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.
|
|
type recentEventRow struct {
|
|
ID string
|
|
CreatedAt time.Time
|
|
Method string
|
|
ContentType string
|
|
ResubmittedFromID *string
|
|
BodyBytes uint64
|
|
Body []byte
|
|
}
|
|
|
|
// recentAttemptRow is one row of recentAttemptColumns. CreatedAt is
|
|
// when the attempt's result was recorded, which is when the attempt
|
|
// finished.
|
|
type recentAttemptRow struct {
|
|
DeliveryID string
|
|
StatusCode int
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
// singleHTTPTargetID returns the ID of the webhook's HTTP target
|
|
// when it has exactly one, and "" when it has none or several.
|
|
func singleHTTPTargetID(targets []database.Target) string {
|
|
id := ""
|
|
count := 0
|
|
|
|
for i := range targets {
|
|
if targets[i].Type == database.TargetTypeHTTP {
|
|
id = targets[i].ID
|
|
count++
|
|
}
|
|
}
|
|
|
|
if count != 1 {
|
|
return ""
|
|
}
|
|
|
|
return id
|
|
}
|
|
|
|
// loadRecentEvents loads the webhook's recentEventLimit newest
|
|
// events for its page, newest first. statusTargetID is the
|
|
// webhook's only HTTP target, or "" when the list shows no status.
|
|
func loadRecentEvents(
|
|
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
|
) ([]RecentEventView, error) {
|
|
var rows []recentEventRow
|
|
|
|
err := webhookDB.Model(&database.Event{}).
|
|
Select(recentEventColumns, maxRenderedBodyBytes).
|
|
Where("webhook_id = ?", webhookID).
|
|
Order("created_at DESC").
|
|
Limit(recentEventLimit).
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
eventIDs := make([]string, len(rows))
|
|
for i := range rows {
|
|
eventIDs[i] = rows[i].ID
|
|
}
|
|
|
|
// Oldest first, so an event's last delivery to a target is its
|
|
// newest: a replay adds a delivery rather than changing the
|
|
// earlier one.
|
|
var deliveries []database.Delivery
|
|
|
|
err = webhookDB.
|
|
Select("id, event_id, target_id, status, created_at").
|
|
Where("event_id IN ?", eventIDs).
|
|
Order("created_at ASC").
|
|
Find(&deliveries).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
byEvent := make(map[string][]database.Delivery, len(rows))
|
|
deliveryIDs := make([]string, len(deliveries))
|
|
|
|
for i := range deliveries {
|
|
eventID := deliveries[i].EventID
|
|
byEvent[eventID] = append(byEvent[eventID], deliveries[i])
|
|
deliveryIDs[i] = deliveries[i].ID
|
|
}
|
|
|
|
attempts, err := loadRecentAttempts(webhookDB, deliveryIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
views := make([]RecentEventView, len(rows))
|
|
for i := range rows {
|
|
views[i] = rows[i].view(
|
|
webhookID, byEvent[rows[i].ID], attempts, statusTargetID,
|
|
)
|
|
}
|
|
|
|
return views, nil
|
|
}
|
|
|
|
// loadRecentAttempts loads the recorded attempts of the listed
|
|
// events' deliveries, keyed by delivery ID, each delivery's in
|
|
// attempt order. The IDs go in chunks for the reason
|
|
// deliveryIDChunkSize gives.
|
|
func loadRecentAttempts(
|
|
webhookDB *gorm.DB, deliveryIDs []string,
|
|
) (map[string][]recentAttemptRow, error) {
|
|
byDelivery := make(map[string][]recentAttemptRow)
|
|
|
|
for chunk := range slices.Chunk(deliveryIDs, deliveryIDChunkSize) {
|
|
var rows []recentAttemptRow
|
|
|
|
err := webhookDB.Model(&database.DeliveryResult{}).
|
|
Select(recentAttemptColumns).
|
|
Where("delivery_id IN ?", chunk).
|
|
Order("attempt_num ASC").
|
|
Find(&rows).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range rows {
|
|
id := rows[i].DeliveryID
|
|
byDelivery[id] = append(byDelivery[id], rows[i])
|
|
}
|
|
}
|
|
|
|
return byDelivery, nil
|
|
}
|
|
|
|
// 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,
|
|
Received: humanize.Time(r.CreatedAt),
|
|
ReceivedUTC: r.CreatedAt.UTC().Format(time.DateTime) + " UTC",
|
|
Size: humanize.Bytes(r.BodyBytes),
|
|
ProcessingTime: processingTime(deliveries, attempts),
|
|
Body: body,
|
|
}
|
|
|
|
if r.ResubmittedFromID != nil {
|
|
v.ResubmittedFromID = *r.ResubmittedFromID
|
|
}
|
|
|
|
if statusTargetID != "" {
|
|
v.Status, v.StatusClass = targetStatus(
|
|
deliveries, attempts, statusTargetID,
|
|
)
|
|
}
|
|
|
|
return v
|
|
}
|
|
|
|
// processingTime is how long the event's slowest delivery took,
|
|
// from being queued to its last recorded attempt, time spent
|
|
// waiting between retries included. A delivery is queued when its
|
|
// event is received, or when an operator replays it, so a replay
|
|
// is timed from the replay rather than from the event's arrival.
|
|
// It is "in progress" while any delivery is pending or retrying,
|
|
// and empty for an event with no deliveries.
|
|
func processingTime(
|
|
deliveries []database.Delivery,
|
|
attempts map[string][]recentAttemptRow,
|
|
) string {
|
|
if len(deliveries) == 0 {
|
|
return ""
|
|
}
|
|
|
|
var slowest time.Duration
|
|
|
|
for i := range deliveries {
|
|
if !deliveries[i].Status.Terminal() {
|
|
return "in progress"
|
|
}
|
|
|
|
tries := attempts[deliveries[i].ID]
|
|
if len(tries) == 0 {
|
|
continue
|
|
}
|
|
|
|
last := tries[len(tries)-1].CreatedAt
|
|
slowest = max(slowest, last.Sub(deliveries[i].CreatedAt))
|
|
}
|
|
|
|
return slowest.Round(time.Millisecond).String()
|
|
}
|
|
|
|
// targetStatus is what the target answered for the event, and the
|
|
// colour to show it in: the HTTP status code of the last attempt of
|
|
// the event's newest delivery to the target. Without a code it is
|
|
// "no response" when that attempt failed before a response
|
|
// arrived, the delivery's status ("pending") before any attempt,
|
|
// and "not sent" when the event has no delivery to the target.
|
|
func targetStatus(
|
|
deliveries []database.Delivery,
|
|
attempts map[string][]recentAttemptRow,
|
|
targetID string,
|
|
) (string, string) {
|
|
newest := -1
|
|
|
|
for i := range deliveries {
|
|
if deliveries[i].TargetID == targetID {
|
|
newest = i
|
|
}
|
|
}
|
|
|
|
if newest < 0 {
|
|
return "not sent", "text-gray-400"
|
|
}
|
|
|
|
tries := attempts[deliveries[newest].ID]
|
|
if len(tries) == 0 {
|
|
return string(deliveries[newest].Status), "text-gray-400"
|
|
}
|
|
|
|
code := tries[len(tries)-1].StatusCode
|
|
|
|
switch {
|
|
case code == 0:
|
|
return "no response", "text-red-600"
|
|
case code >= http.StatusInternalServerError:
|
|
return strconv.Itoa(code), "text-red-600"
|
|
case code >= http.StatusBadRequest:
|
|
return strconv.Itoa(code), "text-yellow-600"
|
|
case code >= http.StatusMultipleChoices:
|
|
return strconv.Itoa(code), "text-gray-500"
|
|
case code >= http.StatusOK:
|
|
return strconv.Itoa(code), "text-green-600"
|
|
default:
|
|
return strconv.Itoa(code), "text-gray-500"
|
|
}
|
|
}
|