List an AS's prefixes by mask length, shortest first (closes #153)
check / check (push) Canceled after 0s

The AS page sorted its IPv4 and IPv6 prefix lists by network address,
then by mask length. It now sorts them by mask length, shortest first,
and by network address within one mask length, so a /16 comes before a
/24. The sort function is renamed from sortRoutesByNetwork to
sortRoutesByMaskLength to say so.

The handler of /api/v1/as/{asn} does not sort its lists, so it is
unchanged.

Model: opus-5-5
This commit was merged in pull request #181.
This commit is contained in:
2026-10-27 04:37:17 +01:00
parent a5bff4e13c
commit 2375480e2e
3 changed files with 70 additions and 21 deletions
+3
View File
@@ -33,6 +33,9 @@ tested.
# Completed Steps # Completed Steps
- 2026-10-27: the AS page lists its IPv4 and its IPv6 prefixes by mask length,
shortest first, and prefixes of one mask length by address. They used to be
listed by address (closes #153)
- 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame - 2026-10-27: on `/` and `/status`, a spinner beside the status turns one frame
each time the stats show more messages than the time before. A page that has each time the stats show more messages than the time before. A page that has
not seen more messages for three refreshes shows "stale" and the time since not seen more messages for three refreshes shows "stale" and the time since
+12 -21
View File
@@ -1,7 +1,7 @@
package server package server
import ( import (
"bytes" "cmp"
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
@@ -9,6 +9,7 @@ import (
"net/http" "net/http"
"net/netip" "net/netip"
"runtime" "runtime"
"slices"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -1028,24 +1029,15 @@ func splitRoutesByIPVersion(
return ipv4Routes, ipv6Routes return ipv4Routes, ipv6Routes
} }
// sortRoutesByNetwork sorts routes by network address, then by mask length. // sortRoutesByMaskLength sorts routes by mask length, shortest first, and
func sortRoutesByNetwork(routes []database.LiveRoute) { // routes with the same mask length by network address.
sort.Slice(routes, func(i, j int) bool { func sortRoutesByMaskLength(routes []database.LiveRoute) {
// Parse the prefixes to compare network addresses slices.SortFunc(routes, func(a, b database.LiveRoute) int {
ipI, netI, _ := net.ParseCIDR(routes[i].Prefix) prefixA, _ := netip.ParsePrefix(a.Prefix)
ipJ, netJ, _ := net.ParseCIDR(routes[j].Prefix) prefixB, _ := netip.ParsePrefix(b.Prefix)
// Compare by network address first return cmp.Or(cmp.Compare(prefixA.Bits(), prefixB.Bits()),
cmp := bytes.Compare(ipI.To16(), ipJ.To16()) prefixA.Addr().Compare(prefixB.Addr()))
if cmp != 0 {
return cmp < 0
}
// If network addresses are equal, compare by mask length
onesI, _ := netI.Mask.Size()
onesJ, _ := netJ.Mask.Size()
return onesI < onesJ
}) })
} }
@@ -1212,9 +1204,8 @@ func (s *Server) handleASDetail() http.HandlerFunc {
// Group prefixes by IP version // Group prefixes by IP version
ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes) ipv4Prefixes, ipv6Prefixes := splitRoutesByIPVersion(prefixes)
// Sort prefixes by network address sortRoutesByMaskLength(ipv4Prefixes)
sortRoutesByNetwork(ipv4Prefixes) sortRoutesByMaskLength(ipv6Prefixes)
sortRoutesByNetwork(ipv6Prefixes)
// Prepare template data // Prepare template data
data := struct { data := struct {
+55
View File
@@ -1586,6 +1586,61 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
} }
} }
// TestSortRoutesByMaskLength expects the AS page's prefix lists in mask length
// order, shortest first, and in address order within one mask length. Before
// the fix they were in address order, so 10.1.0.0/16 came before 11.0.0.0/8
// (https://git.eeqj.de/sneak/routewatch/issues/153).
func TestSortRoutesByMaskLength(t *testing.T) {
t.Parallel()
tests := []struct {
name string
prefixes []string
want []string
}{
{
name: "IPv4",
prefixes: []string{
"100.64.1.0/24", "11.0.0.0/8", "10.1.0.0/16",
"20.0.1.0/24", "172.16.0.0/12", "9.0.0.0/8",
},
want: []string{
"9.0.0.0/8", "11.0.0.0/8", "172.16.0.0/12",
"10.1.0.0/16", "20.0.1.0/24", "100.64.1.0/24",
},
},
{
name: "IPv6",
prefixes: []string{
"2001:db8:1::/48", "2001:db8:8000::/33", "2001:db8:0:1::/64",
"2001:db8::/48", "2001:db8::/33",
},
want: []string{
"2001:db8::/33", "2001:db8:8000::/33", "2001:db8::/48",
"2001:db8:1::/48", "2001:db8:0:1::/64",
},
},
}
for _, tt := range tests {
routes := make([]database.LiveRoute, 0, len(tt.prefixes))
for _, prefix := range tt.prefixes {
routes = append(routes, database.LiveRoute{Prefix: prefix})
}
sortRoutesByMaskLength(routes)
got := make([]string, 0, len(routes))
for _, route := range routes {
got = append(got, route.Prefix)
}
if !slices.Equal(got, tt.want) {
t.Errorf("%s: order %v, want %v", tt.name, got, tt.want)
}
}
}
// TestNoOriginHasNoASAnswers stores a route with no known origin, which has // TestNoOriginHasNoASAnswers stores a route with no known origin, which has
// origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects // origin AS 0 (https://git.eeqj.de/sneak/routewatch/issues/75). It expects
// /as/0 and /api/v1/as/0 to answer 404. // /as/0 and /api/v1/as/0 to answer 404.