From 86277bd82c57c7b5ed2835d215e79b011b24b71d Mon Sep 17 00:00:00 2001 From: sneak Date: Tue, 27 Oct 2026 00:15:38 +0000 Subject: [PATCH] Show ages on the AS and prefix pages in words (closes #163) The Age column printed Go's duration format for every plural age, such as "5m0s ago" or "360h0m0s ago". The templates now register humanize.Time from go-humanize, which the server already uses, as the timeSince function, and the hand-written function is gone. Ages read "5 minutes ago", "3 hours ago" or "2 weeks ago". An age under a minute reads "now" or in seconds instead of "just now". An age over a month reads in months or years instead of a date. Model: opus-5-5 --- TODO.md | 4 ++ internal/server/handlers_internal_test.go | 65 +++++++++++++++++++++++ internal/templates/templates.go | 47 ++-------------- 3 files changed, 72 insertions(+), 44 deletions(-) diff --git a/TODO.md b/TODO.md index 4e987b1..34187e8 100644 --- a/TODO.md +++ b/TODO.md @@ -33,6 +33,10 @@ tested. # Completed Steps +- 2026-10-27: the Age column on the AS and prefix pages reads in words, such as + "5 minutes ago" or "2 weeks ago", instead of Go durations such as "360h0m0s + ago". An age under a minute reads in seconds, and one over a month in months + or years instead of a date (closes #163) - 2026-10-26: a write to a stored AS, peering or peer keeps the earlier of the two first-seen times and the later of the two last-seen times. An old update from a collector catching up on a backlog no longer moves last seen back diff --git a/internal/server/handlers_internal_test.go b/internal/server/handlers_internal_test.go index f4a87f3..0110958 100644 --- a/internal/server/handlers_internal_test.go +++ b/internal/server/handlers_internal_test.go @@ -512,6 +512,71 @@ func TestPagesSayOriginZeroIsNoKnownOrigin(t *testing.T) { } } +// TestPagesShowAgesInWords stores routes last updated about 5 minutes, 3 hours +// and 15 days ago, and checks that the AS page and the prefix pages show each +// age in words. Before the fix the ages read "5m0s ago", "3h0m0s ago" and +// "360h0m0s ago" (https://git.eeqj.de/sneak/routewatch/issues/163). +func TestPagesShowAgesInWords(t *testing.T) { + t.Parallel() + + db, err := database.New(&config.Config{StateDir: t.TempDir()}, logger.New()) + if err != nil { + t.Fatalf("database.New: %v", err) + } + + t.Cleanup(func() { _ = db.Close() }) + + // Each age is well past a whole number of its unit, so the time the test + // takes to run does not change the words. + ages := []struct { + prefix string + age time.Duration + want string + }{ + {testPrefixV4, 5*time.Minute + 30*time.Second, "5 minutes ago"}, + {"198.51.100.0/24", 3*time.Hour + 30*time.Minute, "3 hours ago"}, + {"203.0.113.0/24", 15 * 24 * time.Hour, "2 weeks ago"}, + } + + now := time.Now().UTC() + routes := make([]*database.LiveRoute, 0, len(ages)) + + for _, a := range ages { + routes = append(routes, &database.LiveRoute{ + Prefix: a.prefix, MaskLength: 24, IPVersion: 4, + OriginASN: 64500, PeerIP: testPeerV4, ASPath: []int{64500}, + NextHop: testPeerV4, LastUpdated: now.Add(-a.age), + }) + } + + err = db.UpsertLiveRouteBatch(routes) + if err != nil { + t.Fatalf("UpsertLiveRouteBatch: %v", err) + } + + s := New(db, streamer.New(logger.New(), metrics.New()), logger.New(), &config.Config{}) + + for _, a := range ages { + cell := `` + a.want + `` + + for _, path := range []string{asPagePath, "/prefix/" + a.prefix} { + rec := httptest.NewRecorder() + s.router.ServeHTTP(rec, httptest.NewRequestWithContext( + t.Context(), http.MethodGet, path, nil)) + + if rec.Code != http.StatusOK { + t.Fatalf("%s: status %d, want %d; body %s", + path, rec.Code, http.StatusOK, rec.Body) + } + + if !strings.Contains(rec.Body.String(), cell) { + t.Errorf("%s does not show the age of the route for %s as %q", + path, a.prefix, a.want) + } + } + } +} + // shortRequestDeadline is the deadline the tests below give a request. The // server's 30-second limit and the stats handlers' 4-second limit inherit it, // so the tests do not wait for either. diff --git a/internal/templates/templates.go b/internal/templates/templates.go index 16d6737..ad463fd 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -7,9 +7,9 @@ import ( "net/url" "strings" "sync" - "time" "git.eeqj.de/sneak/routewatch/internal/version" + "github.com/dustin/go-humanize" ) //go:embed index.html @@ -48,48 +48,7 @@ var ( once sync.Once ) -const ( - hoursPerDay = 24 - daysPerMonth = 30 - cidrPartCount = 2 // A CIDR has two parts: prefix and length -) - -// timeSince returns a human-readable duration since the given time -func timeSince(t time.Time) string { - duration := time.Since(t) - if duration < time.Minute { - return "just now" - } - - if duration < time.Hour { - minutes := int(duration.Minutes()) - if minutes == 1 { - return "1 minute ago" - } - - return duration.Truncate(time.Minute).String() + " ago" - } - - if duration < hoursPerDay*time.Hour { - hours := int(duration.Hours()) - if hours == 1 { - return "1 hour ago" - } - - return duration.Truncate(time.Hour).String() + " ago" - } - - days := int(duration.Hours() / hoursPerDay) - if days == 1 { - return "1 day ago" - } - - if days < daysPerMonth { - return duration.Truncate(hoursPerDay*time.Hour).String() + " ago" - } - - return t.Format("2006-01-02") -} +const cidrPartCount = 2 // A CIDR has two parts: prefix and length // prefixURL generates a URL path for a prefix in CIDR notation. // Takes a prefix like "192.168.1.0/24" and returns "/prefix/192.168.1.0/24" @@ -113,7 +72,7 @@ func initTemplates() { // Create common template functions funcs := template.FuncMap{ - "timeSince": timeSince, + "timeSince": humanize.Time, "urlEncode": url.QueryEscape, "prefixURL": prefixURL, "appName": func() string { return version.Name },