Show ages on the AS and prefix pages in words (closes #163)
check / check (push) Waiting to run
check / check (push) Waiting to run
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
This commit is contained in:
@@ -33,6 +33,10 @@ tested.
|
|||||||
|
|
||||||
# Completed Steps
|
# 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
|
- 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
|
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
|
from a collector catching up on a backlog no longer moves last seen back
|
||||||
|
|||||||
@@ -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 := `<td class="age">` + a.want + `</td>`
|
||||||
|
|
||||||
|
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
|
// 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,
|
// server's 30-second limit and the stats handlers' 4-second limit inherit it,
|
||||||
// so the tests do not wait for either.
|
// so the tests do not wait for either.
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
|
||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/version"
|
"git.eeqj.de/sneak/routewatch/internal/version"
|
||||||
|
"github.com/dustin/go-humanize"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed index.html
|
//go:embed index.html
|
||||||
@@ -48,48 +48,7 @@ var (
|
|||||||
once sync.Once
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const cidrPartCount = 2 // A CIDR has two parts: prefix and length
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
// prefixURL generates a URL path for a prefix in CIDR notation.
|
// 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"
|
// 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
|
// Create common template functions
|
||||||
funcs := template.FuncMap{
|
funcs := template.FuncMap{
|
||||||
"timeSince": timeSince,
|
"timeSince": humanize.Time,
|
||||||
"urlEncode": url.QueryEscape,
|
"urlEncode": url.QueryEscape,
|
||||||
"prefixURL": prefixURL,
|
"prefixURL": prefixURL,
|
||||||
"appName": func() string { return version.Name },
|
"appName": func() string { return version.Name },
|
||||||
|
|||||||
Reference in New Issue
Block a user