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
}
+106
View File
@@ -0,0 +1,106 @@
package handlers_test
import (
"net/http"
"strconv"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// TestHandleSourceDetail_ShowsEntrypointEvents proves each entrypoint
// on the webhook page shows its own figures: how many events arrived
// through it within the webhook's retention period, leaving out one
// older than that, and when the newest arrived, or "never" for an
// entrypoint with none.
func TestHandleSourceDetail_ShowsEntrypointEvents(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
)
app := newTestApp(t, &h, &sess, &db, &dbMgr)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "figures", RetentionDays: 7,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
webhookDB, err := dbMgr.GetDB(wh.ID)
require.NoError(t, err)
entrypoint := func() *database.Entrypoint {
ep := &database.Entrypoint{
WebhookID: wh.ID, Path: uuid.New().String(), Active: true,
}
require.NoError(t,
db.DB().Omit(clause.Associations).Create(ep).Error)
return ep
}
event := func(ep *database.Entrypoint, age time.Duration) time.Time {
e := &database.Event{
WebhookID: wh.ID,
EntrypointID: ep.ID,
Method: http.MethodPost,
}
e.CreatedAt = time.Now().Add(-age)
require.NoError(t,
webhookDB.Omit(clause.Associations).Create(e).Error)
return e.CreatedAt
}
busy, quiet, unused := entrypoint(), entrypoint(), entrypoint()
event(busy, 8*24*time.Hour) // older than the 7 days kept
event(busy, 3*time.Hour)
busyLast := event(busy, time.Hour)
quietLast := event(quiet, 2*24*time.Hour)
body := renderSourceDetailPage(t, h, sess, wh.ID)
// rowOf returns the part of the page from an entrypoint's URL to
// the next entrypoint's, which holds its figures.
rowOf := func(ep *database.Entrypoint) string {
_, row, found := strings.Cut(body, `id="entrypoint-url-`+ep.ID+`"`)
require.True(t, found)
row, _, _ = strings.Cut(row, `id="entrypoint-url-`)
return row
}
lastEvent := func(at time.Time) string {
return `Last Event:</span>\s*<span title="` +
at.UTC().Format(time.DateTime) + ` UTC">[^<]+ ago</span>`
}
count := func(n int) string {
return `Events Within Retention:</span>\s*<span>` +
strconv.Itoa(n) + `</span>`
}
assert.Regexp(t, lastEvent(busyLast), rowOf(busy))
assert.Regexp(t, count(2), rowOf(busy))
assert.Regexp(t, lastEvent(quietLast), rowOf(quiet))
assert.Regexp(t, count(1), rowOf(quiet))
assert.Regexp(t, `Last Event:</span>\s*<span>never</span>`, rowOf(unused))
assert.Regexp(t, count(0), rowOf(unused))
}
+12 -1
View File
@@ -482,6 +482,8 @@ func (h *Handlers) renderSourceDetail(
"webhook_id = ?", webhook.ID,
).Find(&targets)
entrypointViews := NewEntrypointViews(entrypoints)
var events []RecentEventView
if h.dbMgr.DBExists(webhook.ID) {
@@ -500,6 +502,15 @@ func (h *Handlers) renderSourceDetail(
return
}
err = addEntrypointEvents(
webhookDB, &webhook, entrypointViews, time.Now(),
)
if err != nil {
h.serverError(w, r, "failed to count entrypoint events", err)
return
}
}
scheme := "http"
@@ -520,7 +531,7 @@ func (h *Handlers) renderSourceDetail(
// Targets are projected to a display-safe view: a
// target's stored config blob holds a credential, and it
// must never reach a template.
"Entrypoints": NewEntrypointViews(entrypoints),
"Entrypoints": entrypointViews,
"Targets": h.targetRows(&webhook, targets),
"Events": events,
"BaseURL": baseURL,