Show the last event as a relative time with a UTC tooltip (closes #567)
check / check (push) Waiting to run
check / check (push) Waiting to run
The "Last event" figure in the webhook page's Statistics block now shows how long ago the newest event arrived, as its two largest units of d, h, m and s with no spaces ("59s", "1m1s", "3d4h"; a zero second unit is left off). It is worked out when the page is served, and a time ahead of the server clock shows "0s". Its tooltip is the full time in UTC. With no event it still shows "none", without a tooltip. A unit test pins each unit boundary, and the headless-browser test checks the figure and its tooltip.
Model: opus-5-5
This commit was merged in pull request #568.
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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</span>\s*<span [^>]*title="` +
|
||||
at.UTC().Format(time.DateTime) + ` UTC">[^<]+</span>`
|
||||
}
|
||||
|
||||
// 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</span>\s*<span [^>]*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</span>\s*<span class="[^"]*">none</span>`,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user