Compare commits

..
1 Commits
Author SHA1 Message Date
sneak 72034907a7 Make the home page's AS name search work (closes #151)
check / check (push) Canceled after 0s
The AS Name Search card on the home page said the search was coming
soon. It now asks the new GET /api/v1/search/as?q=<text>, which
searches the AS list built into asinfo, and lists the matches as links
to their AS pages. An empty q answers 400. asinfo.Search now ignores
case and returns its matches in AS number order. asinfo lowers each
handle and description once, when the list loads.

A match can be an AS that the database has no record of. Its AS page
and /api/v1/as/{asn} then take its handle and description from asinfo
and still list its prefixes. They answer 404 only for an AS with no
record, no asinfo entry and no prefix. Empty prefix lists in that JSON
answer are [] rather than null.

Model: opus-5-5
2026-10-26 20:58:05 +00:00
8 changed files with 194 additions and 96 deletions

No files matched your search

+17 -15
View File
@@ -64,8 +64,9 @@ Every route answers `HEAD` as it answers `GET`, without the body.
database answers and the RIS Live feed is connected, otherwise 503; database answers and the RIS Live feed is connected, otherwise 503;
`data.status` is `ok` or `error` and `data.checks` says which check failed `data.status` is `ok` or `error` and `data.checks` says which check failed
- `GET /as/{asn}` - AS detail page (HTML): the prefixes the AS originates and - `GET /as/{asn}` - AS detail page (HTML): the prefixes the AS originates and
its peers. An AS that only the AS list built into RouteWatch knows gets a page its peers. When the database has no record of the AS, its handle and
with its handle and description and no prefixes. description come from the AS list built into RouteWatch. The page then shows
no first-seen date.
- `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as - `GET /prefix/{prefix}/{length}` - Prefix detail page (HTML), such as
`/prefix/192.0.2.0/24`: every live route for the prefix, one per peer, and its `/prefix/192.0.2.0/24`: every live route for the prefix, one per peer, and its
origin ASes origin ASes
@@ -94,9 +95,9 @@ Every route answers `HEAD` as it answers `GET`, without the body.
and 404 when none of the addresses has a live route. A PTR lookup gets 2 and 404 when none of the addresses has a live route. A PTR lookup gets 2
seconds; an address whose lookup takes longer is answered without PTR names. seconds; an address whose lookup takes longer is answered without PTR names.
- `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6 - `GET /api/v1/as/{asn}` - The AS and the prefixes it originates, IPv4 and IPv6
apart, each with when its newest route was updated. An AS that only the AS apart, each with when its newest route was updated. When the database has no
list built into RouteWatch knows has its handle and description and no record of the AS, its handle and description come from the AS list built into
prefixes. Its answer has no `first_seen` or `last_seen`. RouteWatch. The answer then has no `first_seen` or `last_seen`.
- `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one - `GET /api/v1/prefix/{prefix}/{length}` - Every live route for the prefix, one
per peer, and the same routes grouped by origin AS in `origins` per peer, and the same routes grouped by origin AS in `origins`
- `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description - `GET /api/v1/search/as?q={text}` - Finds the ASes whose handle or description
@@ -108,8 +109,8 @@ Every route answers `HEAD` as it answers `GET`, without the body.
The search reads the AS list built into RouteWatch, not the database. The The search reads the AS list built into RouteWatch, not the database. The
list comes from [ipverse/asn-info](https://github.com/ipverse/asn-info). A list comes from [ipverse/asn-info](https://github.com/ipverse/asn-info). A
match can therefore be an AS that the database does not hold. A `q` that is match can therefore be an AS that the database has no record of. A `q` that
missing, empty or only spaces answers 400. is missing, empty or only spaces answers 400.
A JSON answer is `{"status": "ok", "data": ...}`, or for an error A JSON answer is `{"status": "ok", "data": ...}`, or for an error
`{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an `{"status": "error", "error": {"msg": ..., "code": ...}}`, and carries an
@@ -120,14 +121,15 @@ took. The health check is the exception: its 503 answer is also
A request that runs past its time limit answers 504: the limit is 30 seconds, A request that runs past its time limit answers 504: the limit is 30 seconds,
and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`. and 4 seconds for the database reads behind `/status.json` and `/api/v1/stats`.
An AS number, prefix or mask length that does not parse answers 400, as does a An AS number, prefix or mask length that does not parse answers 400, as does a
mask length out of range for its IP version. An AS number that is in neither the mask length out of range for its IP version. An AS answers 404 when it is not in
database nor the AS list built into RouteWatch answers 404, as does a prefix the AS list built into RouteWatch and the database has neither a record of it
with no live route. The HTML pages give these errors as plain text. Under nor a live route it originates. A prefix with no live route answers 404 too. The
`/api/v1` and `/ip/`, a path no route matches answers 404 with the message HTML pages give these errors as plain text. Under `/api/v1` and `/ip/`, a path
`Not found`, and `POST`, `PUT`, `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or no route matches answers 404 with the message `Not found`, and `POST`, `PUT`,
`CONNECT` answers 405 with `Method not allowed`. A 500 answer says only `PATCH`, `DELETE`, `OPTIONS`, `TRACE` or `CONNECT` answers 405 with
`Internal server error`, and a host name that does not resolve only `Method not allowed`. A 500 answer says only `Internal server error`, and a host
`could not resolve host name`; the detail goes to the log. name that does not resolve only `could not resolve host name`; the detail goes
to the log.
Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`, Every answer carries `Strict-Transport-Security`, `Content-Security-Policy`,
`X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy` and
+3 -3
View File
@@ -35,9 +35,9 @@ tested.
- 2026-10-26: the home page's AS name search lists the ASes whose handle or - 2026-10-26: the home page's AS name search lists the ASes whose handle or
description holds the text, as links to their pages, from the new description holds the text, as links to their pages, from the new
`/api/v1/search/as`; the search ignores case. An AS page answers for an AS `/api/v1/search/as`; the search ignores case. When the database has no record
that only the AS list built into RouteWatch knows, with no prefixes (closes of an AS, its page takes the handle and description from the AS list built
#151) into RouteWatch and still lists the AS's prefixes (closes #151)
- 2026-10-19: README "HTTP Endpoints" lists every route with what it answers, - 2026-10-19: README "HTTP Endpoints" lists every route with what it answers,
"Database Schema" matches `schema.sql`, and the rest of the README matches the "Database Schema" matches `schema.sql`, and the rest of the README matches the
code; this file brought up to date (closes #72) code; this file brought up to date (closes #72)
+27 -16
View File
@@ -1749,9 +1749,10 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
// ones first, each once with the time of its newest route. It makes two index // ones first, each once with the time of its newest route. It makes two index
// searches per prefix, however many peers carry the prefix. // searches per prefix, however many peers carry the prefix.
// //
// An AS that only the AS info lookup knows comes with its handle and // The routes of an AS can be stored before its record in the asns table. When
// description, and without prefixes or seen times. An AS that neither the // the AS has no record, its handle and description come from the AS info
// database nor the lookup knows is ErrNoRoute. // lookup, and it has no seen times. An AS with no record, no lookup entry and
// no prefix is ErrNoRoute.
func (d *Database) GetASDetailsContext( func (d *Database) GetASDetailsContext(
ctx context.Context, asn int, ctx context.Context, asn int,
) (*ASN, []LiveRoute, error) { ) (*ASN, []LiveRoute, error) {
@@ -1767,24 +1768,28 @@ func (d *Database) GetASDetailsContext(
asn, asn,
).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen, ).Scan(&asnInfo.ASN, &handle, &description, &asnInfo.FirstSeen,
&asnInfo.LastSeen) &asnInfo.LastSeen)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
info, ok := asinfo.Get(asn)
if !ok {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
return &ASN{ // known is whether the asns table or the AS info lookup holds the AS.
ASN: asn, Handle: info.Handle, Description: info.Description, known := true
}, nil, nil
switch {
case errors.Is(err, sql.ErrNoRows):
asnInfo.ASN = asn
info, ok := asinfo.Get(asn)
if ok {
asnInfo.Handle = info.Handle
asnInfo.Description = info.Description
} }
known = ok
case err != nil:
return nil, nil, fmt.Errorf("failed to query AS: %w", err) return nil, nil, fmt.Errorf("failed to query AS: %w", err)
default:
asnInfo.Handle = handle.String
asnInfo.Description = description.String
} }
asnInfo.Handle = handle.String
asnInfo.Description = description.String
allPrefixes, err := d.originPrefixes(ctx, asn, ipVersionV4) allPrefixes, err := d.originPrefixes(ctx, asn, ipVersionV4)
if err != nil { if err != nil {
return &asnInfo, nil, err return &asnInfo, nil, err
@@ -1795,7 +1800,13 @@ func (d *Database) GetASDetailsContext(
return &asnInfo, allPrefixes, err return &asnInfo, allPrefixes, err
} }
return &asnInfo, append(allPrefixes, prefixesV6...), nil allPrefixes = append(allPrefixes, prefixesV6...)
if !known && len(allPrefixes) == 0 {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
return &asnInfo, allPrefixes, nil
} }
// ASPeer represents a peering relationship with another AS including handle, // ASPeer represents a peering relationship with another AS including handle,
+57 -17
View File
@@ -1700,9 +1700,12 @@ func TestGetASDetailsListsEachPrefixOnce(t *testing.T) {
} }
} }
// TestGetASDetailsOfASNotInDatabase asks an empty database for an AS that the // TestGetASDetailsOfASNotInDatabase asks for ASes that the database has no
// AS info lookup knows, and gets its handle and description with no prefixes // record of, some with stored routes. Each gets the prefixes of its routes and
// and no seen times. An AS that the lookup does not know is ErrNoRoute. // no seen times. One that the AS info lookup knows gets its handle and
// description from it. An AS with no record, no lookup entry and no route is
// ErrNoRoute. Before the fix an AS with no record got no prefixes, although
// its routes were stored.
func TestGetASDetailsOfASNotInDatabase(t *testing.T) { func TestGetASDetailsOfASNotInDatabase(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1712,31 +1715,68 @@ func TestGetASDetailsOfASNotInDatabase(t *testing.T) {
} }
defer func() { _ = db.Close() }() defer func() { _ = db.Close() }()
const knownASN, unknownASN = 15169, 4200000000 ts := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
info, ok := asinfo.Get(knownASN) tests := []struct {
if !ok { asn int
t.Fatalf("AS%d is not in the AS info data", knownASN) listed bool // in the AS info lookup
prefixes []string // of the routes stored for the AS
}{
{15169, true, []string{"192.0.2.0/24"}},
{13335, true, nil},
{4200000000, false, []string{"198.51.100.0/24"}},
} }
got, prefixes, err := db.GetASDetails(knownASN) var routes []*LiveRoute
for _, tt := range tests {
if _, ok := asinfo.Get(tt.asn); ok != tt.listed {
t.Fatalf("AS%d in the AS info data: %t, want %t", tt.asn, ok, tt.listed)
}
for _, prefix := range tt.prefixes {
routes = append(routes, mkV4Route(t, prefix, tt.asn, ts))
}
}
err = db.UpsertLiveRouteBatch(routes)
if err != nil { if err != nil {
t.Fatalf("GetASDetails(%d): %v", knownASN, err) t.Fatalf("UpsertLiveRouteBatch: %v", err)
} }
want := ASN{ASN: knownASN, Handle: info.Handle, Description: info.Description} for _, tt := range tests {
if *got != want || len(prefixes) != 0 { got, prefixes, err := db.GetASDetails(tt.asn)
t.Errorf("GetASDetails(%d) = %+v with prefixes %+v, want %+v and none", if err != nil {
knownASN, *got, prefixes, want) t.Errorf("GetASDetails(%d): %v", tt.asn, err)
continue
}
want := ASN{ASN: tt.asn}
if info, ok := asinfo.Get(tt.asn); ok {
want.Handle, want.Description = info.Handle, info.Description
}
var gotPrefixes []string
for _, route := range prefixes {
gotPrefixes = append(gotPrefixes, route.Prefix)
}
if *got != want || !slices.Equal(gotPrefixes, tt.prefixes) {
t.Errorf("GetASDetails(%d) = %+v with prefixes %v, want %+v with %v",
tt.asn, *got, gotPrefixes, want, tt.prefixes)
}
} }
if _, ok := asinfo.Get(unknownASN); ok { const absentASN = 4200000001 // no record, no lookup entry and no route
t.Fatalf("AS%d is in the AS info data", unknownASN)
if _, ok := asinfo.Get(absentASN); ok {
t.Fatalf("AS%d is in the AS info data", absentASN)
} }
_, _, err = db.GetASDetails(unknownASN) _, _, err = db.GetASDetails(absentASN)
if !errors.Is(err, ErrNoRoute) { if !errors.Is(err, ErrNoRoute) {
t.Errorf("GetASDetails(%d): error %v, want %v", unknownASN, err, ErrNoRoute) t.Errorf("GetASDetails(%d): error %v, want %v", absentASN, err, ErrNoRoute)
} }
} }
+2 -2
View File
@@ -28,8 +28,8 @@ type ASN struct {
RIRRegDate *time.Time `json:"rir_registration_date,omitempty"` RIRRegDate *time.Time `json:"rir_registration_date,omitempty"`
RIRLastMod *time.Time `json:"rir_last_modified,omitempty"` RIRLastMod *time.Time `json:"rir_last_modified,omitempty"`
WHOISRaw string `json:"whois_raw,omitempty"` WHOISRaw string `json:"whois_raw,omitempty"`
// Timestamps. They are zero, and left out of JSON, for an AS that the // Timestamps. They are zero, and left out of JSON, for an AS that has no
// database does not hold. // record in the asns table.
FirstSeen time.Time `json:"first_seen,omitzero"` FirstSeen time.Time `json:"first_seen,omitzero"`
LastSeen time.Time `json:"last_seen,omitzero"` LastSeen time.Time `json:"last_seen,omitzero"`
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"` WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
+59 -34
View File
@@ -26,6 +26,7 @@ import (
// The prefix and peer addresses the prefix page tests store routes for. // The prefix and peer addresses the prefix page tests store routes for.
const ( const (
testPrefixV4 = "192.0.2.0/24" testPrefixV4 = "192.0.2.0/24"
testPrefixV6 = "2001:db8::/32"
testPeerV4 = "198.51.100.1" testPeerV4 = "198.51.100.1"
testPeerV6 = "2001:db8::1" testPeerV6 = "2001:db8::1"
) )
@@ -194,7 +195,7 @@ func TestStatsHandlersAnswerFromMemory(t *testing.T) {
} }
// newServerWithTestRoutes returns a server on a new database that holds a // newServerWithTestRoutes returns a server on a new database that holds a
// route for testPrefixV4 and one for 2001:db8::/32. // route for testPrefixV4 and one for testPrefixV6.
func newServerWithTestRoutes(t *testing.T) *Server { func newServerWithTestRoutes(t *testing.T) *Server {
t.Helper() t.Helper()
@@ -214,7 +215,7 @@ func newServerWithTestRoutes(t *testing.T) *Server {
NextHop: testPeerV4, LastUpdated: ts, NextHop: testPeerV4, LastUpdated: ts,
}, },
{ {
Prefix: "2001:db8::/32", MaskLength: 32, IPVersion: 6, Prefix: testPrefixV6, MaskLength: 32, IPVersion: 6,
OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501}, OriginASN: 64501, PeerIP: testPeerV6, ASPath: []int{64501},
NextHop: testPeerV6, LastUpdated: ts, NextHop: testPeerV6, LastUpdated: ts,
}, },
@@ -1286,10 +1287,13 @@ func TestASSearchShowsFirstMatchesAndTotal(t *testing.T) {
} }
// The AS numbers the AS detail tests ask for. storedASN originates the route // The AS numbers the AS detail tests ask for. storedASN originates the route
// for testPrefixV4 and is in the database. builtInOnlyASN is in the AS list // for testPrefixV4 and is in the database. routeOnlyASN originates the route
// built into asinfo but not in the database. unknownASN is in neither. // for testPrefixV6, but the database has no record of the AS itself, as when
// a route is stored before its AS. builtInOnlyASN is in the AS list built into
// asinfo but not in the database. unknownASN is in neither.
const ( const (
storedASN = 64500 storedASN = 64500
routeOnlyASN = 64501
builtInOnlyASN = 15169 builtInOnlyASN = 15169
unknownASN = 4200000000 unknownASN = 4200000000
) )
@@ -1297,14 +1301,19 @@ const (
// zeroDate is how a page writes a time that was never set. // zeroDate is how a page writes a time that was never set.
const zeroDate = "0001-01-01" const zeroDate = "0001-01-01"
// firstSeenCard is the label of the AS page's first-seen date.
const firstSeenCard = `<div class="info-label">First Seen</div>`
// newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN // newServerWithStoredAS returns newServerWithTestRoutes's server with storedASN
// added to the database. It fails the test when the AS list built into asinfo // added to the database. It fails the test when the AS list built into asinfo
// lacks builtInOnlyASN or holds unknownASN. // lacks routeOnlyASN or builtInOnlyASN, or holds unknownASN.
func newServerWithStoredAS(t *testing.T) *Server { func newServerWithStoredAS(t *testing.T) *Server {
t.Helper() t.Helper()
if _, ok := asinfo.Get(builtInOnlyASN); !ok { for _, asn := range []int{routeOnlyASN, builtInOnlyASN} {
t.Fatalf("AS%d is not in the AS info data", builtInOnlyASN) if _, ok := asinfo.Get(asn); !ok {
t.Fatalf("AS%d is not in the AS info data", asn)
}
} }
if _, ok := asinfo.Get(unknownASN); ok { if _, ok := asinfo.Get(unknownASN); ok {
@@ -1321,13 +1330,13 @@ func newServerWithStoredAS(t *testing.T) *Server {
return s return s
} }
// TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for an AS in the database, // TestASDetailJSONAnswersBuiltInAS asks /api/v1/as/ for each of the ASes above.
// one only the built-in AS list knows, and one in neither. The first two answer // All but unknownASN answer 200 with the handle and description from the
// 200 with the handle and description from that list and their prefixes, as // built-in AS list and their prefixes, as lists even when empty. Only storedASN
// lists even when empty. Only the AS in the database has a first_seen time. The // has a first_seen time. unknownASN answers 404. Before the fix builtInOnlyASN
// third answers 404. Before the fix the second answered 404, so a result of the // answered 404, so a result of the AS name search could lead nowhere
// AS name search could lead nowhere // (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed
// (https://git.eeqj.de/sneak/routewatch/issues/151). // no prefixes.
func TestASDetailJSONAnswersBuiltInAS(t *testing.T) { func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1336,12 +1345,13 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
tests := []struct { tests := []struct {
asn int asn int
wantCode int wantCode int
wantPrefixes int wantPrefixes []string
wantFirstSeen bool wantFirstSeen bool
}{ }{
{storedASN, http.StatusOK, 1, true}, {storedASN, http.StatusOK, []string{testPrefixV4}, true},
{builtInOnlyASN, http.StatusOK, 0, false}, {routeOnlyASN, http.StatusOK, []string{testPrefixV6}, false},
{unknownASN, http.StatusNotFound, 0, false}, {builtInOnlyASN, http.StatusOK, nil, false},
{unknownASN, http.StatusNotFound, nil, false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1392,9 +1402,15 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
t.Errorf("%s: a prefix list is null; body %s", path, rec.Body) t.Errorf("%s: a prefix list is null; body %s", path, rec.Body)
} }
gotPrefixes := len(body.Data.IPv4Prefixes) + len(body.Data.IPv6Prefixes) routes := slices.Concat(body.Data.IPv4Prefixes, body.Data.IPv6Prefixes)
if gotPrefixes != tt.wantPrefixes {
t.Errorf("%s: %d prefixes, want %d", path, gotPrefixes, tt.wantPrefixes) gotPrefixes := make([]string, 0, len(routes))
for _, route := range routes {
gotPrefixes = append(gotPrefixes, route.Prefix)
}
if !slices.Equal(gotPrefixes, tt.wantPrefixes) {
t.Errorf("%s: prefixes %v, want %v", path, gotPrefixes, tt.wantPrefixes)
} }
gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`) gotFirstSeen := strings.Contains(rec.Body.String(), `"first_seen"`)
@@ -1405,26 +1421,29 @@ func TestASDetailJSONAnswersBuiltInAS(t *testing.T) {
} }
} }
// TestASDetailPageShowsBuiltInAS asks /as/ for an AS in the database, one only // TestASDetailPageShowsBuiltInAS asks /as/ for each of the ASes above. All but
// the built-in AS list knows, and one in neither. The first two answer 200 with // unknownASN answer 200 with the handle and description from the built-in AS
// the handle and description from that list. The first lists its prefix, and // list. storedASN and routeOnlyASN list their prefixes, and builtInOnlyASN says
// the second says the AS announces none. Neither shows a date that was never // the AS announces none. Only storedASN shows a first-seen date, and no page
// set. The third answers 404. Before the fix the second answered 404, so a // shows a date that was never set. unknownASN answers 404. Before the fix
// result of the AS name search led nowhere // builtInOnlyASN answered 404, so a result of the AS name search led nowhere
// (https://git.eeqj.de/sneak/routewatch/issues/151). // (https://git.eeqj.de/sneak/routewatch/issues/151), and routeOnlyASN listed no
// prefixes.
func TestASDetailPageShowsBuiltInAS(t *testing.T) { func TestASDetailPageShowsBuiltInAS(t *testing.T) {
t.Parallel() t.Parallel()
s := newServerWithStoredAS(t) s := newServerWithStoredAS(t)
tests := []struct { tests := []struct {
asn int asn int
wantCode int wantCode int
wantText string wantText string
wantFirstSeen bool
}{ }{
{storedASN, http.StatusOK, testPrefixV4}, {storedASN, http.StatusOK, testPrefixV4, true},
{builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS"}, {routeOnlyASN, http.StatusOK, testPrefixV6, false},
{unknownASN, http.StatusNotFound, ""}, {builtInOnlyASN, http.StatusOK, "No prefixes announced by this AS", false},
{unknownASN, http.StatusNotFound, "", false},
} }
for _, tt := range tests { for _, tt := range tests {
@@ -1460,6 +1479,12 @@ func TestASDetailPageShowsBuiltInAS(t *testing.T) {
if strings.Contains(page, zeroDate) { if strings.Contains(page, zeroDate) {
t.Errorf("%s: page shows the date %s", path, zeroDate) t.Errorf("%s: page shows the date %s", path, zeroDate)
} }
gotFirstSeen := strings.Contains(page, firstSeenCard)
if gotFirstSeen != tt.wantFirstSeen {
t.Errorf("%s: shows a first-seen date %t, want %t",
path, gotFirstSeen, tt.wantFirstSeen)
}
} }
} }
+3 -1
View File
@@ -233,10 +233,12 @@
<div class="info-label">Peer ASNs</div> <div class="info-label">Peer ASNs</div>
<div class="info-value">{{.PeerCount}}</div> <div class="info-value">{{.PeerCount}}</div>
</div> </div>
{{if not .ASN.FirstSeen.IsZero}}
<div class="info-card"> <div class="info-card">
<div class="info-label">First Seen</div> <div class="info-label">First Seen</div>
<div class="info-value">{{if .ASN.FirstSeen.IsZero}}Never{{else}}{{.ASN.FirstSeen.Format "2006-01-02"}}{{end}}</div> <div class="info-value">{{.ASN.FirstSeen.Format "2006-01-02"}}</div>
</div> </div>
{{end}}
</div> </div>
{{if .IPv4Prefixes}} {{if .IPv4Prefixes}}
+26 -8
View File
@@ -26,6 +26,16 @@ type Info struct {
type Registry struct { type Registry struct {
mu sync.RWMutex mu sync.RWMutex
byASN map[int]*Info byASN map[int]*Info
// searchEntries holds every entry in AS number order, for Search.
searchEntries []searchEntry
}
// searchEntry is an entry with its handle and description in lower case. They
// are lowered once, when the data loads, so that Search only compares.
type searchEntry struct {
info *Info
lowerHandle string
lowerDescription string
} }
var ( var (
@@ -62,9 +72,21 @@ func initRegistry() {
panic("failed to unmarshal embedded AS data: " + err.Error()) panic("failed to unmarshal embedded AS data: " + err.Error())
} }
slices.SortFunc(asInfos, func(a, b Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
defaultRegistry.searchEntries = make([]searchEntry, 0, len(asInfos))
for i := range asInfos { for i := range asInfos {
info := &asInfos[i] info := &asInfos[i]
defaultRegistry.byASN[info.ASN] = info defaultRegistry.byASN[info.ASN] = info
defaultRegistry.searchEntries = append(defaultRegistry.searchEntries,
searchEntry{
info: info,
lowerHandle: strings.ToLower(info.Handle),
lowerDescription: strings.ToLower(info.Description),
})
} }
} }
@@ -148,16 +170,12 @@ func Search(query string) []Info {
var results []Info var results []Info
for _, info := range defaultRegistry.byASN { for _, entry := range defaultRegistry.searchEntries {
if strings.Contains(strings.ToLower(info.Handle), query) || if strings.Contains(entry.lowerHandle, query) ||
strings.Contains(strings.ToLower(info.Description), query) { strings.Contains(entry.lowerDescription, query) {
results = append(results, *info) results = append(results, *entry.info)
} }
} }
slices.SortFunc(results, func(a, b Info) int {
return cmp.Compare(a.ASN, b.ASN)
})
return results return results
} }