Author SHA1 Message Date
clawbot a4337e949b Show each webhook's activity in the webhook list (closes #394)
check / check (push) Waiting to run
Each entry in the webhook list now shows when the webhook's last event
arrived, or "No events yet", and how many of its deliveries failed in
the last 24 hours, in red when that is not zero. The entrypoint and
target counts say how many are inactive.

The figures come from the statistics pane's own reads: the event totals
row, which now also gives the event count, shown as events within
retention, instead of counting every stored event, and the pane's query
over the deliveries finished in the last 24 hours. A webhook whose event
database cannot be read says so in its entry rather than showing zeros.

Model: opus-5-5
2026-10-02 07:34:37 +00:00
19 changed files with 511 additions and 213 deletions
+10 -2
View File
@@ -1768,7 +1768,7 @@ retries) is individually logged for full observability.
#### EventTotals and TargetTotals
Running counts in each event database, read by the statistics pane at the
top of the webhook page. `EventTotals` is one row:
top of the webhook page and by the webhook list. `EventTotals` is one row:
| Field | Type | Description |
| ---------------- | --------- | ----------- |
@@ -1800,6 +1800,14 @@ target. Its failure percentage for a window is the deliveries that became
`failed` in it out of all that became `delivered` or `failed` in it, and
a dash when none did.
The webhook list at `/hooks` shows three of the pane's figures for each
webhook: its events within retention and its last event, both from
`EventTotals`, and its deliveries that failed in the last 24 hours,
counted with the pane's query. It opens each webhook's event database once
(the handle stays open) and runs those two reads there, so its cost grows
with the number of webhooks and, for each, with the deliveries that
finished in the last 24 hours, never with the events stored.
#### Event-tier indexes
These indexes on the per-webhook event databases are declared in the model
@@ -1807,7 +1815,7 @@ tags, so `AutoMigrate` creates them on a fresh database:
| Table | Columns | Serves |
| ------------------ | --------------------------- | ------ |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics and the webhook list, which count each target's deliveries by status and when they finished |
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
+6
View File
@@ -40,6 +40,12 @@ duplicate. That is deliberate — the alternative is a silent lost
delivery — and the README says so under Rationale. It is not a defect
to re-file.
One caveat on reading a green check: a docs-only commit deliberately
replays from the layer cache
(https://git.eeqj.de/sneak/webhooker/issues/119), so a green status on
such a commit evidences a replay rather than an executed run. A code
commit invalidates the `COPY` layer and genuinely executes.
# Next Step
Clear the rest of the open 1.0.0 milestone
-12
View File
@@ -124,11 +124,6 @@ func testEnvironmentConfigSuccess(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -277,7 +272,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -370,7 +364,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -411,7 +404,6 @@ func TestDefaultDataDir(t *testing.T) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -542,7 +534,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -659,7 +650,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -773,7 +763,6 @@ func testAllowedEgressCIDRsSuccess(
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
@@ -1017,7 +1006,6 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
-14
View File
@@ -14,7 +14,6 @@ import (
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
@@ -147,7 +146,6 @@ type EngineParams struct {
DB *database.Database
DBManager *database.WebhookDBManager
Globals *globals.Globals
Logger *logger.Logger
SSRFGuard *Guard
Metrics *metrics.Set
@@ -170,10 +168,6 @@ type Engine struct {
retryCh chan Task
workers int
// version is the running build's version, the one the web UI
// footer shows. userAgent puts it on every outbound request.
version string
// mtr is the delivery metric set. Production wires the one
// registered on the registry /metrics serves; a test can
// substitute a set registered on a registry it holds, so it can
@@ -211,7 +205,6 @@ func New(
deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize),
workers: defaultWorkers,
version: params.Globals.Version,
mtr: params.Metrics,
}
@@ -308,13 +301,6 @@ func (e *Engine) ScheduleRetry(
})
}
// userAgent is the User-Agent header of every http and slack
// delivery request: the program name and the running build's
// version.
func (e *Engine) userAgent() string {
return "webhooker/" + e.version
}
// registerHooks wires the engine's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored
// (see start for why the worker pool must not inherit it); the
+5 -1
View File
@@ -1247,6 +1247,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType,
receivedHeaders.Get("Content-Type"),
)
assert.Equal(t,
"webhooker/1.0",
receivedHeaders.Get("User-Agent"),
)
}
// The event's stored inbound headers carry the same Content-Type the
@@ -1315,7 +1320,6 @@ func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
ContentType: tc.event,
},
cfg,
"webhooker/dev",
)
assert.Equal(t,
+1 -2
View File
@@ -83,9 +83,8 @@ func ExportApplyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
userAgent string,
) []string {
return applyRequestHeaders(req, event, cfg, userAgent)
return applyRequestHeaders(req, event, cfg)
}
// ExportTruncate exposes truncate for testing.
-1
View File
@@ -375,7 +375,6 @@ func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
"Content-Type": testContentType,
},
},
"webhooker/dev",
)
assert.Equal(t,
+2 -7
View File
@@ -442,9 +442,7 @@ func (t *httpTarget) doHTTPRequest(
)
}
originScoped := applyRequestHeaders(
req, event, cfg, t.eng.userAgent(),
)
originScoped := applyRequestHeaders(req, event, cfg)
client := t.clientForRequest(cfg, originScoped)
@@ -564,13 +562,10 @@ func isForwardableHeader(name string) bool {
// Content-Type goes out once: a Content-Type configured on the target
// wins, otherwise the event's ContentType, otherwise none. The inbound
// Content-Type in the event's headers is never forwarded.
//
// userAgent is set last, over any configured or inbound User-Agent.
func applyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
userAgent string,
) []string {
if event.ContentType != "" {
req.Header.Set(
@@ -585,7 +580,7 @@ func applyRequestHeaders(
originScoped[http.CanonicalHeaderKey(k)] = struct{}{}
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("User-Agent", "webhooker/1.0")
// A Content-Type configured on the target describes the body
// being sent rather than the sender. A 307/308 preserves the
+1 -1
View File
@@ -136,7 +136,7 @@ func (t *slackTarget) attempt(
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", t.eng.userAgent())
req.Header.Set("User-Agent", "webhooker/1.0")
resp, doErr := executeHTTPRequest(t.client, req)
durationMs := time.Since(start).Milliseconds()
-91
View File
@@ -1,91 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/netip"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Both the http and the slack target send webhooker/ and the version
// in Globals, the value the web UI footer shows. A User-Agent
// configured on the target or carried in by the sender does not
// replace it.
func TestUserAgent_IsTheBuildVersion(t *testing.T) {
t.Parallel()
const want = "webhooker/1.2.3-test"
userAgents := make(chan string, 1)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
userAgents <- r.Header.Get("User-Agent")
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
g := &globals.Globals{Version: "1.2.3-test"}
lc := fxtest.NewLifecycle(t)
log, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
e := delivery.New(lc, delivery.EngineParams{
Globals: g,
Logger: log,
// httptest listens on loopback, which the default guard
// refuses.
SSRFGuard: delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
),
Metrics: metrics.New(prometheus.NewRegistry()),
})
statusCode, _, _, err := e.ExportDoHTTPRequest(
context.Background(),
&delivery.HTTPTargetConfig{
URL: ts.URL,
Headers: map[string]string{"User-Agent": "configured/1"},
},
&database.Event{Headers: `{"User-Agent":["curl/8"]}`},
)
require.NoError(t, err)
require.Equal(t, http.StatusOK, statusCode)
require.Len(t, userAgents, 1, "the http target sent no request")
assert.Equal(t, want, <-userAgents, "http target")
db := testWebhookDB(t)
targetID := uuid.New().String()
slackCfg, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: ts.URL},
)
require.NoError(t, err)
event := seedEvent(t, db, `{"action":"test"}`)
dlv := seedDelivery(
t, db, event.ID, targetID, database.DeliveryStatusPending,
)
e.ExportDeliverSlack(context.Background(), db, buildSlackDelivery(
dlv, event, targetID, "test-slack", string(slackCfg),
))
require.Len(t, userAgents, 1, "the slack target sent no request")
assert.Equal(t, want, <-userAgents, "slack target")
}
-4
View File
@@ -137,10 +137,6 @@ func bootAtDebug(t *testing.T, dataDir string) string {
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
-4
View File
@@ -86,10 +86,6 @@ func newTestApp(
return fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
-34
View File
@@ -241,37 +241,3 @@ func TestHandleSourceDetail_RendersNamedTargetFields(
assert.Contains(t, body, "(unavailable)")
assert.NotContains(t, body, "beak")
}
// TestHandleSourceDetail_FitsWideAndNarrowWindows pins the webhook
// page's maximum width at 108rem (1728 px), half again the 72rem of
// max-w-6xl that the webhook list and the event log use, so an
// entrypoint URL fits on one line in a 1920-pixel window; and the
// wrapping of its title row, so the buttons beside the title do not
// push a phone-width window into scrolling sideways.
func TestHandleSourceDetail_FitsWideAndNarrowWindows(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
)
app := newTestApp(t, &h, &sess, &db)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := seedWebhook(t, db)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(
t, body,
`<div class="mx-auto px-6 py-8" style="max-width: 108rem"`,
)
assert.Contains(
t, body,
`<div class="flex flex-wrap justify-between items-center gap-2 mt-2">`,
)
}
+355
View File
@@ -0,0 +1,355 @@
package handlers_test
import (
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/session"
)
// failedHighlight is how the list marks a number of failed deliveries
// that is not zero.
const failedHighlight = `class="font-medium text-red-600"`
// listWebhook adds a webhook with the given name, owned by the test
// user.
func listWebhook(
t *testing.T, db *database.Database, name string,
) *database.Webhook {
t.Helper()
wh := &database.Webhook{UserID: deleteTestUserID, Name: name}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
return wh
}
// renderWebhookList runs the real webhook list handler as the test user
// and returns the rendered page.
func renderWebhookList(
t *testing.T, h *handlers.Handlers, sess *session.Session,
) string {
t.Helper()
cookies := authenticatedCookies(
t, sess, deleteTestUserID, deleteTestUsername,
)
w := httptest.NewRecorder()
h.HandleSourceList().ServeHTTP(
w, getRequest(t, "/hooks", cookies, nil),
)
require.Equal(t, http.StatusOK, w.Code)
return w.Body.String()
}
// listCard returns one webhook's entry in a rendered webhook list, its
// markup as rendered and its text with the markup taken out and each
// run of space made one space.
func listCard(t *testing.T, page, webhookID string) (string, string) {
t.Helper()
_, card, found := strings.Cut(page, `href="/hook/`+webhookID+`"`)
require.True(t, found, "the list has no entry for %s", webhookID)
card, _, _ = strings.Cut(card, "</a>")
text := regexp.MustCompile(`<[^>]*>`).ReplaceAllString(card, " ")
return card, strings.Join(strings.Fields(text), " ")
}
// receiveEvents posts the given number of events to an entrypoint
// through the real receiver, and returns the webhook's event database
// and its events, oldest first.
func receiveEvents(
t *testing.T,
h *handlers.Handlers,
dbMgr *database.WebhookDBManager,
webhookID, path string,
count int,
) (*gorm.DB, []database.Event) {
t.Helper()
router := receiverRouter(h)
for range count {
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
}
webhookDB, err := dbMgr.GetDB(webhookID)
require.NoError(t, err)
events := listEvents(t, webhookDB)
require.Len(t, events, count)
return webhookDB, events
}
// seedFailingWebhook adds a webhook with two entrypoints, one inactive,
// and four targets, one inactive. Three events each reach the three
// active targets. Two deliveries failed in the last 24 hours, one 30
// hours ago, and one was delivered. It returns the webhook and its
// newest event.
func seedFailingWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "failing")
path := statsEntrypoint(t, db, wh.ID, true)
statsEntrypoint(t, db, wh.ID, false)
first := seedTarget(t, db, wh.ID, database.TargetTypeLog)
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
require.NoError(t, db.DB().Model(inactive).
Update("active", false).Error)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
now := time.Now()
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[0].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-30*time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[1].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Hour))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, first.ID),
database.DeliveryStatusFailed, now.Add(-time.Minute))
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, events[2].ID, second.ID),
database.DeliveryStatusDelivered, now.Add(-time.Minute))
return wh, events[2]
}
// seedHealthyWebhook adds a webhook with one entrypoint and one target,
// both active, and two events, both delivered. It returns the webhook
// and its newest event.
func seedHealthyWebhook(
t *testing.T,
h *handlers.Handlers,
db *database.Database,
dbMgr *database.WebhookDBManager,
) (*database.Webhook, database.Event) {
t.Helper()
wh := listWebhook(t, db, "healthy")
path := statsEntrypoint(t, db, wh.ID, true)
target := seedTarget(t, db, wh.ID, database.TargetTypeLog)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 2)
for _, ev := range events {
statsFinish(t, webhookDB,
statsDelivery(t, webhookDB, ev.ID, target.ID),
database.DeliveryStatusDelivered, time.Now())
}
return wh, events[1]
}
// lastEventText is how the list shows the arrival of an event.
func lastEventText(ev database.Event) string {
return ev.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
}
// TestSourceList_ShowsActivityOfEachWebhook checks the figures the list
// shows for a webhook with recent failures, a healthy one, a new one
// that has received no event, and one without an event database.
func TestSourceList_ShowsActivityOfEachWebhook(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)
failing, failingNewest := seedFailingWebhook(t, h, db, dbMgr)
healthy, healthyNewest := seedHealthyWebhook(t, h, db, dbMgr)
// Creating a webhook creates its event database.
fresh := listWebhook(t, db, "fresh")
require.NoError(t, dbMgr.CreateDB(fresh.ID))
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
card, text := listCard(t, page, failing.ID)
assert.Contains(t, text, "2 entrypoints, 1 inactive "+
"4 targets, 1 inactive "+
"3 events within retention "+
"Last event "+lastEventText(failingNewest)+" "+
"2 failed deliveries in the last 24 hours")
assert.Contains(t, card,
failedHighlight+">2 failed deliveries in the last 24 hours<")
card, text = listCard(t, page, healthy.ID)
assert.Contains(t, text, "1 entrypoint "+
"1 target "+
"2 events within retention "+
"Last event "+lastEventText(healthyNewest)+" "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, text, "inactive")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, fresh.ID)
assert.Contains(t, text, "0 entrypoints "+
"0 targets "+
"0 events within retention "+
"No events yet "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
card, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "0 entrypoints "+
"0 targets "+
"0 events within retention "+
"No events yet "+
"0 failed deliveries in the last 24 hours")
assert.NotContains(t, card, failedHighlight)
assert.False(t, dbMgr.DBExists(quiet.ID),
"showing the list must not create an event database")
}
// TestSourceList_CountsOnlyEventsWithinRetention checks that once
// retention has removed one of a webhook's three events, the list
// counts the two still stored.
func TestSourceList_CountsOnlyEventsWithinRetention(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 14,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 3)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-15*24*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Len(t, listEvents(t, webhookDB), 2)
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text,
"1 entrypoint 0 targets 2 events within retention")
}
// TestSourceList_LastEventSurvivesPruningEveryEvent checks that once
// retention has removed every event of a webhook, the list still shows
// when the last one arrived rather than "No events yet".
func TestSourceList_LastEventSurvivesPruningEveryEvent(t *testing.T) {
t.Parallel()
var (
h *handlers.Handlers
sess *session.Session
db *database.Database
dbMgr *database.WebhookDBManager
log *logger.Logger
)
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
app.RequireStart()
t.Cleanup(app.RequireStop)
wh := &database.Webhook{
UserID: deleteTestUserID, Name: "emptied", RetentionDays: 1,
}
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
path := statsEntrypoint(t, db, wh.ID, true)
webhookDB, events := receiveEvents(t, h, dbMgr, wh.ID, path, 1)
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
statsPrune(t, db, dbMgr, log, webhookDB)
require.Empty(t, listEvents(t, webhookDB))
_, text := listCard(t, renderWebhookList(t, h, sess), wh.ID)
assert.Contains(t, text,
"0 events within retention "+
"Last event "+lastEventText(events[0]))
assert.NotContains(t, text, "No events yet")
}
// TestSourceList_UnreadableEventDatabase checks that a webhook whose
// event database cannot be read says so in its entry instead of
// showing zeros, and that the rest of the list is still shown.
func TestSourceList_UnreadableEventDatabase(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)
broken := listWebhook(t, db, "broken")
statsEntrypoint(t, db, broken.ID, true)
brokenDB, err := dbMgr.GetDB(broken.ID)
require.NoError(t, err)
require.NoError(t,
brokenDB.Migrator().DropTable(&database.EventTotals{}))
quiet := listWebhook(t, db, "quiet")
page := renderWebhookList(t, h, sess)
_, text := listCard(t, page, broken.ID)
assert.Contains(t, text,
"1 entrypoint 0 targets The event figures could not be read.")
assert.NotContains(t, text, "events")
assert.NotContains(t, text, "failed")
_, text = listCard(t, page, quiet.ID)
assert.Contains(t, text, "No events yet")
}
+119 -22
View File
@@ -3,10 +3,12 @@ package handlers
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"slices"
"strconv"
"strings"
"time"
"github.com/go-chi/chi"
"github.com/google/uuid"
@@ -20,9 +22,20 @@ import (
type WebhookListItem struct {
database.Webhook
EntrypointCount int64
TargetCount int64
EventCount int64
EntrypointCount int
InactiveEntrypointCount int
TargetCount int
InactiveTargetCount int
// EventCount is how many events the webhook holds, LastEventAt
// when the newest arrived (nil before the first), and
// FailedLast24Hours how many of its deliveries failed in the last
// 24 hours. When the webhook's event database could not be read,
// EventsUnreadable is set and these three are not known.
EventCount int64
LastEventAt *time.Time
FailedLast24Hours int64
EventsUnreadable bool
}
// errMissingURL signals that a required URL was not provided.
@@ -154,7 +167,12 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
return
}
items := h.buildWebhookListItems(webhooks)
items, err := h.buildWebhookListItems(webhooks)
if err != nil {
h.serverError(w, r, "failed to list webhooks", err)
return
}
data := map[string]any{
"Webhooks": items,
@@ -164,36 +182,115 @@ func (h *Handlers) HandleSourceList() http.HandlerFunc {
}
}
// buildWebhookListItems builds list items with counts.
// buildWebhookListItems builds the list's entry for each webhook. It
// fails when the main database cannot be read. A webhook whose event
// database cannot be read is marked on its own entry, and the error is
// logged.
func (h *Handlers) buildWebhookListItems(
webhooks []database.Webhook,
) []WebhookListItem {
) ([]WebhookListItem, error) {
items := make([]WebhookListItem, len(webhooks))
since := time.Now().Add(-longWindow)
for i := range webhooks {
items[i].Webhook = webhooks[i]
item := &items[i]
item.Webhook = webhooks[i]
h.db.DB().Model(&database.Entrypoint{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].EntrypointCount)
var err error
h.db.DB().Model(&database.Target{}).Where(
"webhook_id = ?", webhooks[i].ID,
).Count(&items[i].TargetCount)
item.EntrypointCount, item.InactiveEntrypointCount, err =
h.countWithInactive(&database.Entrypoint{}, item.ID)
if err != nil {
return nil, err
}
if h.dbMgr.DBExists(webhooks[i].ID) {
webhookDB, err := h.dbMgr.GetDB(
webhooks[i].ID,
item.TargetCount, item.InactiveTargetCount, err =
h.countWithInactive(&database.Target{}, item.ID)
if err != nil {
return nil, err
}
// Opening an event database that does not exist would create
// it, and it would hold nothing to count.
if !h.dbMgr.DBExists(item.ID) {
continue
}
err = h.readListEventFigures(item, since)
if err != nil {
h.log.Error(
"failed to read webhook list figures",
"webhook_id", item.ID,
"error", err,
)
if err == nil {
webhookDB.Model(
&database.Event{},
).Count(&items[i].EventCount)
}
item.EventsUnreadable = true
}
}
return items
return items, nil
}
// countWithInactive returns how many entrypoints or targets, as model
// says, a webhook has, and how many of them are inactive.
func (h *Handlers) countWithInactive(
model any, webhookID string,
) (int, int, error) {
var active []bool
err := h.db.DB().Model(model).
Where("webhook_id = ?", webhookID).
Pluck("active", &active).Error
if err != nil {
return 0, 0, fmt.Errorf(
"reading active flags of webhook %s: %w", webhookID, err,
)
}
inactive := 0
for _, a := range active {
if !a {
inactive++
}
}
return len(active), inactive, nil
}
// readListEventFigures fills in the figures the list shows from the
// webhook's event database, with the statistics pane's own queries:
// the event count and last arrival from the event totals row, and the
// deliveries that failed since the given time from the deliveries'
// status index.
func (h *Handlers) readListEventFigures(
item *WebhookListItem, since time.Time,
) error {
webhookDB, err := h.dbMgr.GetDB(item.ID)
if err != nil {
return err
}
var totals database.EventTotals
err = webhookDB.Take(&totals).Error
if err != nil {
return fmt.Errorf("reading event totals: %w", err)
}
item.EventCount = totals.Events - totals.EventsRemoved
item.LastEventAt = totals.LastEventAt
byTarget, err := finishedByTarget(webhookDB, since)
if err != nil {
return err
}
for _, f := range byTarget {
item.FailedLast24Hours += f.Failed
}
return nil
}
// HandleSourceCreate shows the form to create a new webhook.
-4
View File
@@ -152,10 +152,6 @@ func newServerApp(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
-4
View File
@@ -104,10 +104,6 @@ func newTestEnvWithConfig(
app := fxtest.New(
t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned.
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
+2 -6
View File
@@ -3,14 +3,10 @@
{{define "title"}}{{.Webhook.Name}} - Webhooker{{end}}
{{define "content"}}
<!-- 108rem, half again the 72rem (max-w-6xl) of the webhook list, the
event log, the navbar and the footer, so an entrypoint URL fits on
one line. An inline style, because the committed tailwind.css has
no class this wide. -->
<div class="mx-auto px-6 py-8" style="max-width: 108rem" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="max-w-6xl mx-auto px-6 py-8" x-data="{ showAddEntrypoint: false, showAddTarget: false }">
<div class="mb-6">
<a href="/hooks" class="text-sm text-primary-600 hover:text-primary-700">&larr; Back to webhooks</a>
<div class="flex flex-wrap justify-between items-center gap-2 mt-2">
<div class="flex justify-between items-center mt-2">
<div>
<h1 class="text-2xl font-medium text-gray-900">{{.Webhook.Name}}</h1>
{{if .Webhook.Description}}
+10 -4
View File
@@ -27,10 +27,16 @@
</div>
<span class="badge-info">Retention: {{.RetentionLabel}}</span>
</div>
<div class="flex gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}</span>
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}}</span>
<div class="flex flex-wrap gap-6 mt-4 text-sm text-gray-500">
<span>{{.EntrypointCount}} entrypoint{{if ne .EntrypointCount 1}}s{{end}}{{if .InactiveEntrypointCount}}, {{.InactiveEntrypointCount}} inactive{{end}}</span>
<span>{{.TargetCount}} target{{if ne .TargetCount 1}}s{{end}}{{if .InactiveTargetCount}}, {{.InactiveTargetCount}} inactive{{end}}</span>
{{if .EventsUnreadable}}
<span class="text-red-600">The event figures could not be read.</span>
{{else}}
<span>{{.EventCount}} event{{if ne .EventCount 1}}s{{end}} within retention</span>
<span>{{with .LastEventAt}}Last event {{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}No events yet{{end}}</span>
<span class="{{if .FailedLast24Hours}}font-medium text-red-600{{end}}">{{.FailedLast24Hours}} failed deliver{{if eq .FailedLast24Hours 1}}y{{else}}ies{{end}} in the last 24 hours</span>
{{end}}
</div>
</a>
{{end}}