From aeeb5e7d7d641203791d22ef11b90d4847be4a03 Mon Sep 17 00:00:00 2001 From: sneak Date: Mon, 28 Jul 2025 04:26:20 +0200 Subject: [PATCH] Implement AS and prefix detail pages - Implement handleASDetail() and handlePrefixDetail() HTML handlers - Create AS detail HTML template with prefix listings - Create prefix detail HTML template with route information - Add timeSince template function for human-readable durations - Update templates.go to include new templates - Server-side rendered pages as requested (no client-side API calls) --- internal/server/handlers.go | 157 +++++++++++++++- internal/templates/as_detail.html | 228 +++++++++++++++++++++++ internal/templates/prefix_detail.html | 253 ++++++++++++++++++++++++++ internal/templates/templates.go | 76 +++++++- 4 files changed, 707 insertions(+), 7 deletions(-) create mode 100644 internal/templates/as_detail.html create mode 100644 internal/templates/prefix_detail.html diff --git a/internal/server/handlers.go b/internal/server/handlers.go index d34131d..ab5da75 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "net" "net/http" "runtime" "strconv" @@ -458,16 +459,160 @@ func (s *Server) handlePrefixDetailJSON() http.HandlerFunc { // handleASDetail returns a handler that serves the AS detail HTML page func (s *Server) handleASDetail() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - // TODO: Implement AS detail HTML page - http.Error(w, "Not implemented", http.StatusNotImplemented) + return func(w http.ResponseWriter, r *http.Request) { + asnStr := chi.URLParam(r, "asn") + asn, err := strconv.Atoi(asnStr) + if err != nil { + http.Error(w, "Invalid ASN", http.StatusBadRequest) + + return + } + + asInfo, prefixes, err := s.db.GetASDetails(asn) + if err != nil { + if errors.Is(err, database.ErrNoRoute) { + http.Error(w, "AS not found", http.StatusNotFound) + } else { + s.logger.Error("Failed to get AS details", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + + return + } + + // Group prefixes by IP version + const ipVersionV4 = 4 + var ipv4Prefixes, ipv6Prefixes []database.LiveRoute + for _, p := range prefixes { + if p.IPVersion == ipVersionV4 { + ipv4Prefixes = append(ipv4Prefixes, p) + } else { + ipv6Prefixes = append(ipv6Prefixes, p) + } + } + + // Prepare template data + data := struct { + ASN *database.ASN + IPv4Prefixes []database.LiveRoute + IPv6Prefixes []database.LiveRoute + TotalCount int + IPv4Count int + IPv6Count int + }{ + ASN: asInfo, + IPv4Prefixes: ipv4Prefixes, + IPv6Prefixes: ipv6Prefixes, + TotalCount: len(prefixes), + IPv4Count: len(ipv4Prefixes), + IPv6Count: len(ipv6Prefixes), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := templates.ASDetailTemplate() + if err := tmpl.Execute(w, data); err != nil { + s.logger.Error("Failed to render AS detail template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } } } // handlePrefixDetail returns a handler that serves the prefix detail HTML page func (s *Server) handlePrefixDetail() http.HandlerFunc { - return func(w http.ResponseWriter, _ *http.Request) { - // TODO: Implement prefix detail HTML page - http.Error(w, "Not implemented", http.StatusNotImplemented) + return func(w http.ResponseWriter, r *http.Request) { + prefix := chi.URLParam(r, "prefix") + if prefix == "" { + http.Error(w, "Prefix parameter is required", http.StatusBadRequest) + + return + } + + routes, err := s.db.GetPrefixDetails(prefix) + if err != nil { + if errors.Is(err, database.ErrNoRoute) { + http.Error(w, "Prefix not found", http.StatusNotFound) + } else { + s.logger.Error("Failed to get prefix details", "error", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } + + return + } + + // Group by origin AS and collect unique AS info + type ASNInfo struct { + Number int + Handle string + Description string + PeerCount int + } + originMap := make(map[int]*ASNInfo) + for _, route := range routes { + if _, exists := originMap[route.OriginASN]; !exists { + // Get AS info from database + asInfo, _, _ := s.db.GetASDetails(route.OriginASN) + handle := "" + description := "" + if asInfo != nil { + handle = asInfo.Handle + description = asInfo.Description + } + originMap[route.OriginASN] = &ASNInfo{ + Number: route.OriginASN, + Handle: handle, + Description: description, + PeerCount: 0, + } + } + originMap[route.OriginASN].PeerCount++ + } + + // Get the first route to extract some common info + var maskLength, ipVersion int + if len(routes) > 0 { + // Parse CIDR to get mask length and IP version + _, ipNet, err := net.ParseCIDR(prefix) + if err == nil { + ones, _ := ipNet.Mask.Size() + maskLength = ones + if ipNet.IP.To4() != nil { + ipVersion = 4 + } else { + ipVersion = 6 + } + } + } + + // Convert origin map to sorted slice + var origins []*ASNInfo + for _, origin := range originMap { + origins = append(origins, origin) + } + + // Prepare template data + data := struct { + Prefix string + MaskLength int + IPVersion int + Routes []database.LiveRoute + Origins []*ASNInfo + PeerCount int + OriginCount int + }{ + Prefix: prefix, + MaskLength: maskLength, + IPVersion: ipVersion, + Routes: routes, + Origins: origins, + PeerCount: len(routes), + OriginCount: len(originMap), + } + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + tmpl := templates.PrefixDetailTemplate() + if err := tmpl.Execute(w, data); err != nil { + s.logger.Error("Failed to render prefix detail template", "error", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + } } } diff --git a/internal/templates/as_detail.html b/internal/templates/as_detail.html new file mode 100644 index 0000000..60ce777 --- /dev/null +++ b/internal/templates/as_detail.html @@ -0,0 +1,228 @@ + + + + + + AS{{.ASN.Number}} - {{.ASN.Handle}} - RouteWatch + + + +
+ ← Back to Status + +

AS{{.ASN.Number}}{{if .ASN.Handle}} - {{.ASN.Handle}}{{end}}

+ {{if .ASN.Description}} +

{{.ASN.Description}}

+ {{end}} + +
+
+
Total Prefixes
+
{{.TotalCount}}
+
+
+
IPv4 Prefixes
+
{{.IPv4Count}}
+
+
+
IPv6 Prefixes
+
{{.IPv6Count}}
+
+
+
First Seen
+
{{.ASN.FirstSeen.Format "2006-01-02"}}
+
+
+ + {{if .IPv4Prefixes}} +
+
+

IPv4 Prefixes

+ {{.IPv4Count}} +
+ + + + + + + + + + + {{range .IPv4Prefixes}} + + + + + + + {{end}} + +
PrefixMask LengthLast UpdatedAge
{{.Prefix}}/{{.MaskLength}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{.LastUpdated | timeSince}}
+
+ {{end}} + + {{if .IPv6Prefixes}} +
+
+

IPv6 Prefixes

+ {{.IPv6Count}} +
+ + + + + + + + + + + {{range .IPv6Prefixes}} + + + + + + + {{end}} + +
PrefixMask LengthLast UpdatedAge
{{.Prefix}}/{{.MaskLength}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{.LastUpdated | timeSince}}
+
+ {{end}} + + {{if eq .TotalCount 0}} +
+

No prefixes announced by this AS

+
+ {{end}} +
+ + \ No newline at end of file diff --git a/internal/templates/prefix_detail.html b/internal/templates/prefix_detail.html new file mode 100644 index 0000000..cf58f71 --- /dev/null +++ b/internal/templates/prefix_detail.html @@ -0,0 +1,253 @@ + + + + + + {{.Prefix}} - RouteWatch + + + +
+ ← Back to Status + +

{{.Prefix}}

+

IPv{{.IPVersion}} Prefix{{if .MaskLength}} • /{{.MaskLength}}{{end}}

+ +
+
+
Seen from Peers
+
{{.PeerCount}}
+
+
+
Origin ASNs
+
{{.OriginCount}}
+
+
+
IP Version
+
IPv{{.IPVersion}}
+
+
+ + {{if .Origins}} +
+

Origin ASNs

+
+ {{range .Origins}} +
+ AS{{.Number}} + {{if .Handle}} ({{.Handle}}){{end}} + {{.PeerCount}} peer{{if ne .PeerCount 1}}s{{end}} +
+ {{end}} +
+
+ {{end}} + + {{if .Routes}} +
+
+

Route Details

+ {{.PeerCount}} route{{if ne .PeerCount 1}}s{{end}} +
+ + + + + + + + + + + + + {{range .Routes}} + + + + + + + + + {{end}} + +
Origin ASPeer IPAS PathNext HopLast UpdatedAge
+ AS{{.OriginASN}} + {{.PeerIP}}{{range $i, $as := .ASPath}}{{if $i}} → {{end}}{{$as}}{{end}}{{.NextHop}}{{.LastUpdated.Format "2006-01-02 15:04:05"}}{{.LastUpdated | timeSince}}
+
+ {{else}} +
+

No routes found for this prefix

+
+ {{end}} +
+ + \ No newline at end of file diff --git a/internal/templates/templates.go b/internal/templates/templates.go index c5955a7..8eddaaf 100644 --- a/internal/templates/templates.go +++ b/internal/templates/templates.go @@ -5,14 +5,23 @@ import ( _ "embed" "html/template" "sync" + "time" ) //go:embed status.html var statusHTML string +//go:embed as_detail.html +var asDetailHTML string + +//go:embed prefix_detail.html +var prefixDetailHTML string + // Templates contains all parsed templates type Templates struct { - Status *template.Template + Status *template.Template + ASDetail *template.Template + PrefixDetail *template.Template } var ( @@ -22,17 +31,72 @@ var ( once sync.Once ) +const ( + hoursPerDay = 24 + daysPerMonth = 30 +) + +// 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") +} + // initTemplates parses all embedded templates func initTemplates() { var err error defaultTemplates = &Templates{} + // Create common template functions + funcs := template.FuncMap{ + "timeSince": timeSince, + } + // Parse status template defaultTemplates.Status, err = template.New("status").Parse(statusHTML) if err != nil { panic("failed to parse status template: " + err.Error()) } + + // Parse AS detail template + defaultTemplates.ASDetail, err = template.New("asDetail").Funcs(funcs).Parse(asDetailHTML) + if err != nil { + panic("failed to parse AS detail template: " + err.Error()) + } + + // Parse prefix detail template + defaultTemplates.PrefixDetail, err = template.New("prefixDetail").Funcs(funcs).Parse(prefixDetailHTML) + if err != nil { + panic("failed to parse prefix detail template: " + err.Error()) + } } // Get returns the singleton Templates instance @@ -46,3 +110,13 @@ func Get() *Templates { func StatusTemplate() *template.Template { return Get().Status } + +// ASDetailTemplate returns the parsed AS detail template +func ASDetailTemplate() *template.Template { + return Get().ASDetail +} + +// PrefixDetailTemplate returns the parsed prefix detail template +func PrefixDetailTemplate() *template.Template { + return Get().PrefixDetail +}