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.

A match can be an AS that the database does not hold yet. Its AS page
and /api/v1/as/{asn} now answer with its handle and description from
asinfo and no prefixes, instead of 404. Empty prefix lists in that JSON
answer are [] rather than null.

Model: opus-5-5
This commit is contained in:
2026-10-26 20:39:30 +00:00
parent 71ee2aeda7
commit ca68b8f605
15 changed files with 622 additions and 67 deletions
+12 -1
View File
@@ -1748,6 +1748,10 @@ func (d *Database) GetASDetails(asn int) (*ASN, []LiveRoute, error) {
// GetASDetailsContext returns the AS and the prefixes it originates, the IPv4
// ones first, each once with the time of its newest route. It makes two index
// searches per prefix, however many peers carry the prefix.
//
// An AS that only the AS info lookup knows comes with its handle and
// description, and without prefixes or seen times. An AS that neither the
// database nor the lookup knows is ErrNoRoute.
func (d *Database) GetASDetailsContext(
ctx context.Context, asn int,
) (*ASN, []LiveRoute, error) {
@@ -1765,7 +1769,14 @@ func (d *Database) GetASDetailsContext(
&asnInfo.LastSeen)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
info, ok := asinfo.Get(asn)
if !ok {
return nil, nil, fmt.Errorf("%w: AS%d", ErrNoRoute, asn)
}
return &ASN{
ASN: asn, Handle: info.Handle, Description: info.Description,
}, nil, nil
}
return nil, nil, fmt.Errorf("failed to query AS: %w", err)
@@ -20,6 +20,7 @@ import (
"git.eeqj.de/sneak/routewatch/internal/config"
"git.eeqj.de/sneak/routewatch/internal/logger"
"git.eeqj.de/sneak/routewatch/pkg/asinfo"
"github.com/mattn/go-sqlite3"
)
@@ -1699,6 +1700,46 @@ func TestGetASDetailsListsEachPrefixOnce(t *testing.T) {
}
}
// TestGetASDetailsOfASNotInDatabase asks an empty database for an AS that the
// AS info lookup knows, and gets its handle and description with no prefixes
// and no seen times. An AS that the lookup does not know is ErrNoRoute.
func TestGetASDetailsOfASNotInDatabase(t *testing.T) {
t.Parallel()
db, err := New(&config.Config{StateDir: t.TempDir()}, logger.New())
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
defer func() { _ = db.Close() }()
const knownASN, unknownASN = 15169, 4200000000
info, ok := asinfo.Get(knownASN)
if !ok {
t.Fatalf("AS%d is not in the AS info data", knownASN)
}
got, prefixes, err := db.GetASDetails(knownASN)
if err != nil {
t.Fatalf("GetASDetails(%d): %v", knownASN, err)
}
want := ASN{ASN: knownASN, Handle: info.Handle, Description: info.Description}
if *got != want || len(prefixes) != 0 {
t.Errorf("GetASDetails(%d) = %+v with prefixes %+v, want %+v and none",
knownASN, *got, prefixes, want)
}
if _, ok := asinfo.Get(unknownASN); ok {
t.Fatalf("AS%d is in the AS info data", unknownASN)
}
_, _, err = db.GetASDetails(unknownASN)
if !errors.Is(err, ErrNoRoute) {
t.Errorf("GetASDetails(%d): error %v, want %v", unknownASN, err, ErrNoRoute)
}
}
// TestASPrefixQueriesUseCoveringIndexes checks the plan of each query that
// lists the prefixes an AS originates: a search of a covering index and no
// temporary B-tree, so the list reads one index entry per prefix, not one
+4 -3
View File
@@ -28,9 +28,10 @@ type ASN struct {
RIRRegDate *time.Time `json:"rir_registration_date,omitempty"`
RIRLastMod *time.Time `json:"rir_last_modified,omitempty"`
WHOISRaw string `json:"whois_raw,omitempty"`
// Timestamps
FirstSeen time.Time `json:"first_seen"`
LastSeen time.Time `json:"last_seen"`
// Timestamps. They are zero, and left out of JSON, for an AS that the
// database does not hold.
FirstSeen time.Time `json:"first_seen,omitzero"`
LastSeen time.Time `json:"last_seen,omitzero"`
WHOISUpdatedAt *time.Time `json:"whois_updated_at,omitempty"`
}