Show each entrypoint's last event and event count on the webhook page (closes #393)
check / check (push) Successful in 3m23s

Each entrypoint in the webhook page's entrypoint list now shows when its last event arrived (relative, with the full UTC time on hover), or "never", and how many events arrived through it within the webhook's retention period. Both come from one query per page, grouped by entrypoint, over a new events index on entrypoint_id, deleted_at and created_at, so the page reads only the index entries it counts. Pre-1.0: the index goes into the schema in place. A test checks the database's plan for the statement as the code builds it; another shows two entrypoints with different traffic each with their own figures, an event older than retention left out, and an unused entrypoint reading "never".

Model: opus-5-5
This commit is contained in:
2026-10-02 19:56:29 +00:00
committed by sneak
parent faf7ca1a5e
commit 8c5ab00524
8 changed files with 264 additions and 7 deletions
+76
View File
@@ -1,6 +1,11 @@
package handlers
import (
"fmt"
"time"
"github.com/dustin/go-humanize"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
@@ -11,6 +16,21 @@ type EntrypointView struct {
Path string
Description string
Active bool
// Events is how many events arrived through the entrypoint within
// the webhook's retention period. LastEvent is when the newest of
// them arrived, relative, and LastEventUTC the full time; both are
// empty when none did.
Events int64
LastEvent string
LastEventUTC string
}
// entrypointEvents is one entrypoint's row read by addEntrypointEvents.
type entrypointEvents struct {
EntrypointID string
Events int64
LastEventAt time.Time
}
// NewEntrypointViews projects entrypoints for rendering.
@@ -32,3 +52,59 @@ func NewEntrypointViews(
return views
}
// addEntrypointEvents fills in each view's event figures from the
// webhook's event database, in one query over the events'
// entrypoint_id index. Only events received since the webhook's
// retention cutoff are read.
func addEntrypointEvents(
webhookDB *gorm.DB,
webhook *database.Webhook,
views []EntrypointView,
now time.Time,
) error {
ids := make([]string, len(views))
for i := range views {
ids[i] = views[i].ID
}
// max(created_at) picks each entrypoint's newest row, and SQLite
// takes the bare created_at beside it from that row. Unlike max(),
// the plain column keeps its datetime type, so the driver returns
// it as a time.
query := webhookDB.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events, "+
"max(created_at), created_at AS last_event_at").
Where("entrypoint_id IN ?", ids)
cutoff, finite := webhook.RetentionCutoff(now)
if finite {
query = query.Where("created_at >= ?", cutoff)
}
var rows []entrypointEvents
err := query.Group("entrypoint_id").Find(&rows).Error
if err != nil {
return fmt.Errorf("counting events by entrypoint: %w", err)
}
byID := make(map[string]entrypointEvents, len(rows))
for _, row := range rows {
byID[row.EntrypointID] = row
}
for i := range views {
row, found := byID[views[i].ID]
if !found {
continue
}
views[i].Events = row.Events
views[i].LastEvent = humanize.Time(row.LastEventAt)
views[i].LastEventUTC =
row.LastEventAt.UTC().Format(time.DateTime) + " UTC"
}
return nil
}