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