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 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
This commit is contained in:
@@ -96,6 +96,12 @@ func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
|||||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
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
|
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
||||||
// handlers_test package.
|
// handlers_test package.
|
||||||
func FinishedByTargetForTest(
|
func FinishedByTargetForTest(
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ const (
|
|||||||
longWindow = 24 * time.Hour
|
longWindow = 24 * time.Hour
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// day is the largest unit relativeTime writes.
|
||||||
|
const day = 24 * time.Hour
|
||||||
|
|
||||||
// percent turns a fraction into a percentage.
|
// percent turns a fraction into a percentage.
|
||||||
const percent = 100
|
const percent = 100
|
||||||
|
|
||||||
@@ -35,8 +38,12 @@ type WebhookStats struct {
|
|||||||
InProgress int64
|
InProgress int64
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil when none
|
// LastEventAt is when the newest event arrived, or nil when none
|
||||||
// has. Retention does not change it.
|
// has. Retention does not change it. LastEvent is how long ago
|
||||||
LastEventAt *time.Time
|
// 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
|
Last10Minutes RecentWindow
|
||||||
Last24Hours 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
|
// loadWebhookStats gathers the figures for the statistics pane from the
|
||||||
// webhook's entrypoints and targets, as the page has already loaded
|
// webhook's entrypoints and targets, as the page has already loaded
|
||||||
// them, and from its event database. It returns nil, and logs why, when
|
// them, and from its event database. It returns nil, and logs why, when
|
||||||
@@ -139,7 +182,7 @@ func (h *Handlers) loadWebhookStats(
|
|||||||
func readEventStats(
|
func readEventStats(
|
||||||
db *gorm.DB, now time.Time, stats *WebhookStats,
|
db *gorm.DB, now time.Time, stats *WebhookStats,
|
||||||
) error {
|
) error {
|
||||||
err := readTotals(db, stats)
|
err := readTotals(db, now, stats)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -169,9 +212,9 @@ func readEventStats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// readTotals fills in the lifetime and within-retention figures, and
|
// readTotals fills in the lifetime and within-retention figures, and
|
||||||
// when the last event arrived, from the running totals: the events'
|
// when the last event arrived and how long before now that was, from
|
||||||
// row, and the targets' rows summed.
|
// the running totals: the events' row, and the targets' rows summed.
|
||||||
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
func readTotals(db *gorm.DB, now time.Time, stats *WebhookStats) error {
|
||||||
var events database.EventTotals
|
var events database.EventTotals
|
||||||
|
|
||||||
err := db.Take(&events).Error
|
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.WithinRetention.Events = events.Events - events.EventsRemoved
|
||||||
stats.LastEventAt = events.LastEventAt
|
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 {
|
for _, t := range targets {
|
||||||
stats.Lifetime.Deliveries += t.Deliveries
|
stats.Lifetime.Deliveries += t.Deliveries
|
||||||
stats.Lifetime.Failures += t.Failed
|
stats.Lifetime.Failures += t.Failed
|
||||||
|
|||||||
@@ -265,6 +265,13 @@ func statsPane(t *testing.T, page string) string {
|
|||||||
return strings.Join(strings.Fields(pane), " ")
|
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,
|
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
||||||
// each target's totals and its deliveries finished in the last 24
|
// each target's totals and its deliveries finished in the last 24
|
||||||
// hours. The first target has ten deliveries and the replay, the
|
// hours. The first target has ten deliveries and the replay, the
|
||||||
@@ -300,14 +307,17 @@ func assertStatsPaneAfterPrune(
|
|||||||
) {
|
) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
page := renderSourceDetailPage(t, h, sess, hist.webhook.ID)
|
||||||
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
pane := statsPane(t, page)
|
||||||
|
|
||||||
assert.Contains(t, pane, "Entrypoints 12 (11 active) "+
|
// The newest event arrived moments ago, so the last event is a
|
||||||
"Targets 6 (2 active) "+
|
// few seconds ago.
|
||||||
"Deliveries in progress 13 "+
|
assert.Regexp(t, `Entrypoints 12 \(11 active\) `+
|
||||||
"Last event "+lastEvent+" "+
|
`Targets 6 \(2 active\) `+
|
||||||
"Retention 14 days")
|
`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 "+
|
assert.Contains(t, pane, "Lifetime Within retention "+
|
||||||
"Events 10 9 "+
|
"Events 10 9 "+
|
||||||
"Deliveries 21 18 "+
|
"Deliveries 21 18 "+
|
||||||
@@ -440,15 +450,14 @@ func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|||||||
require.NotNil(t, stats.LastEventAt)
|
require.NotNil(t, stats.LastEventAt)
|
||||||
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
assert.Regexp(t, statsLastEventShown(arrived),
|
||||||
assert.Contains(t, pane,
|
renderSourceDetailPage(t, h, sess, wh.ID))
|
||||||
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
// TestWebhookStats_LastEventInUTC checks that the tooltip of the pane's
|
||||||
// last event arrived in UTC, as the event list does, when the time was
|
// last event gives the time in UTC, as the event list does, when the
|
||||||
// stored in another zone, as it is on a host whose local time is not
|
// time was stored in another zone, as it is on a host whose local time
|
||||||
// UTC.
|
// is not UTC.
|
||||||
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -474,8 +483,9 @@ func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
|||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
require.NoError(t, database.AddEventTotals(webhookDB,
|
||||||
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
assert.Regexp(t,
|
||||||
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
`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
|
// 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.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
||||||
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
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.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.Contains(t, pane, "Failure percentage — —")
|
||||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
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)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ const (
|
|||||||
// event log in a headless browser, served by the real router and so
|
// event log in a headless browser, served by the real router and so
|
||||||
// under the real Content-Security-Policy, and checks that the pages'
|
// under the real Content-Security-Policy, and checks that the pages'
|
||||||
// Alpine.js directives, the copy control and the delete prompts work,
|
// 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) {
|
func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -97,6 +98,7 @@ func TestAlpineRunsUnderTheSecurityPolicy(t *testing.T) {
|
|||||||
checkCopy(ctx, t, page)
|
checkCopy(ctx, t, page)
|
||||||
checkEntrypointEdit(ctx, t, page, page+"/events")
|
checkEntrypointEdit(ctx, t, page, page+"/events")
|
||||||
checkRecentEvents(ctx, t, page)
|
checkRecentEvents(ctx, t, page)
|
||||||
|
checkStatsLastEvent(ctx, t, env, webhook.ID, page)
|
||||||
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
checkArchiveChoice(ctx, t, srv.URL+"/hooks/new", page)
|
||||||
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
checkNewWebhookTargets(ctx, t, env, srv.URL+"/hooks/new")
|
||||||
checkRefusedNewWebhook(ctx, t, 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")
|
"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
|
// 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
|
// 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
|
// turned up, and the log's last event, lastEventID, starts collapsed. In
|
||||||
|
|||||||
@@ -22,7 +22,11 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Last event</span>
|
<span class="text-gray-500">Last event</span>
|
||||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
{{if .LastEvent}}
|
||||||
|
<span class="font-medium text-gray-900" title="{{.LastEventUTC}}">{{.LastEvent}}</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="font-medium text-gray-900">none</span>
|
||||||
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="text-gray-500">Retention</span>
|
<span class="text-gray-500">Retention</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user