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
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"
|
|
}
|
|
}
|