Remove BGP keepalive logging and add peer tracking
- Created bgp_peers table to track all BGP peers - Added PeerHandler to update peer last seen times for all message types - Removed verbose BGP keepalive debug logging - BGP keepalive messages now silently update peer tracking Refactor HTML templates to use go:embed - Created internal/templates package with embedded templates - Moved status.html from inline const to separate file - Templates are parsed once on startup - Server now uses parsed template instead of raw string Optimize AS data embedding with gzip compression - Changed asinfo package to embed gzipped data (2.4MB vs 12MB) - Updated Makefile to gzip AS data during update - Added decompression during initialization - Raw JSON file excluded from git
This commit is contained in:
parent
ee80311ba1
commit
585ff63fae
5
.gitignore
vendored
5
.gitignore
vendored
@ -28,4 +28,7 @@ go.work.sum
|
|||||||
*.db-wal
|
*.db-wal
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
|
|
||||||
|
# Raw AS data (we only commit the gzipped version)
|
||||||
|
pkg/asinfo/asdata.json
|
4
Makefile
4
Makefile
@ -25,5 +25,5 @@ run: build
|
|||||||
|
|
||||||
asupdate:
|
asupdate:
|
||||||
@echo "Updating AS info data..."
|
@echo "Updating AS info data..."
|
||||||
@go run cmd/asinfo-gen/main.go > pkg/asinfo/asdata.json.tmp && \
|
@go run cmd/asinfo-gen/main.go | gzip > pkg/asinfo/asdata.json.gz.tmp && \
|
||||||
mv pkg/asinfo/asdata.json.tmp pkg/asinfo/asdata.json
|
mv pkg/asinfo/asdata.json.gz.tmp pkg/asinfo/asdata.json.gz
|
||||||
|
@ -24,14 +24,16 @@ type ASInfo struct {
|
|||||||
func main() {
|
func main() {
|
||||||
// Configure logger to write to stderr
|
// Configure logger to write to stderr
|
||||||
log.SetOutput(os.Stderr)
|
log.SetOutput(os.Stderr)
|
||||||
|
|
||||||
// Fetch the CSV data
|
// Fetch the CSV data
|
||||||
log.Println("Fetching AS CSV data...")
|
log.Println("Fetching AS CSV data...")
|
||||||
resp, err := http.Get(asCsvURL)
|
resp, err := http.Get(asCsvURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to fetch CSV: %v", err)
|
log.Fatalf("Failed to fetch CSV: %v", err)
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer func() {
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode)
|
log.Fatalf("Failed to fetch CSV: HTTP %d", resp.StatusCode)
|
||||||
@ -39,13 +41,13 @@ func main() {
|
|||||||
|
|
||||||
// Parse CSV
|
// Parse CSV
|
||||||
reader := csv.NewReader(resp.Body)
|
reader := csv.NewReader(resp.Body)
|
||||||
|
|
||||||
// Read header
|
// Read header
|
||||||
header, err := reader.Read()
|
header, err := reader.Read()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to read CSV header: %v", err)
|
log.Fatalf("Failed to read CSV header: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(header) != 3 || header[0] != "asn" || header[1] != "handle" || header[2] != "description" {
|
if len(header) != 3 || header[0] != "asn" || header[1] != "handle" || header[2] != "description" {
|
||||||
log.Fatalf("Unexpected CSV header: %v", header)
|
log.Fatalf("Unexpected CSV header: %v", header)
|
||||||
}
|
}
|
||||||
@ -59,17 +61,21 @@ func main() {
|
|||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error reading CSV record: %v", err)
|
log.Printf("Error reading CSV record: %v", err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(record) != 3 {
|
const expectedFields = 3
|
||||||
|
if len(record) != expectedFields {
|
||||||
log.Printf("Skipping invalid record: %v", record)
|
log.Printf("Skipping invalid record: %v", record)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
asn, err := strconv.Atoi(record[0])
|
asn, err := strconv.Atoi(record[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Invalid ASN %q: %v", record[0], err)
|
log.Printf("Invalid ASN %q: %v", record[0], err)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,4 +94,4 @@ func main() {
|
|||||||
if err := encoder.Encode(asInfos); err != nil {
|
if err := encoder.Encode(asInfos); err != nil {
|
||||||
log.Fatalf("Failed to encode JSON: %v", err)
|
log.Fatalf("Failed to encode JSON: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -57,6 +57,16 @@ CREATE TABLE IF NOT EXISTS asn_peerings (
|
|||||||
UNIQUE(from_asn_id, to_asn_id)
|
UNIQUE(from_asn_id, to_asn_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- BGP peers that send us messages
|
||||||
|
CREATE TABLE IF NOT EXISTS bgp_peers (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
peer_ip TEXT UNIQUE NOT NULL,
|
||||||
|
peer_asn INTEGER NOT NULL,
|
||||||
|
first_seen DATETIME NOT NULL,
|
||||||
|
last_seen DATETIME NOT NULL,
|
||||||
|
last_message_type TEXT
|
||||||
|
);
|
||||||
|
|
||||||
-- Live routing table: current state of announced routes
|
-- Live routing table: current state of announced routes
|
||||||
CREATE TABLE IF NOT EXISTS live_routes (
|
CREATE TABLE IF NOT EXISTS live_routes (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@ -91,6 +101,10 @@ CREATE INDEX IF NOT EXISTS idx_live_routes_origin
|
|||||||
CREATE INDEX IF NOT EXISTS idx_live_routes_prefix
|
CREATE INDEX IF NOT EXISTS idx_live_routes_prefix
|
||||||
ON live_routes(prefix_id)
|
ON live_routes(prefix_id)
|
||||||
WHERE withdrawn_at IS NULL;
|
WHERE withdrawn_at IS NULL;
|
||||||
|
|
||||||
|
-- Indexes for bgp_peers table
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bgp_peers_asn ON bgp_peers(peer_asn);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_bgp_peers_last_seen ON bgp_peers(last_seen);
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -468,6 +482,52 @@ func (d *Database) GetActiveLiveRoutes() ([]LiveRoute, error) {
|
|||||||
return routes, rows.Err()
|
return routes, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePeer updates or creates a BGP peer record
|
||||||
|
func (d *Database) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
|
||||||
|
tx, err := d.db.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if err := tx.Rollback(); err != nil && err != sql.ErrTxDone {
|
||||||
|
d.logger.Error("Failed to rollback transaction", "error", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var exists bool
|
||||||
|
err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM bgp_peers WHERE peer_ip = ?)", peerIP).Scan(&exists)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"UPDATE bgp_peers SET peer_asn = ?, last_seen = ?, last_message_type = ? WHERE peer_ip = ?",
|
||||||
|
peerASN, timestamp, messageType, peerIP,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
_, err = tx.Exec(
|
||||||
|
"INSERT INTO bgp_peers (id, peer_ip, peer_asn, first_seen, last_seen, last_message_type) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
generateUUID().String(), peerIP, peerASN, timestamp, timestamp, messageType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = tx.Commit(); err != nil {
|
||||||
|
d.logger.Error("Failed to commit transaction for peer update",
|
||||||
|
"peer_ip", peerIP,
|
||||||
|
"peer_asn", peerASN,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetStats returns database statistics
|
// GetStats returns database statistics
|
||||||
func (d *Database) GetStats() (Stats, error) {
|
func (d *Database) GetStats() (Stats, error) {
|
||||||
var stats Stats
|
var stats Stats
|
||||||
|
@ -38,6 +38,9 @@ type Store interface {
|
|||||||
// Statistics
|
// Statistics
|
||||||
GetStats() (Stats, error)
|
GetStats() (Stats, error)
|
||||||
|
|
||||||
|
// Peer operations
|
||||||
|
UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error
|
||||||
|
|
||||||
// Lifecycle
|
// Lifecycle
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
@ -76,6 +76,10 @@ func (rw *RouteWatch) Run(ctx context.Context) error {
|
|||||||
dbHandler := NewDatabaseHandler(rw.db, rw.logger)
|
dbHandler := NewDatabaseHandler(rw.db, rw.logger)
|
||||||
rw.streamer.RegisterHandler(dbHandler)
|
rw.streamer.RegisterHandler(dbHandler)
|
||||||
|
|
||||||
|
// Register peer tracking handler to track all peers
|
||||||
|
peerHandler := NewPeerHandler(rw.db, rw.logger)
|
||||||
|
rw.streamer.RegisterHandler(peerHandler)
|
||||||
|
|
||||||
// Start streaming
|
// Start streaming
|
||||||
if err := rw.streamer.Start(); err != nil {
|
if err := rw.streamer.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
|
@ -158,6 +158,12 @@ func (m *mockStore) GetActiveLiveRoutes() ([]database.LiveRoute, error) {
|
|||||||
return []database.LiveRoute{}, nil
|
return []database.LiveRoute{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdatePeer mock implementation
|
||||||
|
func (m *mockStore) UpdatePeer(peerIP string, peerASN int, messageType string, timestamp time.Time) error {
|
||||||
|
// Simple mock - just return nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Close mock implementation
|
// Close mock implementation
|
||||||
func (m *mockStore) Close() error {
|
func (m *mockStore) Close() error {
|
||||||
return nil
|
return nil
|
||||||
|
49
internal/routewatch/peerhandler.go
Normal file
49
internal/routewatch/peerhandler.go
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
package routewatch
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/ristypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PeerHandler tracks BGP peers from all message types
|
||||||
|
type PeerHandler struct {
|
||||||
|
db database.Store
|
||||||
|
logger *slog.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPeerHandler creates a new peer tracking handler
|
||||||
|
func NewPeerHandler(db database.Store, logger *slog.Logger) *PeerHandler {
|
||||||
|
return &PeerHandler{
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WantsMessage returns true for all message types since we track peers from all messages
|
||||||
|
func (h *PeerHandler) WantsMessage(_ string) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleMessage processes a message to track peer information
|
||||||
|
func (h *PeerHandler) HandleMessage(msg *ristypes.RISMessage) {
|
||||||
|
// Parse peer ASN from string
|
||||||
|
peerASN := 0
|
||||||
|
if msg.PeerASN != "" {
|
||||||
|
if asn, err := strconv.Atoi(msg.PeerASN); err == nil {
|
||||||
|
peerASN = asn
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update peer in database
|
||||||
|
if err := h.db.UpdatePeer(msg.Peer, peerASN, msg.Type, msg.ParsedTimestamp); err != nil {
|
||||||
|
h.logger.Error("Failed to update peer",
|
||||||
|
"peer", msg.Peer,
|
||||||
|
"peer_asn", peerASN,
|
||||||
|
"message_type", msg.Type,
|
||||||
|
"error", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -4,7 +4,6 @@ package server
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@ -12,6 +11,7 @@ import (
|
|||||||
|
|
||||||
"git.eeqj.de/sneak/routewatch/internal/database"
|
"git.eeqj.de/sneak/routewatch/internal/database"
|
||||||
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
"git.eeqj.de/sneak/routewatch/internal/streamer"
|
||||||
|
"git.eeqj.de/sneak/routewatch/internal/templates"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
)
|
)
|
||||||
@ -226,191 +226,11 @@ func (s *Server) handleStats() http.HandlerFunc {
|
|||||||
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
func (s *Server) handleStatusHTML() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, _ *http.Request) {
|
return func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
if _, err := fmt.Fprint(w, statusHTML); err != nil {
|
|
||||||
s.logger.Error("Failed to write HTML", "error", err)
|
tmpl := templates.StatusTemplate()
|
||||||
|
if err := tmpl.Execute(w, nil); err != nil {
|
||||||
|
s.logger.Error("Failed to render template", "error", err)
|
||||||
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusHTML = `<!DOCTYPE html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>RouteWatch Status</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
||||||
max-width: 1200px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
background: #f5f5f5;
|
|
||||||
}
|
|
||||||
h1 {
|
|
||||||
color: #333;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.status-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
||||||
gap: 20px;
|
|
||||||
margin-bottom: 30px;
|
|
||||||
}
|
|
||||||
.status-card {
|
|
||||||
background: white;
|
|
||||||
padding: 20px;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.status-card h2 {
|
|
||||||
margin: 0 0 15px 0;
|
|
||||||
font-size: 18px;
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.metric {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
padding: 8px 0;
|
|
||||||
border-bottom: 1px solid #eee;
|
|
||||||
}
|
|
||||||
.metric:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
.metric-label {
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
.metric-value {
|
|
||||||
font-weight: 600;
|
|
||||||
color: #333;
|
|
||||||
}
|
|
||||||
.connected {
|
|
||||||
color: #22c55e;
|
|
||||||
}
|
|
||||||
.disconnected {
|
|
||||||
color: #ef4444;
|
|
||||||
}
|
|
||||||
.error {
|
|
||||||
background: #fee;
|
|
||||||
color: #c00;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
margin-top: 20px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<h1>RouteWatch Status</h1>
|
|
||||||
<div id="error" class="error" style="display: none;"></div>
|
|
||||||
<div class="status-grid">
|
|
||||||
<div class="status-card">
|
|
||||||
<h2>Connection Status</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Status</span>
|
|
||||||
<span class="metric-value" id="connected">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Uptime</span>
|
|
||||||
<span class="metric-value" id="uptime">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<h2>Stream Statistics</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Total Messages</span>
|
|
||||||
<span class="metric-value" id="total_messages">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Messages/sec</span>
|
|
||||||
<span class="metric-value" id="messages_per_sec">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Total Data</span>
|
|
||||||
<span class="metric-value" id="total_bytes">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Throughput</span>
|
|
||||||
<span class="metric-value" id="mbits_per_sec">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="status-card">
|
|
||||||
<h2>Database Statistics</h2>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">ASNs</span>
|
|
||||||
<span class="metric-value" id="asns">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Total Prefixes</span>
|
|
||||||
<span class="metric-value" id="prefixes">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">IPv4 Prefixes</span>
|
|
||||||
<span class="metric-value" id="ipv4_prefixes">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">IPv6 Prefixes</span>
|
|
||||||
<span class="metric-value" id="ipv6_prefixes">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Peerings</span>
|
|
||||||
<span class="metric-value" id="peerings">-</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric">
|
|
||||||
<span class="metric-label">Live Routes</span>
|
|
||||||
<span class="metric-value" id="live_routes">-</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
function formatBytes(bytes) {
|
|
||||||
if (bytes === 0) return '0 B';
|
|
||||||
const k = 1024;
|
|
||||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
||||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
|
||||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatNumber(num) {
|
|
||||||
return num.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStatus() {
|
|
||||||
fetch('/api/v1/stats')
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
// Connection status
|
|
||||||
const connectedEl = document.getElementById('connected');
|
|
||||||
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
|
||||||
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
|
||||||
|
|
||||||
// Update all metrics
|
|
||||||
document.getElementById('uptime').textContent = data.uptime;
|
|
||||||
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
|
||||||
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
|
||||||
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
|
||||||
document.getElementById('mbits_per_sec').textContent = data.mbits_per_sec.toFixed(2) + ' Mbps';
|
|
||||||
document.getElementById('asns').textContent = formatNumber(data.asns);
|
|
||||||
document.getElementById('prefixes').textContent = formatNumber(data.prefixes);
|
|
||||||
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
|
||||||
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
|
||||||
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
|
||||||
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
|
||||||
|
|
||||||
// Clear any errors
|
|
||||||
document.getElementById('error').style.display = 'none';
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
document.getElementById('error').textContent = 'Error fetching status: ' + error;
|
|
||||||
document.getElementById('error').style.display = 'block';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update immediately and then every 500ms
|
|
||||||
updateStatus();
|
|
||||||
setInterval(updateStatus, 500);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`
|
|
||||||
|
@ -255,11 +255,7 @@ func (s *Streamer) stream(ctx context.Context) error {
|
|||||||
case "RIS_PEER_STATE":
|
case "RIS_PEER_STATE":
|
||||||
// RIS peer state messages - silently ignore
|
// RIS peer state messages - silently ignore
|
||||||
case "KEEPALIVE":
|
case "KEEPALIVE":
|
||||||
// BGP keepalive messages - just log at debug level
|
// BGP keepalive messages - silently process
|
||||||
s.logger.Debug("BGP keepalive",
|
|
||||||
"peer", msg.Peer,
|
|
||||||
"peer_asn", msg.PeerASN,
|
|
||||||
)
|
|
||||||
case "OPEN":
|
case "OPEN":
|
||||||
// BGP open messages
|
// BGP open messages
|
||||||
s.logger.Info("BGP session opened",
|
s.logger.Info("BGP session opened",
|
||||||
|
181
internal/templates/status.html
Normal file
181
internal/templates/status.html
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>RouteWatch Status</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background: #f5f5f5;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
color: #333;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.status-card {
|
||||||
|
background: white;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
.status-card h2 {
|
||||||
|
margin: 0 0 15px 0;
|
||||||
|
font-size: 18px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.metric {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 0;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
}
|
||||||
|
.metric:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.metric-label {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.metric-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.connected {
|
||||||
|
color: #22c55e;
|
||||||
|
}
|
||||||
|
.disconnected {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #fee;
|
||||||
|
color: #c00;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>RouteWatch Status</h1>
|
||||||
|
<div id="error" class="error" style="display: none;"></div>
|
||||||
|
<div class="status-grid">
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Connection Status</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Status</span>
|
||||||
|
<span class="metric-value" id="connected">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Uptime</span>
|
||||||
|
<span class="metric-value" id="uptime">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Stream Statistics</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Messages</span>
|
||||||
|
<span class="metric-value" id="total_messages">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Messages/sec</span>
|
||||||
|
<span class="metric-value" id="messages_per_sec">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Data</span>
|
||||||
|
<span class="metric-value" id="total_bytes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Throughput</span>
|
||||||
|
<span class="metric-value" id="mbits_per_sec">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="status-card">
|
||||||
|
<h2>Database Statistics</h2>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">ASNs</span>
|
||||||
|
<span class="metric-value" id="asns">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Total Prefixes</span>
|
||||||
|
<span class="metric-value" id="prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv4 Prefixes</span>
|
||||||
|
<span class="metric-value" id="ipv4_prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">IPv6 Prefixes</span>
|
||||||
|
<span class="metric-value" id="ipv6_prefixes">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Peerings</span>
|
||||||
|
<span class="metric-value" id="peerings">-</span>
|
||||||
|
</div>
|
||||||
|
<div class="metric">
|
||||||
|
<span class="metric-label">Live Routes</span>
|
||||||
|
<span class="metric-value" id="live_routes">-</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(num) {
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStatus() {
|
||||||
|
fetch('/api/v1/stats')
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
// Connection status
|
||||||
|
const connectedEl = document.getElementById('connected');
|
||||||
|
connectedEl.textContent = data.connected ? 'Connected' : 'Disconnected';
|
||||||
|
connectedEl.className = 'metric-value ' + (data.connected ? 'connected' : 'disconnected');
|
||||||
|
|
||||||
|
// Update all metrics
|
||||||
|
document.getElementById('uptime').textContent = data.uptime;
|
||||||
|
document.getElementById('total_messages').textContent = formatNumber(data.total_messages);
|
||||||
|
document.getElementById('messages_per_sec').textContent = data.messages_per_sec.toFixed(1);
|
||||||
|
document.getElementById('total_bytes').textContent = formatBytes(data.total_bytes);
|
||||||
|
document.getElementById('mbits_per_sec').textContent = data.mbits_per_sec.toFixed(2) + ' Mbps';
|
||||||
|
document.getElementById('asns').textContent = formatNumber(data.asns);
|
||||||
|
document.getElementById('prefixes').textContent = formatNumber(data.prefixes);
|
||||||
|
document.getElementById('ipv4_prefixes').textContent = formatNumber(data.ipv4_prefixes);
|
||||||
|
document.getElementById('ipv6_prefixes').textContent = formatNumber(data.ipv6_prefixes);
|
||||||
|
document.getElementById('peerings').textContent = formatNumber(data.peerings);
|
||||||
|
document.getElementById('live_routes').textContent = formatNumber(data.live_routes);
|
||||||
|
|
||||||
|
// Clear any errors
|
||||||
|
document.getElementById('error').style.display = 'none';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
document.getElementById('error').textContent = 'Error fetching status: ' + error;
|
||||||
|
document.getElementById('error').style.display = 'block';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update immediately and then every 500ms
|
||||||
|
updateStatus();
|
||||||
|
setInterval(updateStatus, 500);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
48
internal/templates/templates.go
Normal file
48
internal/templates/templates.go
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
// Package templates provides embedded HTML templates for the RouteWatch application
|
||||||
|
package templates
|
||||||
|
|
||||||
|
import (
|
||||||
|
_ "embed"
|
||||||
|
"html/template"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed status.html
|
||||||
|
var statusHTML string
|
||||||
|
|
||||||
|
// Templates contains all parsed templates
|
||||||
|
type Templates struct {
|
||||||
|
Status *template.Template
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
//nolint:gochecknoglobals // Singleton pattern for templates
|
||||||
|
defaultTemplates *Templates
|
||||||
|
//nolint:gochecknoglobals // Singleton pattern for templates
|
||||||
|
once sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// initTemplates parses all embedded templates
|
||||||
|
func initTemplates() {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
defaultTemplates = &Templates{}
|
||||||
|
|
||||||
|
// Parse status template
|
||||||
|
defaultTemplates.Status, err = template.New("status").Parse(statusHTML)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to parse status template: " + err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the singleton Templates instance
|
||||||
|
func Get() *Templates {
|
||||||
|
once.Do(initTemplates)
|
||||||
|
|
||||||
|
return defaultTemplates
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusTemplate returns the parsed status template
|
||||||
|
func StatusTemplate() *template.Template {
|
||||||
|
return Get().Status
|
||||||
|
}
|
652012
pkg/asinfo/asdata.json
652012
pkg/asinfo/asdata.json
File diff suppressed because it is too large
Load Diff
BIN
pkg/asinfo/asdata.json.gz
Normal file
BIN
pkg/asinfo/asdata.json.gz
Normal file
Binary file not shown.
@ -2,13 +2,16 @@
|
|||||||
package asinfo
|
package asinfo
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
_ "embed"
|
_ "embed"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"io"
|
||||||
"sync"
|
"sync"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed asdata.json
|
//go:embed asdata.json.gz
|
||||||
var asDataJSON []byte
|
var asDataGZ []byte
|
||||||
|
|
||||||
// Info represents information about an Autonomous System
|
// Info represents information about an Autonomous System
|
||||||
type Info struct {
|
type Info struct {
|
||||||
@ -24,8 +27,10 @@ type Registry struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
//nolint:gochecknoglobals // Singleton pattern for embedded data
|
||||||
defaultRegistry *Registry
|
defaultRegistry *Registry
|
||||||
once sync.Once
|
//nolint:gochecknoglobals // Singleton pattern for embedded data
|
||||||
|
once sync.Once
|
||||||
)
|
)
|
||||||
|
|
||||||
// initRegistry initializes the default registry with embedded data
|
// initRegistry initializes the default registry with embedded data
|
||||||
@ -33,12 +38,26 @@ func initRegistry() {
|
|||||||
defaultRegistry = &Registry{
|
defaultRegistry = &Registry{
|
||||||
byASN: make(map[int]*Info),
|
byASN: make(map[int]*Info),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decompress the gzipped data
|
||||||
|
gzReader, err := gzip.NewReader(bytes.NewReader(asDataGZ))
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to create gzip reader: " + err.Error())
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
_ = gzReader.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
jsonData, err := io.ReadAll(gzReader)
|
||||||
|
if err != nil {
|
||||||
|
panic("failed to decompress AS data: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
var asInfos []Info
|
var asInfos []Info
|
||||||
if err := json.Unmarshal(asDataJSON, &asInfos); err != nil {
|
if err := json.Unmarshal(jsonData, &asInfos); err != nil {
|
||||||
panic("failed to unmarshal embedded AS data: " + err.Error())
|
panic("failed to unmarshal embedded AS data: " + err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range asInfos {
|
for i := range asInfos {
|
||||||
info := &asInfos[i]
|
info := &asInfos[i]
|
||||||
defaultRegistry.byASN[info.ASN] = info
|
defaultRegistry.byASN[info.ASN] = info
|
||||||
@ -48,18 +67,19 @@ func initRegistry() {
|
|||||||
// Get returns AS information for the given ASN
|
// Get returns AS information for the given ASN
|
||||||
func Get(asn int) (*Info, bool) {
|
func Get(asn int) (*Info, bool) {
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
info, ok := defaultRegistry.byASN[asn]
|
info, ok := defaultRegistry.byASN[asn]
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return a copy to prevent mutation
|
// Return a copy to prevent mutation
|
||||||
copy := *info
|
infoCopy := *info
|
||||||
return ©, true
|
|
||||||
|
return &infoCopy, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDescription returns just the description for an ASN
|
// GetDescription returns just the description for an ASN
|
||||||
@ -68,6 +88,7 @@ func GetDescription(asn int) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return info.Description
|
return info.Description
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,16 +98,17 @@ func GetHandle(asn int) string {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
return info.Handle
|
return info.Handle
|
||||||
}
|
}
|
||||||
|
|
||||||
// Total returns the total number of AS entries in the registry
|
// Total returns the total number of AS entries in the registry
|
||||||
func Total() int {
|
func Total() int {
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
return len(defaultRegistry.byASN)
|
return len(defaultRegistry.byASN)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -94,15 +116,15 @@ func Total() int {
|
|||||||
// Note: This creates a copy of all data, use sparingly
|
// Note: This creates a copy of all data, use sparingly
|
||||||
func All() []Info {
|
func All() []Info {
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
result := make([]Info, 0, len(defaultRegistry.byASN))
|
result := make([]Info, 0, len(defaultRegistry.byASN))
|
||||||
for _, info := range defaultRegistry.byASN {
|
for _, info := range defaultRegistry.byASN {
|
||||||
result = append(result, *info)
|
result = append(result, *info)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,17 +132,17 @@ func All() []Info {
|
|||||||
// This is a simple case-sensitive substring search
|
// This is a simple case-sensitive substring search
|
||||||
func Search(query string) []Info {
|
func Search(query string) []Info {
|
||||||
once.Do(initRegistry)
|
once.Do(initRegistry)
|
||||||
|
|
||||||
defaultRegistry.mu.RLock()
|
defaultRegistry.mu.RLock()
|
||||||
defer defaultRegistry.mu.RUnlock()
|
defer defaultRegistry.mu.RUnlock()
|
||||||
|
|
||||||
var results []Info
|
var results []Info
|
||||||
for _, info := range defaultRegistry.byASN {
|
for _, info := range defaultRegistry.byASN {
|
||||||
if contains(info.Handle, query) || contains(info.Description, query) {
|
if contains(info.Handle, query) || contains(info.Description, query) {
|
||||||
results = append(results, *info)
|
results = append(results, *info)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,5 +157,6 @@ func containsImpl(s, substr string) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
@ -43,7 +43,7 @@ func TestGet(t *testing.T) {
|
|||||||
t.Errorf("Get(%d) ok = %v, want %v", tt.asn, ok, tt.wantOK)
|
t.Errorf("Get(%d) ok = %v, want %v", tt.asn, ok, tt.wantOK)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if ok && info.Description != tt.wantDesc {
|
if ok && info.Description != tt.wantDesc {
|
||||||
t.Errorf("Get(%d) description = %q, want %q", tt.asn, info.Description, tt.wantDesc)
|
t.Errorf("Get(%d) description = %q, want %q", tt.asn, info.Description, tt.wantDesc)
|
||||||
}
|
}
|
||||||
@ -93,7 +93,7 @@ func TestTotal(t *testing.T) {
|
|||||||
if total < 100000 {
|
if total < 100000 {
|
||||||
t.Errorf("Total() = %d, expected > 100000", total)
|
t.Errorf("Total() = %d, expected > 100000", total)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify it's consistent
|
// Verify it's consistent
|
||||||
if total2 := Total(); total2 != total {
|
if total2 := Total(); total2 != total {
|
||||||
t.Errorf("Total() returned different values: %d vs %d", total, total2)
|
t.Errorf("Total() returned different values: %d vs %d", total, total2)
|
||||||
@ -134,7 +134,7 @@ func TestSearch(t *testing.T) {
|
|||||||
if len(results) < tt.wantMin {
|
if len(results) < tt.wantMin {
|
||||||
t.Errorf("Search(%q) returned %d results, want at least %d", tt.query, len(results), tt.wantMin)
|
t.Errorf("Search(%q) returned %d results, want at least %d", tt.query, len(results), tt.wantMin)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all results contain the query
|
// Verify all results contain the query
|
||||||
if tt.query != "" {
|
if tt.query != "" {
|
||||||
for _, r := range results {
|
for _, r := range results {
|
||||||
@ -157,7 +157,7 @@ func TestDataIntegrity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
seen[info.ASN] = true
|
seen[info.ASN] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all entries have required fields
|
// Verify all entries have required fields
|
||||||
for _, info := range all {
|
for _, info := range all {
|
||||||
if info.Handle == "" && info.ASN != 0 {
|
if info.Handle == "" && info.ASN != 0 {
|
||||||
@ -172,7 +172,7 @@ func TestDataIntegrity(t *testing.T) {
|
|||||||
func BenchmarkGet(b *testing.B) {
|
func BenchmarkGet(b *testing.B) {
|
||||||
// Common ASNs to lookup
|
// Common ASNs to lookup
|
||||||
asns := []int{1, 15169, 13335, 32934, 8075, 16509}
|
asns := []int{1, 15169, 13335, 32934, 8075, 16509}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
Get(asns[i%len(asns)])
|
Get(asns[i%len(asns)])
|
||||||
@ -181,9 +181,9 @@ func BenchmarkGet(b *testing.B) {
|
|||||||
|
|
||||||
func BenchmarkSearch(b *testing.B) {
|
func BenchmarkSearch(b *testing.B) {
|
||||||
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
queries := []string{"Google", "Amazon", "Microsoft", "University"}
|
||||||
|
|
||||||
b.ResetTimer()
|
b.ResetTimer()
|
||||||
for i := 0; i < b.N; i++ {
|
for i := 0; i < b.N; i++ {
|
||||||
Search(queries[i%len(queries)])
|
Search(queries[i%len(queries)])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -25,4 +25,4 @@ Basic usage:
|
|||||||
The data is loaded lazily on first access and cached in memory for the lifetime
|
The data is loaded lazily on first access and cached in memory for the lifetime
|
||||||
of the program. All getter methods are safe for concurrent use.
|
of the program. All getter methods are safe for concurrent use.
|
||||||
*/
|
*/
|
||||||
package asinfo
|
package asinfo
|
||||||
|
@ -26,4 +26,4 @@ func ExampleSearch() {
|
|||||||
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
fmt.Printf("AS%d: %s - %s\n", info.ASN, info.Handle, info.Description)
|
||||||
}
|
}
|
||||||
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
// Output: AS3: MIT-GATEWAYS - Massachusetts Institute of Technology
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user