diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index 73a832e..9d3abe5 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -96,6 +96,12 @@ func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats { return s.loadWebhookStats(webhookID, entrypoints, targets) } +// RelativeTimeForTest exposes relativeTime for use in the +// handlers_test package. +func RelativeTimeForTest(at, now time.Time) string { + return relativeTime(at, now) +} + // FinishedByTargetForTest exposes finishedByTarget for use in the // handlers_test package. func FinishedByTargetForTest( diff --git a/internal/handlers/webhook_stats.go b/internal/handlers/webhook_stats.go index eb6a639..90b8c49 100644 --- a/internal/handlers/webhook_stats.go +++ b/internal/handlers/webhook_stats.go @@ -15,6 +15,9 @@ const ( 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 @@ -35,8 +38,12 @@ type WebhookStats struct { InProgress int64 // LastEventAt is when the newest event arrived, or nil when none - // has. Retention does not change it. - LastEventAt *time.Time + // 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 @@ -81,6 +88,42 @@ func (w RecentWindow) FailurePercent() string { ) } +// 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 @@ -139,7 +182,7 @@ func (h *Handlers) loadWebhookStats( func readEventStats( db *gorm.DB, now time.Time, stats *WebhookStats, ) error { - err := readTotals(db, stats) + err := readTotals(db, now, stats) if err != nil { return err } @@ -169,9 +212,9 @@ func readEventStats( } // readTotals fills in the lifetime and within-retention figures, and -// when the last event arrived, from the running totals: the events' -// row, and the targets' rows summed. -func readTotals(db *gorm.DB, stats *WebhookStats) error { +// 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 @@ -190,6 +233,12 @@ func readTotals(db *gorm.DB, stats *WebhookStats) error { 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 diff --git a/internal/handlers/webhook_stats_test.go b/internal/handlers/webhook_stats_test.go index 1cd0c11..29a4b90 100644 --- a/internal/handlers/webhook_stats_test.go +++ b/internal/handlers/webhook_stats_test.go @@ -265,6 +265,13 @@ func statsPane(t *testing.T, page string) string { return strings.Join(strings.Fields(pane), " ") } +// statsLastEventShown matches the pane's last event for an event that +// arrived at at: a figure with the full time in UTC as its tooltip. +func statsLastEventShown(at time.Time) string { + return `Last event\s*]*title="` + + at.UTC().Format(time.DateTime) + ` UTC">[^<]+` +} + // assertStatsTargets checks, for the history seedStatsHistory builds, // each target's totals and its deliveries finished in the last 24 // hours. The first target has ten deliveries and the replay, the @@ -300,14 +307,17 @@ func assertStatsPaneAfterPrune( ) { t.Helper() - pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID)) - lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC") + page := renderSourceDetailPage(t, h, sess, hist.webhook.ID) + pane := statsPane(t, page) - assert.Contains(t, pane, "Entrypoints 12 (11 active) "+ - "Targets 6 (2 active) "+ - "Deliveries in progress 13 "+ - "Last event "+lastEvent+" "+ - "Retention 14 days") + // The newest event arrived moments ago, so the last event is a + // few seconds ago. + assert.Regexp(t, `Entrypoints 12 \(11 active\) `+ + `Targets 6 \(2 active\) `+ + `Deliveries in progress 13 `+ + `Last event \d+s `+ + `Retention 14 days`, pane) + assert.Regexp(t, statsLastEventShown(hist.newest.CreatedAt), page) assert.Contains(t, pane, "Lifetime Within retention "+ "Events 10 9 "+ "Deliveries 21 18 "+ @@ -440,15 +450,14 @@ func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) { require.NotNil(t, stats.LastEventAt) assert.True(t, arrived.Equal(*stats.LastEventAt)) - pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) - assert.Contains(t, pane, - "Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC")) + assert.Regexp(t, statsLastEventShown(arrived), + renderSourceDetailPage(t, h, sess, wh.ID)) } -// TestWebhookStats_LastEventInUTC checks that the pane shows when the -// last event arrived in UTC, as the event list does, when the time was -// stored in another zone, as it is on a host whose local time is not -// UTC. +// TestWebhookStats_LastEventInUTC checks that the tooltip of the pane's +// last event gives the time in UTC, as the event list does, when the +// time was stored in another zone, as it is on a host whose local time +// is not UTC. func TestWebhookStats_LastEventInUTC(t *testing.T) { t.Parallel() @@ -474,8 +483,9 @@ func TestWebhookStats_LastEventInUTC(t *testing.T) { require.NoError(t, database.AddEventTotals(webhookDB, database.EventTotals{Events: 1, LastEventAt: &arrived})) - pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) - assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC") + assert.Regexp(t, + `Last event\s*]*title="2026-03-05 03:30:00 UTC">`, + renderSourceDetailPage(t, h, sess, wh.ID)) } // TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics @@ -541,8 +551,11 @@ func TestWebhookStats_WebhookWithNoEvents(t *testing.T) { assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID)) assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent()) - pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID)) + page := renderSourceDetailPage(t, h, sess, wh.ID) + pane := statsPane(t, page) assert.Contains(t, pane, "Last event none") + assert.Regexp(t, `Last event\s*none`, + page, "a last event of none has a tooltip") assert.Contains(t, pane, "Failure percentage — —") assert.False(t, dbMgr.DBExists(wh.ID)) } @@ -567,3 +580,35 @@ func TestRecentWindow_FailurePercent(t *testing.T) { assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window) } } + +// TestRelativeTime pins how the pane writes the time since the last +// event, at each boundary between its units. +func TestRelativeTime(t *testing.T) { + t.Parallel() + + now := time.Date(2026, time.October, 26, 12, 0, 0, 0, time.UTC) + + tests := []struct { + ago time.Duration + want string + }{ + {0, "0s"}, + {59900 * time.Millisecond, "59s"}, + {60 * time.Second, "1m"}, + {61 * time.Second, "1m1s"}, + {3599 * time.Second, "59m59s"}, + {3600 * time.Second, "1h"}, + {3660 * time.Second, "1h1m"}, + {86399 * time.Second, "23h59m"}, + {86400 * time.Second, "1d"}, + {90000 * time.Second, "1d1h"}, + {400*24*time.Hour + 3*time.Hour, "400d3h"}, + // An event time ahead of the server clock. + {-90 * time.Second, "0s"}, + } + + for _, tt := range tests { + assert.Equal(t, tt.want, + handlers.RelativeTimeForTest(now.Add(-tt.ago), now), tt.ago) + } +} diff --git a/internal/server/alpine_browser_test.go b/internal/server/alpine_browser_test.go index e27bbfa..eb4c3f0 100644 --- a/internal/server/alpine_browser_test.go +++ b/internal/server/alpine_browser_test.go @@ -61,7 +61,8 @@ const ( // event log in a headless browser, served by the real router and so // under the real Content-Security-Policy, and checks that the pages' // Alpine.js directives, the copy control and the delete prompts work, -// and that a target's row shows its delivery counts. +// that a target's row shows its delivery counts, and that the +// Statistics block shows how long ago the last event arrived. func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { t.Parallel() @@ -97,6 +98,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) { checkCopy(ctx, t, page) checkEntrypointEdit(ctx, t, page, page+"/events") checkRecentEvents(ctx, t, page) + checkStatsLastEvent(ctx, t, env, webhook.ID, page) checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page) checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new") checkRefusedNewWebhook(ctx, t, srv.URL+"/hooks/new") @@ -831,6 +833,41 @@ func checkRecentEvents(ctx context.Context, t *testing.T, url string) { "the event's own page does not show its body") } +// checkStatsLastEvent records that the last event of the webhook +// webhookID arrived 3 days, 4 hours and 30 minutes ago, loads its page, +// and checks that the Statistics block shows the last event as "3d4h" +// with the full time in UTC as its tooltip. +func checkStatsLastEvent( + ctx context.Context, t *testing.T, env *testEnv, webhookID, url string, +) { + t.Helper() + + webhookDB, err := env.dbMgr.GetDB(webhookID) + require.NoError(t, err) + + ago := 3*24*time.Hour + 4*time.Hour + 30*time.Minute + arrived := time.Now().Add(-ago) + require.NoError(t, database.AddEventTotals(webhookDB, + database.EventTotals{LastEventAt: &arrived})) + + figure := `//span[text()="Last event"]` + + `/following-sibling::span[text()="3d4h"]` + + var ( + title string + found bool + ) + + require.NoError(t, chromedp.Run(ctx, loadPage(url))) + require.True(t, shown(ctx, figure), + `the Statistics block does not show the last event as "3d4h"`) + require.NoError(t, chromedp.Run(ctx, chromedp.AttributeValue( + figure, "title", &title, &found, chromedp.BySearch, + ))) + assert.Equal(t, arrived.UTC().Format(time.DateTime)+" UTC", title, + "the last event's tooltip is not the full time in UTC") +} + // checkEventLog loads the event log and checks that of its events only // the newest, eventID, starts expanded: its row says so and its caret is // turned up, and the log's last event, lastEventID, starts collapsed. In diff --git a/templates/webhook_stats.html b/templates/webhook_stats.html index ceba714..d8b9d5a 100644 --- a/templates/webhook_stats.html +++ b/templates/webhook_stats.html @@ -22,7 +22,11 @@
Last event - {{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}} + {{if .LastEvent}} + {{.LastEvent}} + {{else}} + none + {{end}}
Retention