check / check (push) Waiting to run
The Statistics block's "Last event" figure now shows how long ago the
newest event arrived, as its two largest units ("59s", "1m1s", "3d4h"),
worked out when the page is served. Its tooltip is the full time in
UTC. With no event it still shows "none", without a tooltip.
Model: opus-5-5
309 lines
7.9 KiB
Go
309 lines
7.9 KiB
Go
package handlers
|
|
|
|
import (
|
|
"fmt"
|
|
"time"
|
|
|
|
"gorm.io/gorm"
|
|
"sneak.berlin/go/webhooker/internal/database"
|
|
)
|
|
|
|
// The spans of the two recent windows the statistics pane reports on:
|
|
// the last 10 minutes and the last 24 hours.
|
|
const (
|
|
shortWindow = 10 * time.Minute
|
|
longWindow = 24 * time.Hour
|
|
)
|
|
|
|
// day is the largest unit relativeTime writes.
|
|
const day = 24 * time.Hour
|
|
|
|
// percent turns a fraction into a percentage.
|
|
const percent = 100
|
|
|
|
// WebhookStats holds the figures in the statistics pane at the top of
|
|
// the webhook page.
|
|
type WebhookStats struct {
|
|
Entrypoints int
|
|
ActiveEntrypoints int
|
|
Targets int
|
|
ActiveTargets int
|
|
|
|
// Lifetime counts every event, delivery and failure the webhook
|
|
// has had, and WithinRetention those still stored.
|
|
Lifetime Counts
|
|
WithinRetention Counts
|
|
|
|
// InProgress counts the deliveries still pending or retrying.
|
|
InProgress int64
|
|
|
|
// LastEventAt is when the newest event arrived, or nil when none
|
|
// has. Retention does not change it. LastEvent is how long ago
|
|
// that was, as relativeTime writes it, and LastEventUTC the full
|
|
// time; both are empty when no event has arrived.
|
|
LastEventAt *time.Time
|
|
LastEvent string
|
|
LastEventUTC string
|
|
|
|
Last10Minutes RecentWindow
|
|
Last24Hours RecentWindow
|
|
}
|
|
|
|
// Counts holds a number of events, of deliveries and of failed
|
|
// deliveries.
|
|
type Counts struct {
|
|
Events int64
|
|
Deliveries int64
|
|
Failures int64
|
|
}
|
|
|
|
// RecentWindow holds what happened in one recent window: the events
|
|
// received in it, and the deliveries that became delivered or failed in
|
|
// it.
|
|
type RecentWindow struct {
|
|
Events int64
|
|
Delivered int64
|
|
Failed int64
|
|
}
|
|
|
|
// TargetFinished is how many of one target's deliveries became
|
|
// delivered, and how many failed, in a recent window.
|
|
type TargetFinished struct {
|
|
TargetID string
|
|
Delivered int64
|
|
Failed int64
|
|
}
|
|
|
|
// FailurePercent is the share of the deliveries finished in the window
|
|
// that failed, or a dash when none finished. Deliveries still pending
|
|
// or retrying are not counted either way.
|
|
func (w RecentWindow) FailurePercent() string {
|
|
finished := w.Delivered + w.Failed
|
|
if finished == 0 {
|
|
return "—"
|
|
}
|
|
|
|
return fmt.Sprintf(
|
|
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
|
)
|
|
}
|
|
|
|
// relativeTime is how long before now the time at was, in whole
|
|
// seconds, written as its two largest units out of d, h, m and s:
|
|
// "59s", "1m1s", "23h59m", "3d4h". A second unit of zero is left off,
|
|
// as in "5m". A time after now is "0s".
|
|
func relativeTime(at, now time.Time) string {
|
|
elapsed := max(now.Sub(at), 0)
|
|
|
|
days := int64(elapsed / day)
|
|
hours := int64(elapsed % day / time.Hour)
|
|
minutes := int64(elapsed % time.Hour / time.Minute)
|
|
seconds := int64(elapsed % time.Minute / time.Second)
|
|
|
|
switch {
|
|
case days > 0:
|
|
return twoUnits(days, "d", hours, "h")
|
|
case hours > 0:
|
|
return twoUnits(hours, "h", minutes, "m")
|
|
case minutes > 0:
|
|
return twoUnits(minutes, "m", seconds, "s")
|
|
default:
|
|
return fmt.Sprintf("%ds", seconds)
|
|
}
|
|
}
|
|
|
|
// twoUnits writes a count of a larger unit followed by a count of the
|
|
// next smaller one, leaving the smaller off when it is zero.
|
|
func twoUnits(
|
|
larger int64, largerUnit string, smaller int64, smallerUnit string,
|
|
) string {
|
|
if smaller == 0 {
|
|
return fmt.Sprintf("%d%s", larger, largerUnit)
|
|
}
|
|
|
|
return fmt.Sprintf("%d%s%d%s", larger, largerUnit, smaller, smallerUnit)
|
|
}
|
|
|
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
|
// webhook's entrypoints and targets, as the page has already loaded
|
|
// them, and from its event database. It returns nil, and logs why, when
|
|
// the event database cannot be read.
|
|
func (h *Handlers) loadWebhookStats(
|
|
webhookID string,
|
|
entrypoints []database.Entrypoint,
|
|
targets []database.Target,
|
|
) *WebhookStats {
|
|
stats := &WebhookStats{
|
|
Entrypoints: len(entrypoints),
|
|
Targets: len(targets),
|
|
}
|
|
|
|
for i := range entrypoints {
|
|
if entrypoints[i].Active {
|
|
stats.ActiveEntrypoints++
|
|
}
|
|
}
|
|
|
|
for i := range targets {
|
|
if targets[i].Active {
|
|
stats.ActiveTargets++
|
|
}
|
|
}
|
|
|
|
// Opening an event database that does not exist would create it,
|
|
// and it would hold nothing to count.
|
|
if !h.dbMgr.DBExists(webhookID) {
|
|
return stats
|
|
}
|
|
|
|
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
if err == nil {
|
|
err = readEventStats(webhookDB, time.Now(), stats)
|
|
}
|
|
|
|
if err != nil {
|
|
h.log.Error(
|
|
"failed to read webhook statistics",
|
|
"webhook_id", webhookID,
|
|
"error", err,
|
|
)
|
|
|
|
return nil
|
|
}
|
|
|
|
return stats
|
|
}
|
|
|
|
// readEventStats fills in the figures that come from the webhook's
|
|
// event database. None of them reads every stored row: the totals are
|
|
// one row for the events and one per target for the deliveries, and
|
|
// every other figure is read from an index, over only the rows it
|
|
// counts.
|
|
func readEventStats(
|
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
|
) error {
|
|
err := readTotals(db, now, stats)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = db.Model(&database.Delivery{}).
|
|
Where("status IN ?", []database.DeliveryStatus{
|
|
database.DeliveryStatusPending,
|
|
database.DeliveryStatusRetrying,
|
|
}).
|
|
Count(&stats.InProgress).Error
|
|
if err != nil {
|
|
return fmt.Errorf("counting deliveries in progress: %w", err)
|
|
}
|
|
|
|
stats.Last10Minutes, err = readRecentWindow(
|
|
db, now.Add(-shortWindow),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
stats.Last24Hours, err = readRecentWindow(
|
|
db, now.Add(-longWindow),
|
|
)
|
|
|
|
return err
|
|
}
|
|
|
|
// readTotals fills in the lifetime and within-retention figures, and
|
|
// when the last event arrived and how long before now that was, from
|
|
// the running totals: the events' row, and the targets' rows summed.
|
|
func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
|
|
var events database.EventTotals
|
|
|
|
err := db.Take(&events).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading event totals: %w", err)
|
|
}
|
|
|
|
var targets []database.TargetTotals
|
|
|
|
err = db.Find(&targets).Error
|
|
if err != nil {
|
|
return fmt.Errorf("reading target totals: %w", err)
|
|
}
|
|
|
|
stats.Lifetime.Events = events.Events
|
|
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
|
stats.LastEventAt = events.LastEventAt
|
|
|
|
if events.LastEventAt != nil {
|
|
stats.LastEvent = relativeTime(*events.LastEventAt, now)
|
|
stats.LastEventUTC =
|
|
events.LastEventAt.UTC().Format(time.DateTime) + " UTC"
|
|
}
|
|
|
|
for _, t := range targets {
|
|
stats.Lifetime.Deliveries += t.Deliveries
|
|
stats.Lifetime.Failures += t.Failed
|
|
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
|
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readRecentWindow counts the events received, and the deliveries that
|
|
// became delivered or failed, since the given time.
|
|
func readRecentWindow(
|
|
db *gorm.DB, since time.Time,
|
|
) (RecentWindow, error) {
|
|
var w RecentWindow
|
|
|
|
err := db.Model(&database.Event{}).
|
|
Where("created_at >= ?", since).
|
|
Count(&w.Events).Error
|
|
if err != nil {
|
|
return w, fmt.Errorf("counting recent events: %w", err)
|
|
}
|
|
|
|
byTarget, err := finishedByTarget(db, since)
|
|
if err != nil {
|
|
return w, err
|
|
}
|
|
|
|
for _, f := range byTarget {
|
|
w.Delivered += f.Delivered
|
|
w.Failed += f.Failed
|
|
}
|
|
|
|
return w, nil
|
|
}
|
|
|
|
// finishedByTarget counts, for each target, the deliveries that became
|
|
// delivered and those that failed since the given time, in one query
|
|
// over just that window of the deliveries' status index. A target with
|
|
// neither is left out.
|
|
func finishedByTarget(
|
|
db *gorm.DB, since time.Time,
|
|
) ([]TargetFinished, error) {
|
|
var byTarget []TargetFinished
|
|
|
|
err := db.Model(&database.Delivery{}).
|
|
Select("target_id, "+
|
|
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
database.DeliveryStatusDelivered,
|
|
database.DeliveryStatusFailed).
|
|
Where("status IN ? AND finished_at >= ?",
|
|
[]database.DeliveryStatus{
|
|
database.DeliveryStatusDelivered,
|
|
database.DeliveryStatusFailed,
|
|
}, since).
|
|
Group("target_id").
|
|
Find(&byTarget).Error
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"counting deliveries finished by target: %w", err,
|
|
)
|
|
}
|
|
|
|
return byTarget, nil
|
|
}
|