From c5812c996466bd0b752b0771894efda2acb7fd39 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 13:09:05 +0000 Subject: [PATCH 1/2] Show the last event as a relative time with a UTC tooltip (closes #567) 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 --- internal/handlers/export_test.go | 6 ++ internal/handlers/webhook_stats.go | 61 +++++++++++++++++-- internal/handlers/webhook_stats_test.go | 79 +++++++++++++++++++------ internal/server/alpine_browser_test.go | 39 +++++++++++- templates/webhook_stats.html | 6 +- 5 files changed, 166 insertions(+), 25 deletions(-) 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..6f631d3 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. + {-time.Hour, "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 -- 2.54.0 From 1577876fc21425a561b0968aa931817279788992 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 26 Oct 2026 18:06:19 +0000 Subject: [PATCH 2/2] Test a future time 90 seconds ahead in TestRelativeTime An hour ahead comes out as 0s even without the clamp to zero, so the case did not pin it. 90 seconds ahead would come out as -30s. Model: opus-5-5 --- internal/handlers/webhook_stats_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/handlers/webhook_stats_test.go b/internal/handlers/webhook_stats_test.go index 6f631d3..29a4b90 100644 --- a/internal/handlers/webhook_stats_test.go +++ b/internal/handlers/webhook_stats_test.go @@ -604,7 +604,7 @@ func TestRelativeTime(t *testing.T) { {90000 * time.Second, "1d1h"}, {400*24*time.Hour + 3*time.Hour, "400d3h"}, // An event time ahead of the server clock. - {-time.Hour, "0s"}, + {-90 * time.Second, "0s"}, } for _, tt := range tests { -- 2.54.0