check / check (push) Successful in 3m53s
The recent events list on a webhook's page now shows the 50 newest events, each with its time relative to now (the full UTC timestamp on hover), its body size, its processing time and, when the webhook has exactly one HTTP target, that target's last HTTP status, colour-coded. Resubmitted copies are marked, as in the event log. Processing time is how long the event's slowest delivery took, from being queued to its last recorded attempt. It is read from the existing delivery and attempt timestamps, so nothing new is recorded. The body's size is read in SQL, never the body. Deliveries and attempts load in one batched query each, reusing the event log's attempt loader. Model: opus-5-5
249 lines
6.5 KiB
Go
249 lines
6.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"net/http"
|
|
"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's size and never the body itself, for the reason
|
|
// maxRenderedBodyBytes gives; the cast to blob makes length count
|
|
// bytes rather than characters.
|
|
const recentEventColumns = "id, created_at, method, content_type, " +
|
|
"resubmitted_from_id, length(cast(body as blob)) AS body_bytes"
|
|
|
|
// 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
|
|
}
|
|
|
|
// recentEventRow is one row of recentEventColumns.
|
|
type recentEventRow struct {
|
|
ID string
|
|
CreatedAt time.Time
|
|
Method string
|
|
ContentType string
|
|
ResubmittedFromID *string
|
|
BodyBytes uint64
|
|
}
|
|
|
|
// 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 (h *Handlers) loadRecentEvents(
|
|
webhookDB *gorm.DB, webhookID, statusTargetID string,
|
|
) ([]RecentEventView, error) {
|
|
var rows []recentEventRow
|
|
|
|
err := webhookDB.Model(&database.Event{}).
|
|
Select(recentEventColumns).
|
|
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 := h.loadDeliveryResults(webhookDB, deliveryIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
views := make([]RecentEventView, len(rows))
|
|
for i := range rows {
|
|
views[i] = rows[i].view(
|
|
byEvent[rows[i].ID], attempts, statusTargetID,
|
|
)
|
|
}
|
|
|
|
return views, 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.
|
|
func (r *recentEventRow) view(
|
|
deliveries []database.Delivery,
|
|
attempts map[string][]deliveryResultRow,
|
|
statusTargetID string,
|
|
) RecentEventView {
|
|
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),
|
|
}
|
|
|
|
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][]deliveryResultRow,
|
|
) 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][]deliveryResultRow,
|
|
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"
|
|
}
|
|
}
|