Build a Network Diagnostics Platform

Build a Go backend that runs real pings, traceroutes, and DNS benchmarks.

Introduction

30 Second Summary

You know that feeling when a website takes forever to load, but your "internet speed" test says everything is fine? The problem is that most tools just check raw speed. They do not tell you where along the path between you and the server things are going wrong.

In this project, you will build a production-grade internet diagnostics platform that runs real network probes and streams results to an interactive dashboard in real time. You will deploy it to the cloud with a live public URL that anyone can use.

What You'll Build

A self-hosted network diagnostics tool that performs ICMP pings, UDP traceroute, DNS benchmarking, speed tests, and intelligent issue classification, visualized on an interactive world map and real-time charts.

By the end of this project, you'll have:

  • A Go backend that performs real ICMP ping, UDP traceroute, DNS benchmarking, and speed testing, streaming results over WebSockets to connected clients.
  • A Next.js dashboard with a D3.js network topology graph and a MapLibre GL world map showing hop-by-hop traffic paths in real time.
  • A full Docker Compose stack deployed to AWS EC2 with automatic HTTPS via Caddy, plus a frontend on Vercel with a live public URL.
  • Secret Mission: A multi-target comparison mode that runs diagnostics against multiple destinations simultaneously and highlights where network paths diverge.

Are there any prerequisites?

You should have basic programming knowledge and familiarity with terminal commands. You will need Go, Node.js, Docker, and an AWS account (free tier) set up on your machine. Step 1 walks you through verifying and configuring everything.

Set Up AWS and Scaffold the Project

Your diagnostics platform needs a server that can send raw network packets (ICMP pings, UDP traceroute probes) to the internet.

Most cloud platforms block raw socket access, but AWS EC2 gives you a full virtual machine with complete control over networking. In this step, you will launch an EC2 instance, install Docker on it, and scaffold the Go and Next.js projects on your local machine.

In this step, get ready to:
  • Launch an EC2 t2.micro instance with the right security group and SSH into it.
  • Install Docker and Docker Compose on the EC2 instance.
  • Scaffold the Go backend and Next.js frontend locally.
Launch an EC2 instance with a security group

Your Go backend needs to run ICMP ping and UDP traceroute probes. These require raw socket access, which means you need a real VM, not a managed container platform.

Why EC2 over Railway or Fly.io?

Railway does not support the NET_RAW capability that ICMP requires. Fly.io uses Firecracker microVMs that could work, but requires a credit card.

EC2 t2.micro is free for 750 hours per month under the AWS free tier. It gives you full VM-level control over networking, which is exactly what a network diagnostics tool demands.

  • Open your browser and sign in to the AWS Management Console.
  • Make sure your region is set to US East (N. Virginia) us-east-1 in the top-right dropdown.
  • Search for EC2 in the top search bar and select EC2 from the results.

First, create a key pair so you can SSH into the instance later.

  • In the left sidebar, click Key Pairs under the Network & Security section.
  • Click Create key pair.
  • Enter netprobe-key as the name.
  • Select RSA for key pair type.
  • Select .pem for file format.
  • Click Create key pair. The file netprobe-key.pem downloads automatically.

Now move the key to a safe location and lock down its permissions.

  • Move the key file and set its permissions by running these commands:
mv ~/Downloads/netprobe-key.pem ~/.ssh/netprobe-key.pem
chmod 400 ~/.ssh/netprobe-key.pem

What does chmod 400 do?

The chmod 400 command restricts the file so only you can read it. SSH refuses to use key files that have open permissions, so this step is required.

Next, create a security group that opens the ports your platform needs.

  • In the EC2 sidebar, click Security Groups under Network & Security.
  • Click Create security group.
  • Enter netprobe-sg for the Security group name.
  • Enter NetProbe diagnostic platform ports for the Description.

Under Inbound rules, add four rules by clicking Add rule for each one.

  • Rule 1: Type = SSH, Source = My IP.
  • Rule 2: Type = HTTP, Source = Anywhere-IPv4.
  • Rule 3: Type = HTTPS, Source = Anywhere-IPv4.
  • Rule 4: Type = Custom TCP, Port range = 8080, Source = Anywhere-IPv4.
  • Click Create security group.

Why these four ports?

Port 22 is for SSH access. Port 80 is for Caddy's HTTP challenge when obtaining TLS certificates. Port 443 is for production HTTPS traffic. Port 8080 is for direct API testing before the reverse proxy is set up.

Now launch the EC2 instance itself.

  • In the EC2 sidebar, click Instances.
  • Click Launch instances.
  • Enter netprobe-server for the Name.
  • Under Application and OS Images, select Amazon Linux 2023 (it should be the default).
  • Under Instance type, select t2.micro (Free tier eligible).
  • Under Key pair, select netprobe-key from the dropdown.
  • Under Network settings, click Select existing security group.
  • Choose netprobe-sg from the list.
  • Leave all other settings at their defaults.
  • Click Launch instance.
  • Wait about 30 seconds, then click View all instances.
  • Wait for the Instance state column to show Running and the Status check to show 2/2 checks passed.
  • Click on your instance ID, then copy the Public IPv4 DNS value. You will need this to SSH in.

Instance stuck in Pending?

EC2 instances typically take 30-60 seconds to reach the Running state. If it takes longer than 2 minutes, refresh the console page.

If your instance immediately stops, your account may not have the free tier available. Check the Events tab on the instance detail page for error messages.

Help me debug my EC2 instance launch issue

SSH into EC2 and install Docker

Your EC2 instance is a bare Linux machine. It needs Docker and Docker Compose installed so you can later deploy the full stack as containers.

  • SSH into your EC2 instance by running this command:
ssh -i ~/.ssh/netprobe-key.pem ec2-user@[[EC2_PUBLIC_DNS="your EC2 Public IPv4 DNS"]]

What does this command do?

The -i flag specifies your private key file. ec2-user is the default username on Amazon Linux instances. The part after @ is your instance's public address.

  • When prompted "Are you sure you want to continue connecting?", type yes and press Enter.

You should see a welcome banner showing "Amazon Linux 2023" and a command prompt ending in $.

Permission denied or connection timed out?

"Permission denied" means the key file path is wrong or the permissions are not set to 400. Run chmod 400 ~/.ssh/netprobe-key.pem and try again.

"Connection timed out" means the security group does not have port 22 open to your IP. Go back to the security group and confirm the SSH rule has your current IP.

Help me fix my SSH connection to EC2

  • Install Docker on the EC2 instance by running these commands:
sudo dnf update -y
sudo dnf install -y docker
sudo systemctl start docker
sudo systemctl enable docker
sudo usermod -aG docker ec2-user

What do these commands do?

The first command updates all system packages. The second installs Docker. The third and fourth start Docker now and set it to start automatically on boot. The last command adds your user to the docker group so you can run Docker without sudo.

  • Install Docker Compose by running these commands:
sudo curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
sudo chmod +x /usr/local/bin/docker-compose
  • Log out and back in so the docker group takes effect. Run:
exit
  • SSH back into the instance using the same command as before:
ssh -i ~/.ssh/netprobe-key.pem ec2-user@[[EC2_PUBLIC_DNS="your EC2 Public IPv4 DNS"]]
  • Verify Docker is working by running:
docker --version
docker-compose --version

You should see version numbers for both Docker and Docker Compose. No sudo was needed, which confirms your user is in the docker group.

Permission denied when running docker?

If you see "permission denied while trying to connect to the Docker daemon", the group change has not taken effect. Make sure you logged out completely with exit and SSH'd back in.

Help me fix Docker permissions on EC2

  • Disconnect from EC2 by running exit. You will return to your local terminal for the next substep.
Scaffold the Go and Next.js projects locally

The platform has two codebases. The Go backend handles networking probes and WebSocket streaming. The Next.js frontend renders the interactive dashboard.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create the project folder and directory structure by running these commands:
mkdir netprobe
cd netprobe
mkdir -p cmd/server
mkdir -p internal

What is this directory structure?

This follows the standard Go project layout. The cmd/server/ folder holds your application entrypoint. The internal/ folder holds private packages (ping, traceroute, DNS, WebSocket hub) that only your project can import.

  • Initialize the Go module by running this command:
go mod init netprobe

You should see the message go: creating new go.mod: module netprobe.

  • Add the project dependencies by running these commands:
go get github.com/coder/websocket@v1.8.14
go get github.com/lib/pq@v1.10.9
go get github.com/miekg/dns@v1.1.62
go get github.com/prometheus-community/pro-bing@v0.4.1
go get github.com/kjansson/go-traceroute@v1.1.0

What are these dependencies?

  • coder/websocket is the actively maintained WebSocket library for Go (successor to gorilla/websocket, which was archived).
  • lib/pq is the PostgreSQL driver for storing diagnostic history.
  • miekg/dns provides low-level DNS query capabilities for the DNS benchmark module.
  • pro-bing is the actively maintained ICMP ping library (fork of the deprecated go-ping/ping).
  • go-traceroute runs UDP-based traceroutes with async hop streaming.
  • Tidy up the module to fetch all indirect dependencies by running:
go mod tidy
  • Verify your go.mod file looks correct by running:
cat go.mod

You should see the module name, Go version, and all five dependencies listed with their version numbers.

✔️ Awesome, I've got everything!

Great. Make sure you have saved everything and your terminal is still inside the netprobe folder.

ⓧ I'd like to double check the full code

Your go.mod should look exactly like this:

module netprobe

go 1.23

require (
	github.com/coder/websocket v1.8.14
	github.com/lib/pq v1.10.9
	github.com/miekg/dns v1.1.62
	github.com/prometheus-community/pro-bing v0.4.1
	github.com/kjansson/go-traceroute v1.1.0
)

You may also see a require ( ... ) block with // indirect entries below it. Those are transitive dependencies pulled in by go mod tidy and are normal.

Seeing errors during go get?

If you see "module not found" errors, check that you have internet access and that Go is at version 1.23 or later. Run go version to confirm.

Help me fix go get errors

Now scaffold the Next.js frontend.

  • Make sure you are still inside the netprobe folder on your Desktop.
  • Create the Next.js frontend by running this command:
npx create-next-app netprobe-ui

What will the wizard ask?

The create-next-app command launches an interactive wizard. It asks about TypeScript, ESLint, Tailwind CSS, and other options. Accept the defaults listed below.

  • Enter Yes for the TypeScript prompt.
  • Enter Yes for the ESLint prompt.
  • Enter Yes for the Tailwind CSS prompt.
  • Enter No for the src/ directory prompt.
  • Enter Yes for the App Router prompt.
  • Enter No for the import alias prompt.

The wizard installs dependencies and creates the netprobe-ui folder. This takes about 30-60 seconds.

  • Confirm both projects exist by running:
ls

You should see cmd, internal, go.mod, go.sum, and netprobe-ui in the output.

Your AWS infrastructure is running and both project skeletons are ready. Next up, you will build the Go HTTP server and WebSocket hub that streams diagnostic results to connected clients in real time.

Build the Go HTTP Server and WebSocket Hub

Your project structure is scaffolded and all dependencies are locked down.

Now you need to build the backbone that everything else plugs into: an HTTP server that routes requests to diagnostic modules, and a WebSocket hub that streams results to the frontend in real time.

In this step, get ready to:
  • Build a WebSocket Hub that manages client connections and broadcasts messages.
  • Create an API router with CORS middleware and a health check endpoint.
  • Wire everything together in a server entry point with graceful shutdown.
Build the WebSocket Hub

Network diagnostics produce results over time as packets fly back and forth.

Instead of making the frontend poll for updates, a WebSocket connection lets the backend push each result the instant it arrives. The Hub pattern manages every connected client in one place and broadcasts messages to all of them simultaneously.

  • Create the internal/ws/ directory inside your netprobe project folder by running this command:
mkdir -p internal/ws
  • Create a new file called hub.go inside internal/ws/ and add the struct definitions and constructor:
package ws

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"sync"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

// Message represents a WebSocket message sent to clients
type Message struct {
	Type   string      `json:"type"`
	Target string      `json:"target,omitempty"`
	Data   interface{} `json:"data"`
}

// Client wraps a single WebSocket connection
type Client struct {
	conn *websocket.Conn
	send chan Message
}

// Hub manages all active WebSocket clients and broadcasts messages
type Hub struct {
	clients    map[*Client]bool
	broadcast  chan Message
	register   chan *Client
	unregister chan *Client
	mu         sync.RWMutex
}

// NewHub creates and returns a fresh Hub instance
func NewHub() *Hub {
	return &Hub{
		clients:    make(map[*Client]bool),
		broadcast:  make(chan Message, 256),
		register:   make(chan *Client),
		unregister: make(chan *Client),
	}
}

What does this code do?

These structs define the core data model for real-time communication.

  • Message is the envelope for every WebSocket message. The Type field tells the frontend what kind of data arrived (ping result, traceroute hop, diagnosis).
  • Client pairs a single WebSocket connection with its own send channel, so writes never block the Hub.
  • Hub holds the set of all active clients plus channels for registering, unregistering, and broadcasting.
  • The broadcast channel is buffered to 256, preventing a slow client from stalling diagnostic modules.

You won't see a change yet. The next chunk adds the event loop that makes the Hub work.

  • Below the NewHub() function, add the Run() method that processes client events in a loop:
// Run starts the Hub event loop, processing register/unregister/broadcast events
func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.mu.Lock()
			h.clients[client] = true
			h.mu.Unlock()
		case client := <-h.unregister:
			h.mu.Lock()
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
			h.mu.Unlock()
		case msg := <-h.broadcast:
			h.mu.RLock()
			for client := range h.clients {
				select {
				case client.send <- msg:
				default:
					close(client.send)
					delete(h.clients, client)
				}
			}
			h.mu.RUnlock()
		}
	}
}

What does this code do?

Run() is an infinite loop that listens on three channels using Go's select statement.

  • When a client connects, it gets added to the map.
  • When a client disconnects, it gets removed and its send channel is closed.
  • When a message arrives on broadcast, it gets pushed to every connected client's send channel.
  • If a client's send channel is full (it's too slow), the Hub drops that client instead of blocking everyone else.

You won't see a change yet. The next chunk adds the public methods that accept connections and send data.

  • Below the Run() method, add the Broadcast(), HandleWebSocket(), and handleClientMessage() functions:
// Broadcast sends a message to all connected clients
func (h *Hub) Broadcast(msg Message) {
	h.broadcast <- msg
}

// HandleWebSocket upgrades an HTTP request to a WebSocket connection
func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		OriginPatterns: []string{"*"},
	})
	if err != nil {
		log.Printf("WebSocket accept error: %v", err)
		return
	}

	client := &Client{
		conn: conn,
		send: make(chan Message, 64),
	}
	h.register <- client

	// Goroutine that writes queued messages to the WebSocket
	go func() {
		defer func() {
			h.unregister <- client
			conn.CloseNow()
		}()
		for msg := range client.send {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			err := wsjson.Write(ctx, conn, msg)
			cancel()
			if err != nil {
				return
			}
		}
	}()

	// Read loop listens for messages from the client
	for {
		var incoming map[string]interface{}
		err := wsjson.Read(context.Background(), conn, &incoming)
		if err != nil {
			return
		}
		h.handleClientMessage(incoming, client)
	}
}

// handleClientMessage logs messages received from a client
func (h *Hub) handleClientMessage(msg map[string]interface{}, client *Client) {
	data, _ := json.Marshal(msg)
	log.Printf("Received from client: %s", string(data))
}

What does this code do?

  • Broadcast() is the one-liner that diagnostic modules call to push results to all clients.
  • HandleWebSocket() accepts a WebSocket upgrade using coder/websocket, registers the new client, spawns a write goroutine, and enters a read loop.
  • OriginPatterns: []string{"*"} allows connections from any origin. This is necessary because the frontend lives on a different domain (Vercel).
  • The write goroutine drains the client's send channel with a 5-second timeout per write, preventing a stuck connection from leaking resources.
  • Save internal/ws/hub.go.

Seeing a red squiggly on time.Second?

Your editor may flag that time is not imported. Add "time" to your import block if your editor does not auto-add it. Most Go editors (VS Code with gopls, GoLand) will add it automatically on save.

help me fix an import error in my Go WebSocket hub file

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

package ws

import (
	"context"
	"encoding/json"
	"log"
	"net/http"
	"sync"

	"github.com/coder/websocket"
	"github.com/coder/websocket/wsjson"
)

type Message struct {
	Type   string      `json:"type"`
	Target string      `json:"target,omitempty"`
	Data   interface{} `json:"data"`
}

type Client struct {
	conn *websocket.Conn
	send chan Message
}

type Hub struct {
	clients    map[*Client]bool
	broadcast  chan Message
	register   chan *Client
	unregister chan *Client
	mu         sync.RWMutex
}

func NewHub() *Hub {
	return &Hub{
		clients:    make(map[*Client]bool),
		broadcast:  make(chan Message, 256),
		register:   make(chan *Client),
		unregister: make(chan *Client),
	}
}

func (h *Hub) Run() {
	for {
		select {
		case client := <-h.register:
			h.mu.Lock()
			h.clients[client] = true
			h.mu.Unlock()
		case client := <-h.unregister:
			h.mu.Lock()
			if _, ok := h.clients[client]; ok {
				delete(h.clients, client)
				close(client.send)
			}
			h.mu.Unlock()
		case msg := <-h.broadcast:
			h.mu.RLock()
			for client := range h.clients {
				select {
				case client.send <- msg:
				default:
					close(client.send)
					delete(h.clients, client)
				}
			}
			h.mu.RUnlock()
		}
	}
}

func (h *Hub) Broadcast(msg Message) {
	h.broadcast <- msg
}

func (h *Hub) HandleWebSocket(w http.ResponseWriter, r *http.Request) {
	conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
		OriginPatterns: []string{"*"},
	})
	if err != nil {
		log.Printf("WebSocket accept error: %v", err)
		return
	}

	client := &Client{
		conn: conn,
		send: make(chan Message, 64),
	}
	h.register <- client

	go func() {
		defer func() {
			h.unregister <- client
			conn.CloseNow()
		}()
		for msg := range client.send {
			ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
			err := wsjson.Write(ctx, conn, msg)
			cancel()
			if err != nil {
				return
			}
		}
	}()

	for {
		var incoming map[string]interface{}
		err := wsjson.Read(context.Background(), conn, &incoming)
		if err != nil {
			return
		}
		h.handleClientMessage(incoming, client)
	}
}

func (h *Hub) handleClientMessage(msg map[string]interface{}, client *Client) {
	data, _ := json.Marshal(msg)
	log.Printf("Received from client: %s", string(data))
}
Create the API router with CORS and health check

The router maps incoming HTTP requests to the correct handler function. It also needs CORS middleware so the Next.js frontend (running on a different domain) can make requests without being blocked by the browser.

  • Create the internal/api/ directory by running:
mkdir -p internal/api
  • Create a new file called router.go inside internal/api/ and add the following code:
package api

import (
	"database/sql"
	"net/http"

	"netprobe/internal/ws"
)

// NewRouter creates the HTTP multiplexer and registers all API routes
func NewRouter(hub *ws.Hub, database *sql.DB) http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/health", handleHealth)
	mux.HandleFunc("POST /api/ping", handlePing(hub, database))
	mux.HandleFunc("POST /api/traceroute", handleTraceroute(hub, database))
	mux.HandleFunc("POST /api/dns", handleDNS(hub, database))
	mux.HandleFunc("POST /api/speedtest", handleSpeedTest(hub))
	mux.HandleFunc("POST /api/diagnose", handleDiagnose(hub, database))
	mux.HandleFunc("GET /api/history", handleHistory(database))
	mux.HandleFunc("GET /api/trends", handleTrends(database))
	mux.HandleFunc("GET /ws", hub.HandleWebSocket)

	return corsMiddleware(mux)
}

// corsMiddleware allows cross-origin requests from the frontend
func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if r.Method == "OPTIONS" {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// handleHealth returns a simple JSON response confirming the server is alive
func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"status":"ok","service":"netprobe"}`))
}

What does this code do?

  • NewRouter() creates a standard library ServeMux and registers 9 routes. Go 1.22+ supports method-based routing like "POST /api/ping" directly in the pattern string.
  • The handler functions (handlePing, handleTraceroute, etc.) will be implemented in later steps. The route skeleton is set up first.
  • corsMiddleware() wraps the entire router. It sets permissive CORS headers and short-circuits OPTIONS preflight requests with a 204 response.
  • handleHealth() is a zero-dependency endpoint that confirms the server is alive. You will use this later to verify deployments.
  • Save internal/api/router.go.

You won't see a change yet. The handler functions referenced in the router are created in upcoming steps.

Seeing unresolved references in your editor?

Your editor will show errors for handlePing, handleTraceroute, and other handler functions. This is expected. Those functions live in internal/api/handlers.go which you will create in later steps.

help me understand why my Go code shows unresolved references

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

package api

import (
	"database/sql"
	"net/http"

	"netprobe/internal/ws"
)

func NewRouter(hub *ws.Hub, database *sql.DB) http.Handler {
	mux := http.NewServeMux()

	mux.HandleFunc("GET /api/health", handleHealth)
	mux.HandleFunc("POST /api/ping", handlePing(hub, database))
	mux.HandleFunc("POST /api/traceroute", handleTraceroute(hub, database))
	mux.HandleFunc("POST /api/dns", handleDNS(hub, database))
	mux.HandleFunc("POST /api/speedtest", handleSpeedTest(hub))
	mux.HandleFunc("POST /api/diagnose", handleDiagnose(hub, database))
	mux.HandleFunc("GET /api/history", handleHistory(database))
	mux.HandleFunc("GET /api/trends", handleTrends(database))
	mux.HandleFunc("GET /ws", hub.HandleWebSocket)

	return corsMiddleware(mux)
}

func corsMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Access-Control-Allow-Origin", "*")
		w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
		w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
		if r.Method == "OPTIONS" {
			w.WriteHeader(http.StatusNoContent)
			return
		}
		next.ServeHTTP(w, r)
	})
}

func handleHealth(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/json")
	w.Write([]byte(`{"status":"ok","service":"netprobe"}`))
}
Wire everything in the server entry point

The entry point is the single file that starts your application. It creates the Hub, connects to the database, starts the background monitor, and launches the HTTP server with graceful shutdown so in-flight requests finish cleanly when you stop the process.

  • Open cmd/server/main.go (this file was created during scaffolding in Step 1) and replace its contents with the following:
package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"netprobe/internal/api"
	"netprobe/internal/db"
	"netprobe/internal/monitor"
	"netprobe/internal/ws"
)

func main() {
	// Create the WebSocket hub and start its event loop
	hub := ws.NewHub()
	go hub.Run()

	// Connect to PostgreSQL using the DATABASE_URL env var
	database, err := db.Connect(os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	defer database.Close()

	// Run database migrations to ensure tables exist
	if err := db.Migrate(database); err != nil {
		log.Fatalf("Failed to run migrations: %v", err)
	}

	// Start the background monitor that pings targets every 60 seconds
	mon := monitor.New(hub, database)
	go mon.Start()

	// Create the HTTP router with all routes registered
	handler := api.NewRouter(hub, database)

	// Configure the HTTP server with timeouts
	srv := &http.Server{
		Addr:         ":8080",
		Handler:      handler,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 60 * time.Second,
	}

	// Start serving in a goroutine
	go func() {
		log.Printf("NetProbe server starting on :8080")
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("Server failed: %v", err)
		}
	}()

	// Wait for SIGINT or SIGTERM to trigger graceful shutdown
	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	// Give in-flight requests 10 seconds to finish
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(shutdownCtx)
	log.Println("Server shut down gracefully")
}

What does this code do?

  • go hub.Run() launches the Hub event loop in a background goroutine so it processes client events concurrently.
  • db.Connect reads the DATABASE_URL environment variable and opens a PostgreSQL connection pool.
  • WriteTimeout: 60 * time.Second is intentionally long because WebSocket connections and streaming responses need time to complete.
  • signal.NotifyContext blocks until the process receives SIGINT (Ctrl+C) or SIGTERM (Docker stop). Then the 10-second shutdown window lets active connections finish.
  • Save cmd/server/main.go.
  • Confirm all three files exist by running:
ls internal/ws/hub.go internal/api/router.go cmd/server/main.go

You should see all three file paths printed without errors.

File not found?

Make sure you are running the ls command from the root of your netprobe folder (the one containing go.mod). If you are inside a subdirectory, run cd ~/Desktop/netprobe first.

Check that the directories are spelled exactly internal/ws/ and internal/api/ with no typos.

help me find my missing Go files

Why can't I run the server yet?

The main.go file imports netprobe/internal/db and netprobe/internal/monitor, which you have not created yet. The router also references handler functions that do not exist.

This is normal when building incrementally. The project will compile and run after you create the remaining modules in the next few steps.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before moving on.

ⓧ I'd like to double check the full code

package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"netprobe/internal/api"
	"netprobe/internal/db"
	"netprobe/internal/monitor"
	"netprobe/internal/ws"
)

func main() {
	hub := ws.NewHub()
	go hub.Run()

	database, err := db.Connect(os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatalf("Failed to connect to database: %v", err)
	}
	defer database.Close()

	if err := db.Migrate(database); err != nil {
		log.Fatalf("Failed to run migrations: %v", err)
	}

	mon := monitor.New(hub, database)
	go mon.Start()

	handler := api.NewRouter(hub, database)

	srv := &http.Server{
		Addr:         ":8080",
		Handler:      handler,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 60 * time.Second,
	}

	go func() {
		log.Printf("NetProbe server starting on :8080")
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("Server failed: %v", err)
		}
	}()

	ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
	defer stop()
	<-ctx.Done()

	shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()
	srv.Shutdown(shutdownCtx)
	log.Println("Server shut down gracefully")
}

Your server skeleton is complete. You have a WebSocket Hub that can broadcast to any number of connected clients, a router with all 9 endpoints mapped, and an entry point that ties everything together with graceful shutdown.

Next up, you will build the first diagnostic module: ICMP ping with real-time packet streaming.

Implement the Diagnostic Modules

Your server can accept HTTP requests and broadcast messages over WebSockets. Now it needs the engines that perform actual network diagnostics.

In this step, you will build five Go modules that probe the network in different ways. Each module streams its results through the WebSocket hub as data arrives, giving the frontend real-time updates instead of waiting for everything to finish.

In this step, get ready to:
  • Build the ICMP ping module with real-time per-packet streaming.
  • Build the UDP traceroute module with GeoIP enrichment for each hop.
  • Build the DNS benchmark, speed test, and intelligent issue classifier.
Download the Go dependencies

Your go.mod already declares all the third-party libraries. You need to download them before writing code that imports them.

  • Download all module dependencies by running this command from your netprobe project folder:
go mod download
  • You should see no error output. A go.sum file appears (or updates) in your project root confirming the downloaded checksums.

Seeing network errors?

Make sure you are connected to the internet and your Go proxy is reachable. Run go env GOPROXY to check. The default value https://proxy.golang.org,direct should work on most networks.

help me debug go mod download failures

Build the ICMP ping module

ICMP ping is the most fundamental network diagnostic. It sends small packets to a target and measures how long each takes to return.

The pro-bing library handles raw socket creation and packet parsing. Your module wraps it to stream each packet's round-trip time over WebSocket as it arrives.

  • Create the file internal/ping/ping.go inside your netprobe project folder and paste the following code:
package ping

import (
	"fmt"
	"time"

	probing "github.com/prometheus-community/pro-bing"
	"netprobe/internal/ws"
)

// Result holds the summary statistics after all pings complete
type Result struct {
	Target      string    `json:"target"`
	PacketsSent int       `json:"packets_sent"`
	PacketsRecv int       `json:"packets_recv"`
	PacketLoss  float64   `json:"packet_loss"`
	MinRtt      float64   `json:"min_rtt_ms"`
	AvgRtt      float64   `json:"avg_rtt_ms"`
	MaxRtt      float64   `json:"max_rtt_ms"`
	Jitter      float64   `json:"jitter_ms"`
	Rtts        []float64 `json:"rtts"`
}

// PacketEvent represents a single received packet streamed in real time
type PacketEvent struct {
	Seq int     `json:"seq"`
	Rtt float64 `json:"rtt_ms"`
	TTL int     `json:"ttl"`
}

// Run sends ICMP pings to the target and streams each reply over WebSocket
func Run(target string, count int, timeout time.Duration, hub *ws.Hub) (*Result, error) {
	pinger, err := probing.NewPinger(target)
	if err != nil {
		return nil, fmt.Errorf("failed to create pinger: %w", err)
	}

	// Configure ping parameters
	pinger.Count = count
	pinger.Timeout = timeout * time.Duration(count+2)
	pinger.Interval = 200 * time.Millisecond
	// Required for Docker containers and Linux without sysctl workaround
	pinger.SetPrivileged(true)

	var rtts []float64

	// Stream each packet as it arrives
	pinger.OnRecv = func(pkt *probing.Packet) {
		rttMs := float64(pkt.Rtt.Microseconds()) / 1000.0
		rtts = append(rtts, rttMs)
		hub.Broadcast(ws.Message{
			Type:   "ping_packet",
			Target: target,
			Data: PacketEvent{
				Seq: pkt.Seq,
				Rtt: rttMs,
				TTL: pkt.TTL,
			},
		})
	}

	if err := pinger.Run(); err != nil {
		return nil, fmt.Errorf("ping failed: %w", err)
	}

	// Collect final statistics
	stats := pinger.Statistics()

	result := &Result{
		Target:      target,
		PacketsSent: stats.PacketsSent,
		PacketsRecv: stats.PacketsRecv,
		PacketLoss:  stats.PacketLoss,
		MinRtt:      float64(stats.MinRtt.Microseconds()) / 1000.0,
		AvgRtt:      float64(stats.AvgRtt.Microseconds()) / 1000.0,
		MaxRtt:      float64(stats.MaxRtt.Microseconds()) / 1000.0,
		Jitter:      float64(stats.StdDevRtt.Microseconds()) / 1000.0,
		Rtts:        rtts,
	}

	// Broadcast the complete summary
	hub.Broadcast(ws.Message{Type: "ping_complete", Target: target, Data: result})
	return result, nil
}

What does this code do?

The Run function creates an ICMP pinger with probing.NewPinger(target) and configures it to send the specified number of packets at 200ms intervals.

SetPrivileged(true) is required because raw ICMP sockets need elevated permissions in Linux and Docker. The OnRecv callback fires for every returning packet, broadcasting its RTT and sequence number over WebSocket immediately. The frontend gets live data point by point instead of waiting for all 10 pings to finish.

  • Save internal/ping/ping.go.
  • Verify the package compiles by running:
go build ./internal/ping
  • You should see no output. No output means the package compiled successfully.

Seeing an import error?

If you see cannot find package, confirm you ran go mod download earlier. Also check that the import path matches exactly: github.com/prometheus-community/pro-bing with the alias probing.

help me fix Go import errors in my ping module

Build the traceroute module

Ping tells you how fast packets travel. Traceroute tells you the path they take to get there, revealing every router hop between your server and the target.

This module uses UDP-based traceroute (no root required) and enriches each hop with geographic coordinates from the free ip-api.com service. The frontend will plot these coordinates on a world map.

  • Create the file internal/traceroute/traceroute.go and paste the following code:
package traceroute

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	gotraceroute "github.com/kjansson/go-traceroute"
	"netprobe/internal/ws"
)

// Hop represents a single router along the path to the target
type Hop struct {
	TTL     int      `json:"ttl"`
	IP      string   `json:"ip"`
	Host    string   `json:"host"`
	Latency float64  `json:"latency_ms"`
	Geo     *GeoInfo `json:"geo,omitempty"`
}

// GeoInfo holds geographic and network ownership data for an IP
type GeoInfo struct {
	City    string  `json:"city"`
	Country string  `json:"country"`
	Lat     float64 `json:"lat"`
	Lon     float64 `json:"lon"`
	ISP     string  `json:"isp"`
	AS      string  `json:"as"`
}

// Result holds the complete traceroute with all hops
type Result struct {
	Target string `json:"target"`
	Hops   []Hop  `json:"hops"`
	Total  int    `json:"total_hops"`
}

// Run performs a UDP traceroute and streams each hop over WebSocket
func Run(target string, hub *ws.Hub) (*Result, error) {
	tracer := gotraceroute.New()
	tracer.Address = target
	tracer.MaxTTL = 30
	tracer.Timeout = 3 * time.Second
	tracer.DNSLookup = true

	traceResult, err := tracer.Trace()
	if err != nil {
		return nil, fmt.Errorf("traceroute failed: %w", err)
	}

	var hops []Hop
	for _, h := range traceResult.Hops {
		hop := Hop{
			TTL:     h.TTL,
			IP:      h.Address,
			Host:    h.Host,
			Latency: h.Latency,
		}

		// Look up geographic location for valid IPs
		if h.Address != "" && h.Address != "*" {
			geo, err := lookupGeo(h.Address)
			if err == nil {
				hop.Geo = geo
			}
		}

		hops = append(hops, hop)
		// Stream each hop to connected clients as it resolves
		hub.Broadcast(ws.Message{Type: "traceroute_hop", Target: target, Data: hop})
	}

	result := &Result{Target: target, Hops: hops, Total: len(hops)}
	hub.Broadcast(ws.Message{Type: "traceroute_complete", Target: target, Data: result})
	return result, nil
}

// lookupGeo calls ip-api.com to get lat/lon/city/country/ISP for an IP
func lookupGeo(ip string) (*GeoInfo, error) {
	resp, err := http.Get(fmt.Sprintf("http://ip-api.com/json/%s?fields=city,country,lat,lon,isp,as", ip))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var data struct {
		City    string  `json:"city"`
		Country string  `json:"country"`
		Lat     float64 `json:"lat"`
		Lon     float64 `json:"lon"`
		ISP     string  `json:"isp"`
		AS      string  `json:"as"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		return nil, err
	}

	return &GeoInfo{
		City: data.City, Country: data.Country,
		Lat: data.Lat, Lon: data.Lon,
		ISP: data.ISP, AS: data.AS,
	}, nil
}

What does this code do?

The Run function creates a UDP tracer with a max of 30 hops and a 3-second timeout per hop. It enables DNS lookup so each hop includes a hostname alongside its IP.

For each hop that returns a valid IP (not * for timed-out hops), lookupGeo calls the free ip-api.com endpoint to get latitude, longitude, city, country, ISP, and AS number. This geo data powers the world map visualization in the frontend.

What is ip-api.com?

ip-api.com is a free GeoIP lookup service. No API key or registration is needed. It allows 45 requests per minute, which is enough for traceroutes with up to 30 hops.

  • Save internal/traceroute/traceroute.go.
  • Verify it compiles by running:
go build ./internal/traceroute
  • No output means success.

Seeing a type mismatch?

Check that your import alias is gotraceroute (matching github.com/kjansson/go-traceroute). The library returns a TraceResult struct whose Hops field contains structs with TTL, Address, Host, and Latency fields.

help me fix my Go traceroute module compilation error

Build the DNS, speed test, and classifier modules

The remaining three modules complete the diagnostic suite. The DNS benchmark races multiple resolvers against each other. The speed test measures raw throughput. The classifier analyzes everything and tells the user what is actually wrong.

  • Create the file internal/dns/dns.go and paste the following code:
package dns

import (
	"fmt"
	"sort"
	"time"

	mdns "github.com/miekg/dns"
	"netprobe/internal/ws"
)

// ResolverResult holds benchmark data for a single DNS resolver
type ResolverResult struct {
	Name    string  `json:"name"`
	Address string  `json:"address"`
	AvgMs   float64 `json:"avg_ms"`
	P95Ms   float64 `json:"p95_ms"`
	Answers []string `json:"answers"`
}

// Result holds the comparison of all tested resolvers
type Result struct {
	Target    string           `json:"target"`
	Resolvers []ResolverResult `json:"resolvers"`
	Fastest   string           `json:"fastest"`
}

// resolvers to benchmark against
var resolvers = []struct {
	Name    string
	Address string
}{
	{"Google", "8.8.8.8:53"},
	{"Cloudflare", "1.1.1.1:53"},
	{"Quad9", "9.9.9.9:53"},
	{"OpenDNS", "208.67.222.222:53"},
}

// Benchmark queries the target domain against all resolvers and ranks them
func Benchmark(target string, hub *ws.Hub) (*Result, error) {
	var results []ResolverResult

	for _, r := range resolvers {
		rr, err := benchmarkResolver(target, r.Name, r.Address)
		if err != nil {
			continue
		}
		results = append(results, *rr)
		// Stream each resolver result as it completes
		hub.Broadcast(ws.Message{Type: "dns_result", Target: target, Data: rr})
	}

	if len(results) == 0 {
		return nil, fmt.Errorf("all resolvers failed")
	}

	// Sort by average response time to find the fastest
	sort.Slice(results, func(i, j int) bool {
		return results[i].AvgMs < results[j].AvgMs
	})

	result := &Result{
		Target:    target,
		Resolvers: results,
		Fastest:   results[0].Name,
	}

	hub.Broadcast(ws.Message{Type: "dns_complete", Target: target, Data: result})
	return result, nil
}

// benchmarkResolver runs 5 queries against a single resolver and computes avg/p95
func benchmarkResolver(target, name, address string) (*ResolverResult, error) {
	client := &mdns.Client{Timeout: 5 * time.Second}
	msg := &mdns.Msg{}
	msg.SetQuestion(mdns.Fqdn(target), mdns.TypeA)

	var times []float64
	var answers []string

	// Run 5 queries to get a stable average
	for i := 0; i < 5; i++ {
		start := time.Now()
		resp, _, err := client.Exchange(msg, address)
		elapsed := time.Since(start)

		if err != nil {
			continue
		}

		times = append(times, float64(elapsed.Microseconds())/1000.0)

		// Capture the resolved IPs from the first successful query
		if i == 0 && resp != nil {
			for _, a := range resp.Answer {
				if aRecord, ok := a.(*mdns.A); ok {
					answers = append(answers, aRecord.A.String())
				}
			}
		}
	}

	if len(times) == 0 {
		return nil, fmt.Errorf("resolver %s failed all queries", name)
	}

	// Calculate average and p95 latency
	sort.Float64s(times)
	avg := 0.0
	for _, t := range times {
		avg += t
	}
	avg /= float64(len(times))

	p95Idx := int(float64(len(times)) * 0.95)
	if p95Idx >= len(times) {
		p95Idx = len(times) - 1
	}

	return &ResolverResult{
		Name:    name,
		Address: address,
		AvgMs:   avg,
		P95Ms:   times[p95Idx],
		Answers: answers,
	}, nil
}

What does this code do?

The DNS benchmark queries the same domain against four popular public resolvers: Google (8.8.8.8), Cloudflare (1.1.1.1), Quad9 (9.9.9.9), and OpenDNS (208.67.222.222). Each resolver gets 5 queries so the average is stable.

It also captures which IPs each resolver returns. Different resolvers can return different IPs for the same domain, indicating CDN steering or DNS-based load balancing.

  • Save internal/dns/dns.go.
  • Verify it compiles by running:
go build ./internal/dns
  • No output means the DNS package compiled cleanly.

Seeing a naming conflict?

The package name is dns which conflicts with the miekg/dns import. Make sure you use the import alias mdns for the library to avoid the collision.

help me resolve this Go DNS package naming conflict

  • Create the file internal/speedtest/speedtest.go and paste the following code:
package speedtest

import (
	"fmt"
	"io"
	"net/http"
	"time"

	"netprobe/internal/ws"
)

// Result holds the final speed test measurements
type Result struct {
	DownloadMbps float64 `json:"download_mbps"`
	DurationMs   float64 `json:"duration_ms"`
	BytesRead    int64   `json:"bytes_read"`
}

// Progress is streamed to the frontend during download
type Progress struct {
	Percent     int     `json:"percent"`
	CurrentMbps float64 `json:"current_mbps"`
}

// Run downloads a 10MB file from Cloudflare and measures throughput
func Run(hub *ws.Hub) (*Result, error) {
	url := "https://speed.cloudflare.com/__down?bytes=10000000"

	start := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		return nil, fmt.Errorf("speed test download failed: %w", err)
	}
	defer resp.Body.Close()

	buf := make([]byte, 32*1024)
	var totalBytes int64
	lastReport := start

	for {
		n, err := resp.Body.Read(buf)
		totalBytes += int64(n)

		// Stream progress every 200ms so the UI updates smoothly
		if time.Since(lastReport) > 200*time.Millisecond {
			elapsed := time.Since(start).Seconds()
			currentMbps := (float64(totalBytes) * 8) / (elapsed * 1_000_000)
			percent := int((float64(totalBytes) / 10_000_000) * 100)
			if percent > 100 {
				percent = 100
			}

			hub.Broadcast(ws.Message{
				Type: "speedtest_progress",
				Data: Progress{Percent: percent, CurrentMbps: currentMbps},
			})
			lastReport = time.Now()
		}

		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("read error: %w", err)
		}
	}

	// Calculate final throughput
	elapsed := time.Since(start)
	mbps := (float64(totalBytes) * 8) / (elapsed.Seconds() * 1_000_000)

	result := &Result{
		DownloadMbps: mbps,
		DurationMs:   float64(elapsed.Milliseconds()),
		BytesRead:    totalBytes,
	}

	hub.Broadcast(ws.Message{Type: "speedtest_complete", Data: result})
	return result, nil
}

What does this code do?

The speed test downloads a 10MB file from Cloudflare's free speed test endpoint. It reads the response body in 32KB chunks, measuring how many bytes arrive over time.

Every 200 milliseconds, it broadcasts a progress update with the current download speed in Mbps and percentage complete. This gives the frontend a live speed gauge instead of a single number at the end.

  • Save internal/speedtest/speedtest.go.
  • Verify it compiles by running:
go build ./internal/speedtest
  • No output means success.
  • Create the file internal/classifier/classifier.go and paste the following code:
package classifier

import (
	"netprobe/internal/dns"
	"netprobe/internal/ping"
	"netprobe/internal/speedtest"
	"netprobe/internal/traceroute"
)

// Diagnosis is the final verdict explaining what is wrong with the connection
type Diagnosis struct {
	Category    string                 `json:"category"`
	Confidence  float64                `json:"confidence"`
	Explanation string                 `json:"explanation"`
	Details     map[string]interface{} `json:"details"`
}

// Classify analyzes all diagnostic results and returns a root cause diagnosis
func Classify(pingResult *ping.Result, traceResult *traceroute.Result, dnsResult *dns.Result, speedResult *speedtest.Result) *Diagnosis {
	// No data means we cannot classify
	if pingResult == nil {
		return &Diagnosis{Category: "Unknown", Confidence: 0, Explanation: "Insufficient data to classify the issue."}
	}

	// Priority 1: Slow DNS resolution
	if dnsResult != nil && dnsResult.Resolvers[0].AvgMs > 100 {
		return &Diagnosis{
			Category:    "DNS",
			Confidence:  0.85,
			Explanation: "DNS resolution is slow. Your configured resolver is taking too long to look up domain names.",
			Details: map[string]interface{}{
				"slowest_resolver_ms": dnsResult.Resolvers[len(dnsResult.Resolvers)-1].AvgMs,
				"fastest_resolver":    dnsResult.Fastest,
			},
		}
	}

	// Priority 2: High packet loss
	if pingResult.PacketLoss > 5 {
		return &Diagnosis{
			Category:    "Packet Loss",
			Confidence:  0.9,
			Explanation: "Significant packet loss detected. This usually indicates Wi-Fi interference, a congested link, or ISP issues.",
			Details: map[string]interface{}{
				"loss_percent": pingResult.PacketLoss,
				"packets_lost": pingResult.PacketsSent - pingResult.PacketsRecv,
			},
		}
	}

	// Priority 3: Local network issues (high first-hop latency)
	if traceResult != nil && len(traceResult.Hops) > 0 {
		firstHopLatency := traceResult.Hops[0].Latency
		if firstHopLatency > 10 {
			return &Diagnosis{
				Category:    "Wi-Fi",
				Confidence:  0.8,
				Explanation: "High latency on the first hop suggests a local network issue, likely Wi-Fi congestion or a weak signal.",
				Details: map[string]interface{}{
					"first_hop_ms": firstHopLatency,
					"first_hop_ip": traceResult.Hops[0].IP,
				},
			}
		}

		// Priority 4: Mid-path routing bottleneck
		for i := 1; i < len(traceResult.Hops)-1; i++ {
			if traceResult.Hops[i].Latency > 100 && traceResult.Hops[i].Latency > traceResult.Hops[i-1].Latency*3 {
				return &Diagnosis{
					Category:    "Routing",
					Confidence:  0.75,
					Explanation: "A significant latency spike was detected mid-path, suggesting a congested or poorly optimized route.",
					Details: map[string]interface{}{
						"bottleneck_hop": traceResult.Hops[i].TTL,
						"bottleneck_ip":  traceResult.Hops[i].IP,
						"bottleneck_isp": traceResult.Hops[i].Geo,
						"latency_jump":   traceResult.Hops[i].Latency - traceResult.Hops[i-1].Latency,
					},
				}
			}
		}
	}

	// Priority 5: General high latency (ISP congestion)
	if pingResult.AvgRtt > 100 {
		return &Diagnosis{
			Category:    "ISP",
			Confidence:  0.7,
			Explanation: "Overall latency is high without a clear single bottleneck. This often indicates ISP congestion or geographic distance.",
			Details: map[string]interface{}{
				"avg_rtt_ms": pingResult.AvgRtt,
				"jitter_ms":  pingResult.Jitter,
			},
		}
	}

	// Priority 6: Low speed despite good latency (server-side throttling)
	if speedResult != nil && speedResult.DownloadMbps < 5 && pingResult.AvgRtt < 50 {
		return &Diagnosis{
			Category:    "Server-Side",
			Confidence:  0.65,
			Explanation: "Low throughput despite good latency suggests the server is throttling connections or is under heavy load.",
			Details: map[string]interface{}{
				"download_mbps": speedResult.DownloadMbps,
				"latency_ms":    pingResult.AvgRtt,
			},
		}
	}

	// Fallback: Everything looks healthy
	return &Diagnosis{
		Category:    "Healthy",
		Confidence:  0.8,
		Explanation: "No significant issues detected. Your connection looks good.",
		Details: map[string]interface{}{
			"avg_rtt_ms":    pingResult.AvgRtt,
			"packet_loss":   pingResult.PacketLoss,
			"download_mbps": speedResult.DownloadMbps,
		},
	}
}

How does the classifier work?

The classifier uses a priority-based decision tree. It checks for the most specific, high-confidence issues first (DNS too slow, packet loss over 5%) before falling back to less certain diagnoses (ISP congestion, server-side throttling).

Each diagnosis includes a confidence score and a human-readable explanation. The frontend uses these to display color-coded issue cards that tell users exactly what is wrong and why.

  • Save internal/classifier/classifier.go.
  • Verify all five modules compile together by running:
go build ./internal/...
  • You should see no output. All five packages (ping, traceroute, dns, speedtest, classifier) compiled without errors.

Seeing errors from the classifier?

The classifier imports all four other diagnostic packages. If any of them have syntax errors, the classifier will fail too. Fix errors in ping, traceroute, dns, or speedtest first, then retry.

Also verify that your folder structure matches: each module lives in its own directory under internal/ (for example, internal/classifier/classifier.go, not internal/classifier.go).

help me debug Go compilation errors across multiple internal packages

✔️ Awesome, I've got everything!

Double check you have saved all five files before moving on.

ⓧ I'd like to double check the full code

Here are all five module files for reference.

package ping

import (
	"fmt"
	"time"

	probing "github.com/prometheus-community/pro-bing"
	"netprobe/internal/ws"
)

type Result struct {
	Target     string    `json:"target"`
	PacketsSent int      `json:"packets_sent"`
	PacketsRecv int      `json:"packets_recv"`
	PacketLoss float64   `json:"packet_loss"`
	MinRtt     float64   `json:"min_rtt_ms"`
	AvgRtt     float64   `json:"avg_rtt_ms"`
	MaxRtt     float64   `json:"max_rtt_ms"`
	Jitter     float64   `json:"jitter_ms"`
	Rtts       []float64 `json:"rtts"`
}

type PacketEvent struct {
	Seq int     `json:"seq"`
	Rtt float64 `json:"rtt_ms"`
	TTL int     `json:"ttl"`
}

func Run(target string, count int, timeout time.Duration, hub *ws.Hub) (*Result, error) {
	pinger, err := probing.NewPinger(target)
	if err != nil {
		return nil, fmt.Errorf("failed to create pinger: %w", err)
	}

	pinger.Count = count
	pinger.Timeout = timeout * time.Duration(count+2)
	pinger.Interval = 200 * time.Millisecond
	pinger.SetPrivileged(true)

	var rtts []float64

	pinger.OnRecv = func(pkt *probing.Packet) {
		rttMs := float64(pkt.Rtt.Microseconds()) / 1000.0
		rtts = append(rtts, rttMs)
		hub.Broadcast(ws.Message{
			Type:   "ping_packet",
			Target: target,
			Data: PacketEvent{
				Seq: pkt.Seq,
				Rtt: rttMs,
				TTL: pkt.TTL,
			},
		})
	}

	if err := pinger.Run(); err != nil {
		return nil, fmt.Errorf("ping failed: %w", err)
	}

	stats := pinger.Statistics()

	result := &Result{
		Target:      target,
		PacketsSent: stats.PacketsSent,
		PacketsRecv: stats.PacketsRecv,
		PacketLoss:  stats.PacketLoss,
		MinRtt:      float64(stats.MinRtt.Microseconds()) / 1000.0,
		AvgRtt:      float64(stats.AvgRtt.Microseconds()) / 1000.0,
		MaxRtt:      float64(stats.MaxRtt.Microseconds()) / 1000.0,
		Jitter:      float64(stats.StdDevRtt.Microseconds()) / 1000.0,
		Rtts:        rtts,
	}

	hub.Broadcast(ws.Message{Type: "ping_complete", Target: target, Data: result})
	return result, nil
}
package traceroute

import (
	"encoding/json"
	"fmt"
	"net/http"
	"time"

	gotraceroute "github.com/kjansson/go-traceroute"
	"netprobe/internal/ws"
)

type Hop struct {
	TTL     int     `json:"ttl"`
	IP      string  `json:"ip"`
	Host    string  `json:"host"`
	Latency float64 `json:"latency_ms"`
	Geo     *GeoInfo `json:"geo,omitempty"`
}

type GeoInfo struct {
	City    string  `json:"city"`
	Country string  `json:"country"`
	Lat     float64 `json:"lat"`
	Lon     float64 `json:"lon"`
	ISP     string  `json:"isp"`
	AS      string  `json:"as"`
}

type Result struct {
	Target string `json:"target"`
	Hops   []Hop  `json:"hops"`
	Total  int    `json:"total_hops"`
}

func Run(target string, hub *ws.Hub) (*Result, error) {
	tracer := gotraceroute.New()
	tracer.Address = target
	tracer.MaxTTL = 30
	tracer.Timeout = 3 * time.Second
	tracer.DNSLookup = true

	traceResult, err := tracer.Trace()
	if err != nil {
		return nil, fmt.Errorf("traceroute failed: %w", err)
	}

	var hops []Hop
	for _, h := range traceResult.Hops {
		hop := Hop{
			TTL:     h.TTL,
			IP:      h.Address,
			Host:    h.Host,
			Latency: h.Latency,
		}

		if h.Address != "" && h.Address != "*" {
			geo, err := lookupGeo(h.Address)
			if err == nil {
				hop.Geo = geo
			}
		}

		hops = append(hops, hop)
		hub.Broadcast(ws.Message{Type: "traceroute_hop", Target: target, Data: hop})
	}

	result := &Result{Target: target, Hops: hops, Total: len(hops)}
	hub.Broadcast(ws.Message{Type: "traceroute_complete", Target: target, Data: result})
	return result, nil
}

func lookupGeo(ip string) (*GeoInfo, error) {
	resp, err := http.Get(fmt.Sprintf("http://ip-api.com/json/%s?fields=city,country,lat,lon,isp,as", ip))
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	var data struct {
		City    string  `json:"city"`
		Country string  `json:"country"`
		Lat     float64 `json:"lat"`
		Lon     float64 `json:"lon"`
		ISP     string  `json:"isp"`
		AS      string  `json:"as"`
	}
	if err := json.NewDecoder(resp.Body).Decode(&data); err != nil {
		return nil, err
	}

	return &GeoInfo{
		City: data.City, Country: data.Country,
		Lat: data.Lat, Lon: data.Lon,
		ISP: data.ISP, AS: data.AS,
	}, nil
}
package dns

import (
	"fmt"
	"sort"
	"time"

	mdns "github.com/miekg/dns"
	"netprobe/internal/ws"
)

type ResolverResult struct {
	Name    string  `json:"name"`
	Address string  `json:"address"`
	AvgMs   float64 `json:"avg_ms"`
	P95Ms   float64 `json:"p95_ms"`
	Answers []string `json:"answers"`
}

type Result struct {
	Target    string           `json:"target"`
	Resolvers []ResolverResult `json:"resolvers"`
	Fastest   string           `json:"fastest"`
}

var resolvers = []struct {
	Name    string
	Address string
}{
	{"Google", "8.8.8.8:53"},
	{"Cloudflare", "1.1.1.1:53"},
	{"Quad9", "9.9.9.9:53"},
	{"OpenDNS", "208.67.222.222:53"},
}

func Benchmark(target string, hub *ws.Hub) (*Result, error) {
	var results []ResolverResult

	for _, r := range resolvers {
		rr, err := benchmarkResolver(target, r.Name, r.Address)
		if err != nil {
			continue
		}
		results = append(results, *rr)
		hub.Broadcast(ws.Message{Type: "dns_result", Target: target, Data: rr})
	}

	if len(results) == 0 {
		return nil, fmt.Errorf("all resolvers failed")
	}

	sort.Slice(results, func(i, j int) bool {
		return results[i].AvgMs < results[j].AvgMs
	})

	result := &Result{
		Target:    target,
		Resolvers: results,
		Fastest:   results[0].Name,
	}

	hub.Broadcast(ws.Message{Type: "dns_complete", Target: target, Data: result})
	return result, nil
}

func benchmarkResolver(target, name, address string) (*ResolverResult, error) {
	client := &mdns.Client{Timeout: 5 * time.Second}
	msg := &mdns.Msg{}
	msg.SetQuestion(mdns.Fqdn(target), mdns.TypeA)

	var times []float64
	var answers []string

	for i := 0; i < 5; i++ {
		start := time.Now()
		resp, _, err := client.Exchange(msg, address)
		elapsed := time.Since(start)

		if err != nil {
			continue
		}

		times = append(times, float64(elapsed.Microseconds())/1000.0)

		if i == 0 && resp != nil {
			for _, a := range resp.Answer {
				if aRecord, ok := a.(*mdns.A); ok {
					answers = append(answers, aRecord.A.String())
				}
			}
		}
	}

	if len(times) == 0 {
		return nil, fmt.Errorf("resolver %s failed all queries", name)
	}

	sort.Float64s(times)
	avg := 0.0
	for _, t := range times {
		avg += t
	}
	avg /= float64(len(times))

	p95Idx := int(float64(len(times)) * 0.95)
	if p95Idx >= len(times) {
		p95Idx = len(times) - 1
	}

	return &ResolverResult{
		Name:    name,
		Address: address,
		AvgMs:   avg,
		P95Ms:   times[p95Idx],
		Answers: answers,
	}, nil
}
package speedtest

import (
	"fmt"
	"io"
	"net/http"
	"time"

	"netprobe/internal/ws"
)

type Result struct {
	DownloadMbps float64 `json:"download_mbps"`
	DurationMs   float64 `json:"duration_ms"`
	BytesRead    int64   `json:"bytes_read"`
}

type Progress struct {
	Percent      int     `json:"percent"`
	CurrentMbps  float64 `json:"current_mbps"`
}

func Run(hub *ws.Hub) (*Result, error) {
	url := "https://speed.cloudflare.com/__down?bytes=10000000"

	start := time.Now()
	resp, err := http.Get(url)
	if err != nil {
		return nil, fmt.Errorf("speed test download failed: %w", err)
	}
	defer resp.Body.Close()

	buf := make([]byte, 32*1024)
	var totalBytes int64
	lastReport := start

	for {
		n, err := resp.Body.Read(buf)
		totalBytes += int64(n)

		if time.Since(lastReport) > 200*time.Millisecond {
			elapsed := time.Since(start).Seconds()
			currentMbps := (float64(totalBytes) * 8) / (elapsed * 1_000_000)
			percent := int((float64(totalBytes) / 10_000_000) * 100)
			if percent > 100 {
				percent = 100
			}

			hub.Broadcast(ws.Message{
				Type: "speedtest_progress",
				Data: Progress{Percent: percent, CurrentMbps: currentMbps},
			})
			lastReport = time.Now()
		}

		if err == io.EOF {
			break
		}
		if err != nil {
			return nil, fmt.Errorf("read error: %w", err)
		}
	}

	elapsed := time.Since(start)
	mbps := (float64(totalBytes) * 8) / (elapsed.Seconds() * 1_000_000)

	result := &Result{
		DownloadMbps: mbps,
		DurationMs:   float64(elapsed.Milliseconds()),
		BytesRead:    totalBytes,
	}

	hub.Broadcast(ws.Message{Type: "speedtest_complete", Data: result})
	return result, nil
}
package classifier

import (
	"netprobe/internal/dns"
	"netprobe/internal/ping"
	"netprobe/internal/speedtest"
	"netprobe/internal/traceroute"
)

type Diagnosis struct {
	Category    string  `json:"category"`
	Confidence  float64 `json:"confidence"`
	Explanation string  `json:"explanation"`
	Details     map[string]interface{} `json:"details"`
}

func Classify(pingResult *ping.Result, traceResult *traceroute.Result, dnsResult *dns.Result, speedResult *speedtest.Result) *Diagnosis {
	if pingResult == nil {
		return &Diagnosis{Category: "Unknown", Confidence: 0, Explanation: "Insufficient data to classify the issue."}
	}

	if dnsResult != nil && dnsResult.Resolvers[0].AvgMs > 100 {
		return &Diagnosis{
			Category:    "DNS",
			Confidence:  0.85,
			Explanation: "DNS resolution is slow. Your configured resolver is taking too long to look up domain names.",
			Details: map[string]interface{}{
				"slowest_resolver_ms": dnsResult.Resolvers[len(dnsResult.Resolvers)-1].AvgMs,
				"fastest_resolver":    dnsResult.Fastest,
			},
		}
	}

	if pingResult.PacketLoss > 5 {
		return &Diagnosis{
			Category:    "Packet Loss",
			Confidence:  0.9,
			Explanation: "Significant packet loss detected. This usually indicates Wi-Fi interference, a congested link, or ISP issues.",
			Details: map[string]interface{}{
				"loss_percent": pingResult.PacketLoss,
				"packets_lost": pingResult.PacketsSent - pingResult.PacketsRecv,
			},
		}
	}

	if traceResult != nil && len(traceResult.Hops) > 0 {
		firstHopLatency := traceResult.Hops[0].Latency
		if firstHopLatency > 10 {
			return &Diagnosis{
				Category:    "Wi-Fi",
				Confidence:  0.8,
				Explanation: "High latency on the first hop suggests a local network issue, likely Wi-Fi congestion or a weak signal.",
				Details: map[string]interface{}{
					"first_hop_ms": firstHopLatency,
					"first_hop_ip": traceResult.Hops[0].IP,
				},
			}
		}

		for i := 1; i < len(traceResult.Hops)-1; i++ {
			if traceResult.Hops[i].Latency > 100 && traceResult.Hops[i].Latency > traceResult.Hops[i-1].Latency*3 {
				return &Diagnosis{
					Category:    "Routing",
					Confidence:  0.75,
					Explanation: "A significant latency spike was detected mid-path, suggesting a congested or poorly optimized route.",
					Details: map[string]interface{}{
						"bottleneck_hop": traceResult.Hops[i].TTL,
						"bottleneck_ip":  traceResult.Hops[i].IP,
						"bottleneck_isp": traceResult.Hops[i].Geo,
						"latency_jump":   traceResult.Hops[i].Latency - traceResult.Hops[i-1].Latency,
					},
				}
			}
		}
	}

	if pingResult.AvgRtt > 100 {
		return &Diagnosis{
			Category:    "ISP",
			Confidence:  0.7,
			Explanation: "Overall latency is high without a clear single bottleneck. This often indicates ISP congestion or geographic distance.",
			Details: map[string]interface{}{
				"avg_rtt_ms": pingResult.AvgRtt,
				"jitter_ms":  pingResult.Jitter,
			},
		}
	}

	if speedResult != nil && speedResult.DownloadMbps < 5 && pingResult.AvgRtt < 50 {
		return &Diagnosis{
			Category:    "Server-Side",
			Confidence:  0.65,
			Explanation: "Low throughput despite good latency suggests the server is throttling connections or is under heavy load.",
			Details: map[string]interface{}{
				"download_mbps": speedResult.DownloadMbps,
				"latency_ms":    pingResult.AvgRtt,
			},
		}
	}

	return &Diagnosis{
		Category:    "Healthy",
		Confidence:  0.8,
		Explanation: "No significant issues detected. Your connection looks good.",
		Details: map[string]interface{}{
			"avg_rtt_ms":    pingResult.AvgRtt,
			"packet_loss":   pingResult.PacketLoss,
			"download_mbps": speedResult.DownloadMbps,
		},
	}
}

Your diagnostic engines are ready. They can ping, trace routes, benchmark DNS, measure speed, and classify issues. Next up, you will wire these modules into the HTTP handlers and database so the API endpoints actually return results.

Add PostgreSQL, API Handlers, and Background Monitor

Your diagnostic modules can ping, traceroute, benchmark DNS, estimate speed, and classify issues. Right now, every result disappears the moment the request ends.

In this step, you will wire up PostgreSQL for persistent storage, build the HTTP handler layer that ties all your modules together, and add a background monitor that continuously probes targets so you have historical trend data.

In this step, get ready to:
  • Create the database package with connection pooling and auto-migration.
  • Build all API handler functions that call your diagnostic modules and persist results.
  • Implement a background goroutine that pings targets every 60 seconds and stores latency data.
Create the database connection package

The database package handles connecting to PostgreSQL and creating the tables your application needs. It uses the lib/pq driver that is already in your go.mod.

  • Create a new file called internal/db/db.go inside your netprobe project folder.
  • Add the Connect function by pasting this code:
package db

import (
	"database/sql"
	"fmt"

	_ "github.com/lib/pq"
)

// Connect opens a PostgreSQL connection and configures the pool
func Connect(databaseURL string) (*sql.DB, error) {
	if databaseURL == "" {
		databaseURL = "postgres://netprobe:netprobe@localhost:5432/netprobe?sslmode=disable"
	}

	db, err := sql.Open("postgres", databaseURL)
	if err != nil {
		return nil, fmt.Errorf("failed to open database: %w", err)
	}

	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("failed to ping database: %w", err)
	}

	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)

	return db, nil
}

What does this code do?

The blank import _ "github.com/lib/pq" registers the PostgreSQL driver with Go's database/sql package without directly calling any of its functions.

If no DATABASE_URL is provided, it falls back to a default local connection string. The connection pool is capped at 10 open connections and 5 idle connections to avoid exhausting PostgreSQL.

  • Now add the Migrate function below the Connect function in the same file:
// Migrate creates the required tables and indexes
func Migrate(db *sql.DB) error {
	schema := `
	CREATE TABLE IF NOT EXISTS diagnostic_runs (
		id SERIAL PRIMARY KEY,
		test_type VARCHAR(50) NOT NULL,
		target VARCHAR(255) NOT NULL,
		result JSONB NOT NULL,
		created_at TIMESTAMP DEFAULT NOW()
	);

	CREATE TABLE IF NOT EXISTS latency_series (
		id SERIAL PRIMARY KEY,
		target VARCHAR(255) NOT NULL,
		avg_rtt DOUBLE PRECISION NOT NULL,
		packet_loss DOUBLE PRECISION NOT NULL,
		created_at TIMESTAMP DEFAULT NOW()
	);

	CREATE INDEX IF NOT EXISTS idx_runs_target ON diagnostic_runs(target);
	CREATE INDEX IF NOT EXISTS idx_runs_created ON diagnostic_runs(created_at);
	CREATE INDEX IF NOT EXISTS idx_series_target ON latency_series(target, created_at);
	`

	_, err := db.Exec(schema)
	return err
}

What does this code do?

  • diagnostic_runs stores every test result (ping, traceroute, DNS, diagnosis) as JSONB for flexible querying.
  • latency_series stores time-series ping data (avg RTT and packet loss) collected by the background monitor for trend charts.
  • The three indexes speed up queries that filter by target or sort by creation time.
  • Save internal/db/db.go.

You will not see a change yet. The database package will be used when you wire up handlers next.

✔️ Awesome, I've got everything!

Great. Double check you have saved the file before moving on.

ⓧ I'd like to double check the full code

package db

import (
	"database/sql"
	"fmt"

	_ "github.com/lib/pq"
)

// Connect opens a PostgreSQL connection and configures the pool
func Connect(databaseURL string) (*sql.DB, error) {
	if databaseURL == "" {
		databaseURL = "postgres://netprobe:netprobe@localhost:5432/netprobe?sslmode=disable"
	}

	db, err := sql.Open("postgres", databaseURL)
	if err != nil {
		return nil, fmt.Errorf("failed to open database: %w", err)
	}

	if err := db.Ping(); err != nil {
		return nil, fmt.Errorf("failed to ping database: %w", err)
	}

	db.SetMaxOpenConns(10)
	db.SetMaxIdleConns(5)

	return db, nil
}

// Migrate creates the required tables and indexes
func Migrate(db *sql.DB) error {
	schema := `
	CREATE TABLE IF NOT EXISTS diagnostic_runs (
		id SERIAL PRIMARY KEY,
		test_type VARCHAR(50) NOT NULL,
		target VARCHAR(255) NOT NULL,
		result JSONB NOT NULL,
		created_at TIMESTAMP DEFAULT NOW()
	);

	CREATE TABLE IF NOT EXISTS latency_series (
		id SERIAL PRIMARY KEY,
		target VARCHAR(255) NOT NULL,
		avg_rtt DOUBLE PRECISION NOT NULL,
		packet_loss DOUBLE PRECISION NOT NULL,
		created_at TIMESTAMP DEFAULT NOW()
	);

	CREATE INDEX IF NOT EXISTS idx_runs_target ON diagnostic_runs(target);
	CREATE INDEX IF NOT EXISTS idx_runs_created ON diagnostic_runs(created_at);
	CREATE INDEX IF NOT EXISTS idx_series_target ON latency_series(target, created_at);
	`

	_, err := db.Exec(schema)
	return err
}
Build the API handler functions

Your router already defines all the routes. Now you need the handler functions that decode requests, call the correct diagnostic module, save results to PostgreSQL, and return JSON responses.

  • Create a new file called internal/api/handlers.go in the same internal/api folder as your router.
  • Start with the imports, request struct, and the first handler to establish the pattern:
package api

import (
	"database/sql"
	"encoding/json"
	"net/http"
	"time"

	"netprobe/internal/classifier"
	"netprobe/internal/dns"
	"netprobe/internal/ping"
	"netprobe/internal/speedtest"
	"netprobe/internal/traceroute"
	"netprobe/internal/ws"
)

// TargetRequest is the JSON body for diagnostic endpoints
type TargetRequest struct {
	Target string `json:"target"`
}

// handlePing runs ICMP ping against the target and saves results
func handlePing(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := ping.Run(req.Target, 10, time.Second, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "ping", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

What does this code do?

Each handler is a closure that captures the hub and database dependencies and returns an http.HandlerFunc. This pattern avoids global variables while keeping your router clean.

The flow is: decode the JSON body, call the diagnostic module (which streams results via WebSocket), persist the final result to PostgreSQL, then return the result as the HTTP response.

  • Add the traceroute, DNS, and speed test handlers below. They follow the same decode-run-save-respond pattern:
// handleTraceroute runs UDP traceroute with GeoIP lookup
func handleTraceroute(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := traceroute.Run(req.Target, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "traceroute", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

// handleDNS benchmarks multiple DNS resolvers
func handleDNS(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := dns.Benchmark(req.Target, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "dns", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

// handleSpeedTest runs a download speed test
func handleSpeedTest(hub *ws.Hub) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		result, err := speedtest.Run(hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

Why doesn't handleSpeedTest take a database parameter?

The speed test measures your server's download throughput. It is not target-specific, so there is no meaningful way to store it against a domain name. The result still streams via WebSocket for real-time UI updates.

  • Add the diagnose handler next. This is the orchestrator that runs all four modules and then classifies the issue:
// handleDiagnose runs all diagnostic modules and classifies the issue
func handleDiagnose(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		// Run all diagnostic modules in sequence
		pingResult, _ := ping.Run(req.Target, 10, time.Second, hub)
		traceResult, _ := traceroute.Run(req.Target, hub)
		dnsResult, _ := dns.Benchmark(req.Target, hub)
		speedResult, _ := speedtest.Run(hub)

		// Feed all results into the classifier
		diagnosis := classifier.Classify(pingResult, traceResult, dnsResult, speedResult)

		hub.Broadcast(ws.Message{Type: "diagnosis", Target: req.Target, Data: diagnosis})
		saveResult(database, "diagnosis", req.Target, diagnosis)

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(diagnosis)
	}
}

What does this code do?

This is the "full diagnostic" endpoint. It calls ping, traceroute, DNS, and speed test in sequence, then passes all results to the classifier. Each module broadcasts its own streaming results over WebSocket as it runs, so the frontend updates in real time.

Individual module errors are intentionally ignored (using _ for the error). The classifier handles nil results gracefully, so a failed traceroute does not block the diagnosis.

  • Finally, add the history and trends query handlers plus the saveResult helper at the bottom of the file:
// handleHistory returns the last 50 diagnostic runs
func handleHistory(database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := database.Query(
			"SELECT id, test_type, target, result, created_at FROM diagnostic_runs ORDER BY created_at DESC LIMIT 50",
		)
		if err != nil {
			http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		var runs []map[string]interface{}
		for rows.Next() {
			var id int
			var testType, target, result string
			var createdAt time.Time
			rows.Scan(&id, &testType, &target, &result, &createdAt)
			runs = append(runs, map[string]interface{}{
				"id": id, "type": testType, "target": target,
				"result": json.RawMessage(result), "created_at": createdAt,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(runs)
	}
}

// handleTrends returns time-series latency data for trend charts
func handleTrends(database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		target := r.URL.Query().Get("target")
		if target == "" {
			target = "google.com"
		}

		rows, err := database.Query(
			"SELECT avg_rtt, packet_loss, created_at FROM latency_series WHERE target = $1 ORDER BY created_at DESC LIMIT 100",
			target,
		)
		if err != nil {
			http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		var points []map[string]interface{}
		for rows.Next() {
			var avgRtt, packetLoss float64
			var createdAt time.Time
			rows.Scan(&avgRtt, &packetLoss, &createdAt)
			points = append(points, map[string]interface{}{
				"avg_rtt": avgRtt, "packet_loss": packetLoss, "timestamp": createdAt,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(points)
	}
}

// saveResult persists a diagnostic result to the database
func saveResult(database *sql.DB, testType, target string, result interface{}) {
	data, _ := json.Marshal(result)
	database.Exec(
		"INSERT INTO diagnostic_runs (test_type, target, result) VALUES ($1, $2, $3)",
		testType, target, string(data),
	)
}

What does this code do?

  • handleHistory queries the last 50 diagnostic runs and returns them as JSON. It uses json.RawMessage to avoid double-encoding the JSONB result column.
  • handleTrends pulls the last 100 latency data points for a given target (defaulting to google.com). The frontend will chart these over time.
  • saveResult is a fire-and-forget helper that marshals any result to JSON and inserts it. Errors are silently ignored to avoid blocking the HTTP response.
  • Save internal/api/handlers.go.

You will not see a runtime change yet. The next chunk adds the monitor, and then you will compile the full project to confirm everything wires together.

✔️ Awesome, I've got everything!

Great. Make sure you have saved the file before continuing.

ⓧ I'd like to double check the full code

package api

import (
	"database/sql"
	"encoding/json"
	"net/http"
	"time"

	"netprobe/internal/classifier"
	"netprobe/internal/dns"
	"netprobe/internal/ping"
	"netprobe/internal/speedtest"
	"netprobe/internal/traceroute"
	"netprobe/internal/ws"
)

type TargetRequest struct {
	Target string `json:"target"`
}

func handlePing(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := ping.Run(req.Target, 10, time.Second, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "ping", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

func handleTraceroute(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := traceroute.Run(req.Target, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "traceroute", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

func handleDNS(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		result, err := dns.Benchmark(req.Target, hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		saveResult(database, "dns", req.Target, result)
		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

func handleSpeedTest(hub *ws.Hub) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		result, err := speedtest.Run(hub)
		if err != nil {
			http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusInternalServerError)
			return
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(result)
	}
}

func handleDiagnose(hub *ws.Hub, database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		var req TargetRequest
		if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
			http.Error(w, `{"error":"invalid request"}`, http.StatusBadRequest)
			return
		}

		pingResult, _ := ping.Run(req.Target, 10, time.Second, hub)
		traceResult, _ := traceroute.Run(req.Target, hub)
		dnsResult, _ := dns.Benchmark(req.Target, hub)
		speedResult, _ := speedtest.Run(hub)

		diagnosis := classifier.Classify(pingResult, traceResult, dnsResult, speedResult)

		hub.Broadcast(ws.Message{Type: "diagnosis", Target: req.Target, Data: diagnosis})
		saveResult(database, "diagnosis", req.Target, diagnosis)

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(diagnosis)
	}
}

func handleHistory(database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		rows, err := database.Query(
			"SELECT id, test_type, target, result, created_at FROM diagnostic_runs ORDER BY created_at DESC LIMIT 50",
		)
		if err != nil {
			http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		var runs []map[string]interface{}
		for rows.Next() {
			var id int
			var testType, target, result string
			var createdAt time.Time
			rows.Scan(&id, &testType, &target, &result, &createdAt)
			runs = append(runs, map[string]interface{}{
				"id": id, "type": testType, "target": target,
				"result": json.RawMessage(result), "created_at": createdAt,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(runs)
	}
}

func handleTrends(database *sql.DB) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		target := r.URL.Query().Get("target")
		if target == "" {
			target = "google.com"
		}

		rows, err := database.Query(
			"SELECT avg_rtt, packet_loss, created_at FROM latency_series WHERE target = $1 ORDER BY created_at DESC LIMIT 100",
			target,
		)
		if err != nil {
			http.Error(w, `{"error":"database error"}`, http.StatusInternalServerError)
			return
		}
		defer rows.Close()

		var points []map[string]interface{}
		for rows.Next() {
			var avgRtt, packetLoss float64
			var createdAt time.Time
			rows.Scan(&avgRtt, &packetLoss, &createdAt)
			points = append(points, map[string]interface{}{
				"avg_rtt": avgRtt, "packet_loss": packetLoss, "timestamp": createdAt,
			})
		}

		w.Header().Set("Content-Type", "application/json")
		json.NewEncoder(w).Encode(points)
	}
}

func saveResult(database *sql.DB, testType, target string, result interface{}) {
	data, _ := json.Marshal(result)
	database.Exec(
		"INSERT INTO diagnostic_runs (test_type, target, result) VALUES ($1, $2, $3)",
		testType, target, string(data),
	)
}
Implement the background monitor

A diagnostics tool is most useful when it has historical data to compare against. The background monitor runs pings every 60 seconds against a set of well-known targets and writes the results into the latency_series table. This builds a baseline over time so your frontend can display trend charts.

  • Create a new file called internal/monitor/monitor.go inside your netprobe project folder.
  • Paste the full monitor implementation:
package monitor

import (
	"database/sql"
	"log"
	"time"

	probing "github.com/prometheus-community/pro-bing"
	"netprobe/internal/ws"
)

// Monitor runs periodic pings and stores results for trend data
type Monitor struct {
	hub      *ws.Hub
	database *sql.DB
	targets  []string
}

// New creates a monitor with default targets
func New(hub *ws.Hub, database *sql.DB) *Monitor {
	return &Monitor{
		hub:      hub,
		database: database,
		targets:  []string{"google.com", "cloudflare.com", "amazon.com"},
	}
}

// Start begins the periodic monitoring loop
func (m *Monitor) Start() {
	ticker := time.NewTicker(60 * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		for _, target := range m.targets {
			go m.probe(target)
		}
	}
}

// probe pings a single target and inserts the result
func (m *Monitor) probe(target string) {
	pinger, err := probing.NewPinger(target)
	if err != nil {
		log.Printf("Monitor ping error for %s: %v", target, err)
		return
	}

	pinger.Count = 5
	pinger.Timeout = 10 * time.Second
	pinger.SetPrivileged(true)

	if err := pinger.Run(); err != nil {
		log.Printf("Monitor ping run error for %s: %v", target, err)
		return
	}

	stats := pinger.Statistics()
	avgRtt := float64(stats.AvgRtt.Microseconds()) / 1000.0

	_, err = m.database.Exec(
		"INSERT INTO latency_series (target, avg_rtt, packet_loss) VALUES ($1, $2, $3)",
		target, avgRtt, stats.PacketLoss,
	)
	if err != nil {
		log.Printf("Monitor db insert error: %v", err)
	}
}

What does this code do?

  • New() creates a monitor pre-configured to probe google.com, cloudflare.com, and amazon.com. These represent different major cloud providers, giving a useful baseline.
  • Start() ticks every 60 seconds and launches a goroutine for each target. This means all three probes run concurrently without blocking each other.
  • probe() sends 5 ICMP pings in privileged mode, computes the average RTT in milliseconds, and inserts a row into latency_series.
  • Save internal/monitor/monitor.go.
  • Verify that the entire project compiles by running this command from your netprobe folder:
go build ./cmd/server

If the command exits silently with no output, your code compiles successfully. All packages resolve and all function signatures match.

Seeing a compile error?

  • If you see undefined: handlePing or similar, make sure handlers.go is in the internal/api folder and has package api at the top.
  • If you see imported and not used, check that all import paths in handlers.go match the module path in your go.mod (should be netprobe/internal/...).
  • If you see cannot find package, run go mod tidy to ensure all dependencies are downloaded.

Help me debug this Go compile error

✔️ Awesome, I've got everything!

Great. Make sure all three new files are saved.

ⓧ I'd like to double check the full code

package monitor

import (
	"database/sql"
	"log"
	"time"

	probing "github.com/prometheus-community/pro-bing"
	"netprobe/internal/ws"
)

type Monitor struct {
	hub      *ws.Hub
	database *sql.DB
	targets  []string
}

func New(hub *ws.Hub, database *sql.DB) *Monitor {
	return &Monitor{
		hub:      hub,
		database: database,
		targets:  []string{"google.com", "cloudflare.com", "amazon.com"},
	}
}

func (m *Monitor) Start() {
	ticker := time.NewTicker(60 * time.Second)
	defer ticker.Stop()

	for range ticker.C {
		for _, target := range m.targets {
			go m.probe(target)
		}
	}
}

func (m *Monitor) probe(target string) {
	pinger, err := probing.NewPinger(target)
	if err != nil {
		log.Printf("Monitor ping error for %s: %v", target, err)
		return
	}

	pinger.Count = 5
	pinger.Timeout = 10 * time.Second
	pinger.SetPrivileged(true)

	if err := pinger.Run(); err != nil {
		log.Printf("Monitor ping run error for %s: %v", target, err)
		return
	}

	stats := pinger.Statistics()
	avgRtt := float64(stats.AvgRtt.Microseconds()) / 1000.0

	_, err = m.database.Exec(
		"INSERT INTO latency_series (target, avg_rtt, packet_loss) VALUES ($1, $2, $3)",
		target, avgRtt, stats.PacketLoss,
	)
	if err != nil {
		log.Printf("Monitor db insert error: %v", err)
	}
}

Your backend is now feature-complete. It has real diagnostic modules, persistent storage, historical monitoring, and a full REST API. Next up, you will build the Next.js frontend that visualizes all of this data in real time.

Build the Next.js Dashboard and Components

Your Go backend can now run every diagnostic module, classify issues, store results in PostgreSQL, and stream everything over WebSockets.

Now you need a frontend that makes all that data visible. In this step, you will build a Next.js dashboard with real-time charts, an interactive world map, and a network topology graph. The frontend connects to your Go server via WebSocket and updates every panel live as diagnostic results stream in.

In this step, get ready to:
  • Scaffold a Next.js project with Tailwind CSS and TypeScript.
  • Build the main page and a Dashboard component that streams data over WebSocket.
  • Create visualization components using Recharts, MapLibre GL, and D3.js.
Scaffold the Next.js project

The frontend lives in its own folder inside the netprobe project. You will create it manually so every dependency version is pinned exactly.

  • Inside your netprobe project folder, create the frontend directory and move into it by running these commands:
mkdir netprobe-ui
cd netprobe-ui
  • Create the package.json file that defines your frontend dependencies by running:
{
  "name": "netprobe-ui",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^15.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "recharts": "^2.15.0",
    "maplibre-gl": "^4.7.0",
    "d3": "^7.9.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/d3": "^7.4.0",
    "typescript": "^5.7.0",
    "tailwindcss": "^3.4.0",
    "postcss": "^8.4.0",
    "autoprefixer": "^10.4.0"
  }
}

What are these dependencies?

  • next and react power the app framework and UI rendering.
  • recharts draws line charts for ping RTT data.
  • maplibre-gl renders the interactive world map for traceroute hops.
  • d3 builds the force-directed network topology graph.
  • tailwindcss provides utility-first styling for the dark theme.
  • Save this as package.json in your netprobe-ui folder.
  • Install all the dependencies by running:
npm install
  • You should see a node_modules folder appear and a package-lock.json file created.

npm install failing?

Make sure you have Node.js 18+ installed. Next.js 15 requires it.

Check that your package.json is valid JSON with no trailing commas.

Help me fix my npm install error in the netprobe-ui project

Now create the configuration files that Next.js and Tailwind CSS need to run.

  • Create next.config.js in the netprobe-ui folder with this content:
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;
  • Create tailwind.config.ts to tell Tailwind which files to scan for class names:
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

export default config;

What does the content array do?

Tailwind scans these file paths to find class names you actually use. It then strips out all unused CSS in production, keeping your bundle tiny.

  • Create the app directory inside netprobe-ui by running:
mkdir app
  • Create app/globals.css with the Tailwind directives and base styles:
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  font-family: system-ui, -apple-system, sans-serif;
}

.maplibregl-map {
  border-radius: 0.75rem;
}
  • Create app/layout.tsx to define the HTML shell with a dark theme:
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "NetProbe - Internet Diagnostics",
  description: "Production-grade internet diagnostics platform",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className="dark">
      <body className="bg-gray-950 text-gray-100 min-h-screen">{children}</body>
    </html>
  );
}

What does layout.tsx do?

This is the root layout that wraps every page. It imports your global CSS, sets the page title, and applies the dark background color to the entire app.

  • Save all four files (next.config.js, tailwind.config.ts, app/globals.css, app/layout.tsx).
  • Confirm your folder structure by running:
ls app
  • You should see globals.css and layout.tsx in the output.
Build the main page and Dashboard component

The main page provides the target input bar and a Diagnose button. When the user submits a target, it renders a Dashboard component that handles all the real-time data.

  • Create app/page.tsx with the home page component:
"use client";

import { useState } from "react";
import Dashboard from "@/components/Dashboard";

export default function Home() {
  const [target, setTarget] = useState("");
  const [isRunning, setIsRunning] = useState(false);
  const [activeTarget, setActiveTarget] = useState("");

  const handleRun = () => {
    if (!target.trim()) return;
    setActiveTarget(target.trim());
    setIsRunning(true);
  };

  return (
    <main className="max-w-7xl mx-auto px-4 py-8">
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-emerald-400 mb-2">NetProbe</h1>
        <p className="text-gray-400">Internet Diagnostics Platform</p>
      </header>

      <div className="flex gap-3 mb-8">
        <input
          type="text"
          value={target}
          onChange={(e) => setTarget(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleRun()}
          placeholder="Enter target (e.g. google.com, discord.gg)"
          className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-lg focus:outline-none focus:border-emerald-500"
        />
        <button
          onClick={handleRun}
          disabled={isRunning}
          className="bg-emerald-600 hover:bg-emerald-500 disabled:bg-gray-700 px-8 py-3 rounded-lg font-semibold text-lg transition-colors"
        >
          {isRunning ? "Running..." : "Diagnose"}
        </button>
      </div>

      {activeTarget && (
        <Dashboard target={activeTarget} onComplete={() => setIsRunning(false)} />
      )}
    </main>
  );
}

What does this component do?

The Home component manages three pieces of state: what the user typed (target), whether a diagnostic is in progress (isRunning), and the confirmed target being diagnosed (activeTarget).

When activeTarget is set, the Dashboard component mounts and connects to the backend via WebSocket.

  • Save app/page.tsx.

Now build the Dashboard component. This is the brain of the frontend. It opens a WebSocket connection, dispatches incoming messages to the correct panel, and triggers the full diagnostic run via an HTTP POST.

  • Create a components directory inside netprobe-ui by running:
mkdir components
  • Create components/Dashboard.tsx with the WebSocket connection and grid layout:
"use client";

import { useEffect, useRef, useState } from "react";
import PingChart from "./PingChart";
import TracerouteMap from "./TracerouteMap";
import DnsBenchmark from "./DnsBenchmark";
import NetworkTopology from "./NetworkTopology";
import IssuePanel from "./IssuePanel";

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";

interface DashboardProps {
  target: string;
  onComplete: () => void;
}

export default function Dashboard({ target, onComplete }: DashboardProps) {
  const [pingData, setPingData] = useState<any>(null);
  const [traceData, setTraceData] = useState<any>(null);
  const [dnsData, setDnsData] = useState<any>(null);
  const [diagnosis, setDiagnosis] = useState<any>(null);
  const [pingPackets, setPingPackets] = useState<any[]>([]);
  const [traceHops, setTraceHops] = useState<any[]>([]);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const wsUrl = API_URL.replace("http", "ws") + "/ws";
    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      switch (msg.type) {
        case "ping_packet":
          setPingPackets((prev) => [...prev, msg.data]);
          break;
        case "ping_complete":
          setPingData(msg.data);
          break;
        case "traceroute_hop":
          setTraceHops((prev) => [...prev, msg.data]);
          break;
        case "traceroute_complete":
          setTraceData(msg.data);
          break;
        case "dns_complete":
          setDnsData(msg.data);
          break;
        case "diagnosis":
          setDiagnosis(msg.data);
          onComplete();
          break;
      }
    };

    return () => ws.close();
  }, [target]);

  useEffect(() => {
    setPingPackets([]);
    setTraceHops([]);
    setPingData(null);
    setTraceData(null);
    setDnsData(null);
    setDiagnosis(null);

    fetch(`${API_URL}/api/diagnose`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ target }),
    });
  }, [target]);

  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Ping</h2>
        <PingChart packets={pingPackets} result={pingData} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">DNS Benchmark</h2>
        <DnsBenchmark data={dnsData} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800 lg:col-span-2">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Traceroute Map</h2>
        <TracerouteMap hops={traceHops} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Network Topology</h2>
        <NetworkTopology hops={traceHops} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Diagnosis</h2>
        <IssuePanel diagnosis={diagnosis} />
      </div>
    </div>
  );
}

How does the real-time streaming work?

The first useEffect opens a WebSocket to your Go backend. As the backend runs diagnostics, it broadcasts messages with types like ping_packet and traceroute_hop. The switch statement routes each message to the correct state setter.

The second useEffect resets all state and fires the HTTP POST to /api/diagnose. The backend then runs all modules, streaming results through the WebSocket as they complete.

  • Save components/Dashboard.tsx.

Seeing import errors in your editor?

Your editor will show errors for the five component imports (PingChart, TracerouteMap, etc.) because those files do not exist yet. This is expected. You will create them in the next substep.

Help me understand why my Dashboard imports are red-underlined

Build the visualization components

Each diagnostic module gets its own visualization component. You will create five components: a ping chart, a world map, a DNS comparison, a network graph, and a diagnosis panel.

  • Create components/PingChart.tsx with the Recharts line chart:
"use client";

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";

interface PingChartProps {
  packets: { seq: number; rtt: number; ttl: number }[];
  result: any;
}

export default function PingChart({ packets, result }: PingChartProps) {
  if (packets.length === 0) {
    return <p className="text-gray-500">Waiting for ping data...</p>;
  }

  const chartData = packets.map((p) => ({
    seq: p.seq,
    rtt: Math.round(p.rtt * 100) / 100,
  }));

  return (
    <div>
      <ResponsiveContainer width="100%" height={200}>
        <LineChart data={chartData}>
          <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
          <XAxis dataKey="seq" stroke="#9ca3af" />
          <YAxis stroke="#9ca3af" unit="ms" />
          <Tooltip
            contentStyle={{ backgroundColor: "#1f2937", border: "1px solid #374151" }}
          />
          <Line type="monotone" dataKey="rtt" stroke="#10b981" strokeWidth={2} dot={false} />
        </LineChart>
      </ResponsiveContainer>

      {result && (
        <div className="grid grid-cols-4 gap-4 mt-4 text-sm">
          <div>
            <p className="text-gray-400">Avg</p>
            <p className="text-lg font-semibold">{result.avg_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Min</p>
            <p className="text-lg font-semibold">{result.min_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Max</p>
            <p className="text-lg font-semibold">{result.max_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Loss</p>
            <p className={`text-lg font-semibold ${result.packet_loss > 0 ? "text-red-400" : "text-emerald-400"}`}>
              {result.packet_loss.toFixed(1)}%
            </p>
          </div>
        </div>
      )}
    </div>
  );
}

What does PingChart render?

As ping packets arrive over WebSocket, the chart plots each RTT value on a green line. Once the ping completes, the summary stats grid appears below showing average, min, max, and packet loss. Loss turns red if any packets were dropped.

  • Save components/PingChart.tsx.
  • Create components/TracerouteMap.tsx with the MapLibre GL world map:
"use client";

import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

interface Hop {
  ttl: number;
  ip: string;
  host: string;
  latency_ms: number;
  geo?: { lat: number; lon: number; city: string; country: string; isp: string };
}

export default function TracerouteMap({ hops }: { hops: Hop[] }) {
  const mapContainer = useRef<HTMLDivElement>(null);
  const map = useRef<maplibregl.Map | null>(null);

  useEffect(() => {
    if (!mapContainer.current || map.current) return;

    map.current = new maplibregl.Map({
      container: mapContainer.current,
      style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
      center: [0, 20],
      zoom: 1.5,
    });

    return () => {
      map.current?.remove();
      map.current = null;
    };
  }, []);

  useEffect(() => {
    if (!map.current) return;

    const geoHops = hops.filter((h) => h.geo && h.geo.lat !== 0);
    if (geoHops.length < 2) return;

    const existingMarkers = document.querySelectorAll(".hop-marker");
    existingMarkers.forEach((m) => m.remove());

    const coordinates: [number, number][] = geoHops.map((h) => [h.geo!.lon, h.geo!.lat]);

    if (map.current.getSource("route")) {
      (map.current.getSource("route") as maplibregl.GeoJSONSource).setData({
        type: "Feature",
        properties: {},
        geometry: { type: "LineString", coordinates },
      });
    } else {
      map.current.addSource("route", {
        type: "geojson",
        data: {
          type: "Feature",
          properties: {},
          geometry: { type: "LineString", coordinates },
        },
      });
      map.current.addLayer({
        id: "route",
        type: "line",
        source: "route",
        layout: { "line-join": "round", "line-cap": "round" },
        paint: { "line-color": "#10b981", "line-width": 3, "line-opacity": 0.8 },
      });
    }

    geoHops.forEach((hop) => {
      const el = document.createElement("div");
      el.className = "hop-marker";
      el.style.width = "12px";
      el.style.height = "12px";
      el.style.borderRadius = "50%";
      el.style.backgroundColor = hop.latency_ms > 100 ? "#ef4444" : "#10b981";
      el.style.border = "2px solid white";

      new maplibregl.Marker({ element: el })
        .setLngLat([hop.geo!.lon, hop.geo!.lat])
        .setPopup(
          new maplibregl.Popup({ offset: 10 }).setHTML(
            `<div style="color:#000"><strong>Hop ${hop.ttl}</strong><br/>
            ${hop.geo!.city}, ${hop.geo!.country}<br/>
            ${hop.ip} (${hop.host})<br/>
            Latency: ${hop.latency_ms.toFixed(1)}ms<br/>
            ISP: ${hop.geo!.isp}</div>`
          )
        )
        .addTo(map.current!);
    });

    const bounds = new maplibregl.LngLatBounds();
    coordinates.forEach((c) => bounds.extend(c));
    map.current.fitBounds(bounds, { padding: 50 });
  }, [hops]);

  return <div ref={mapContainer} style={{ height: "400px", width: "100%" }} />;
}

How does the map update in real time?

The first useEffect initializes a MapLibre GL map with a dark tile style from CARTO. The second useEffect runs every time the hops array changes. It draws a green line connecting hop coordinates and places markers that turn red when latency exceeds 100ms.

  • Save components/TracerouteMap.tsx.
  • Create components/DnsBenchmark.tsx to display the resolver comparison:
"use client";

interface DnsBenchmarkProps {
  data: {
    resolvers: { name: string; address: string; avg_ms: number; p95_ms: number; answers: string[] }[];
    fastest: string;
  } | null;
}

export default function DnsBenchmark({ data }: DnsBenchmarkProps) {
  if (!data) {
    return <p className="text-gray-500">Waiting for DNS benchmark...</p>;
  }

  const maxMs = Math.max(...data.resolvers.map((r) => r.avg_ms));

  return (
    <div className="space-y-3">
      {data.resolvers.map((resolver) => (
        <div key={resolver.name} className="space-y-1">
          <div className="flex justify-between text-sm">
            <span className={resolver.name === data.fastest ? "text-emerald-400 font-semibold" : ""}>
              {resolver.name} {resolver.name === data.fastest && "\u26A1"}
            </span>
            <span className="text-gray-400">{resolver.avg_ms.toFixed(1)}ms</span>
          </div>
          <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
            <div
              className={`h-full rounded-full ${resolver.name === data.fastest ? "bg-emerald-500" : "bg-blue-500"}`}
              style={{ width: `${(resolver.avg_ms / maxMs) * 100}%` }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}

What does this component show?

Each DNS resolver (Google, Cloudflare, Quad9, OpenDNS) gets a horizontal bar proportional to its response time. The fastest resolver is highlighted in emerald green with a lightning bolt icon.

  • Save components/DnsBenchmark.tsx.
  • Create components/NetworkTopology.tsx with the D3.js force-directed graph:
"use client";

import { useEffect, useRef } from "react";
import * as d3 from "d3";

interface Hop {
  ttl: number;
  ip: string;
  host: string;
  latency_ms: number;
  geo?: { city: string; country: string; isp: string };
}

export default function NetworkTopology({ hops }: { hops: Hop[] }) {
  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    if (!svgRef.current || hops.length === 0) return;

    const width = svgRef.current.clientWidth;
    const height = 300;

    d3.select(svgRef.current).selectAll("*").remove();

    const svg = d3.select(svgRef.current).attr("width", width).attr("height", height);

    const nodes = hops.map((h, i) => ({
      id: h.ip || `hop-${h.ttl}`,
      label: h.host || h.ip || `*`,
      latency: h.latency_ms,
      ttl: h.ttl,
      type: i === 0 ? "source" : i === hops.length - 1 ? "destination" : "hop",
    }));

    const links = nodes.slice(0, -1).map((_, i) => ({
      source: nodes[i].id,
      target: nodes[i + 1].id,
      latency: nodes[i + 1].latency,
    }));

    const simulation = d3
      .forceSimulation(nodes as any)
      .force("link", d3.forceLink(links).id((d: any) => d.id).distance(60))
      .force("charge", d3.forceManyBody().strength(-100))
      .force("x", d3.forceX(width / 2).strength(0.1))
      .force("y", d3.forceY(height / 2).strength(0.3));

    const link = svg
      .selectAll("line")
      .data(links)
      .enter()
      .append("line")
      .attr("stroke", "#4b5563")
      .attr("stroke-width", 2);

    const node = svg
      .selectAll("circle")
      .data(nodes)
      .enter()
      .append("circle")
      .attr("r", (d) => (d.type === "source" || d.type === "destination" ? 10 : 7))
      .attr("fill", (d) => {
        if (d.type === "source") return "#10b981";
        if (d.type === "destination") return "#f59e0b";
        if (d.latency > 100) return "#ef4444";
        return "#6366f1";
      });

    const label = svg
      .selectAll("text")
      .data(nodes)
      .enter()
      .append("text")
      .text((d) => d.label.substring(0, 15))
      .attr("font-size", "10px")
      .attr("fill", "#9ca3af")
      .attr("dx", 12)
      .attr("dy", 4);

    simulation.on("tick", () => {
      link
        .attr("x1", (d: any) => d.source.x)
        .attr("y1", (d: any) => d.source.y)
        .attr("x2", (d: any) => d.target.x)
        .attr("y2", (d: any) => d.target.y);
      node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y);
      label.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y);
    });
  }, [hops]);

  if (hops.length === 0) {
    return <p className="text-gray-500">Waiting for traceroute data...</p>;
  }

  return <svg ref={svgRef} className="w-full" style={{ height: "300px" }} />;
}

How does the topology graph work?

D3's force simulation positions nodes based on link distance and charge repulsion. Each hop becomes a node, connected by links. Nodes are color-coded: green for your server (source), gold for the destination, red for high-latency hops, and indigo for normal intermediate hops.

  • Save components/NetworkTopology.tsx.
  • Create the final component components/IssuePanel.tsx for the diagnosis display:
"use client";

const categoryColors: Record<string, string> = {
  DNS: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30",
  "Packet Loss": "text-red-400 bg-red-400/10 border-red-400/30",
  "Wi-Fi": "text-orange-400 bg-orange-400/10 border-orange-400/30",
  Routing: "text-purple-400 bg-purple-400/10 border-purple-400/30",
  ISP: "text-blue-400 bg-blue-400/10 border-blue-400/30",
  "Server-Side": "text-pink-400 bg-pink-400/10 border-pink-400/30",
  Healthy: "text-emerald-400 bg-emerald-400/10 border-emerald-400/30",
  Unknown: "text-gray-400 bg-gray-400/10 border-gray-400/30",
};

interface IssuePanelProps {
  diagnosis: {
    category: string;
    confidence: number;
    explanation: string;
    details: Record<string, any>;
  } | null;
}

export default function IssuePanel({ diagnosis }: IssuePanelProps) {
  if (!diagnosis) {
    return <p className="text-gray-500">Running diagnostics...</p>;
  }

  const colors = categoryColors[diagnosis.category] || categoryColors.Unknown;

  return (
    <div className={`rounded-lg border p-4 ${colors}`}>
      <div className="flex items-center justify-between mb-2">
        <span className="text-lg font-bold">{diagnosis.category}</span>
        <span className="text-sm opacity-70">
          {Math.round(diagnosis.confidence * 100)}% confidence
        </span>
      </div>
      <p className="text-sm opacity-90 mb-3">{diagnosis.explanation}</p>
      {diagnosis.details && (
        <div className="text-xs opacity-70 space-y-1">
          {Object.entries(diagnosis.details).map(([key, value]) => (
            <div key={key}>
              <span className="font-mono">{key}</span>: {JSON.stringify(value)}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

What does the IssuePanel display?

The panel renders a color-coded card based on the issue category returned by your Go classifier. DNS issues appear in yellow, packet loss in red, Wi-Fi in orange, and a healthy connection in green. Each card shows the confidence percentage and supporting data points.

  • Save components/IssuePanel.tsx.
  • Start the development server to verify everything compiles by running:
npm run dev
  • Open http://localhost:3000 in your browser. You should see the dark-themed NetProbe header with a text input and a green Diagnose button.

Page not loading or showing errors?

Check your terminal for compilation errors. The most common issue is a typo in one of the component file names. The imports in Dashboard.tsx must match the exact file names you created (PingChart, TracerouteMap, DnsBenchmark, NetworkTopology, IssuePanel).

If you see a CSS error, confirm that globals.css starts with the three @tailwind directives.

Help me debug my Next.js frontend compilation error

✔️ Awesome, I've got everything!

Great. Double check you have saved all files, then stop the dev server with Ctrl+C when you are ready to continue.

ⓧ I'd like to double check the full code

Here is each file for comparison. Make sure your code matches exactly.

{
  "name": "netprobe-ui",
  "version": "1.0.0",
  "private": true,
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "next": "^15.1.0",
    "react": "^19.0.0",
    "react-dom": "^19.0.0",
    "recharts": "^2.15.0",
    "maplibre-gl": "^4.7.0",
    "d3": "^7.9.0"
  },
  "devDependencies": {
    "@types/react": "^19.0.0",
    "@types/d3": "^7.4.0",
    "typescript": "^5.7.0",
    "tailwindcss": "^3.4.0",
    "postcss": "^8.4.0",
    "autoprefixer": "^10.4.0"
  }
}
/** @type {import('next').NextConfig} */
const nextConfig = {};
module.exports = nextConfig;
import type { Config } from "tailwindcss";

const config: Config = {
  content: [
    "./app/**/*.{js,ts,jsx,tsx}",
    "./components/**/*.{js,ts,jsx,tsx}",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
};

export default config;
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
  font-family: system-ui, -apple-system, sans-serif;
}

.maplibregl-map {
  border-radius: 0.75rem;
}
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "NetProbe - Internet Diagnostics",
  description: "Production-grade internet diagnostics platform",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en" className="dark">
      <body className="bg-gray-950 text-gray-100 min-h-screen">{children}</body>
    </html>
  );
}
"use client";

import { useState } from "react";
import Dashboard from "@/components/Dashboard";

export default function Home() {
  const [target, setTarget] = useState("");
  const [isRunning, setIsRunning] = useState(false);
  const [activeTarget, setActiveTarget] = useState("");

  const handleRun = () => {
    if (!target.trim()) return;
    setActiveTarget(target.trim());
    setIsRunning(true);
  };

  return (
    <main className="max-w-7xl mx-auto px-4 py-8">
      <header className="mb-8">
        <h1 className="text-4xl font-bold text-emerald-400 mb-2">NetProbe</h1>
        <p className="text-gray-400">Internet Diagnostics Platform</p>
      </header>

      <div className="flex gap-3 mb-8">
        <input
          type="text"
          value={target}
          onChange={(e) => setTarget(e.target.value)}
          onKeyDown={(e) => e.key === "Enter" && handleRun()}
          placeholder="Enter target (e.g. google.com, discord.gg)"
          className="flex-1 bg-gray-900 border border-gray-700 rounded-lg px-4 py-3 text-lg focus:outline-none focus:border-emerald-500"
        />
        <button
          onClick={handleRun}
          disabled={isRunning}
          className="bg-emerald-600 hover:bg-emerald-500 disabled:bg-gray-700 px-8 py-3 rounded-lg font-semibold text-lg transition-colors"
        >
          {isRunning ? "Running..." : "Diagnose"}
        </button>
      </div>

      {activeTarget && (
        <Dashboard target={activeTarget} onComplete={() => setIsRunning(false)} />
      )}
    </main>
  );
}
"use client";

import { useEffect, useRef, useState } from "react";
import PingChart from "./PingChart";
import TracerouteMap from "./TracerouteMap";
import DnsBenchmark from "./DnsBenchmark";
import NetworkTopology from "./NetworkTopology";
import IssuePanel from "./IssuePanel";

const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080";

interface DashboardProps {
  target: string;
  onComplete: () => void;
}

export default function Dashboard({ target, onComplete }: DashboardProps) {
  const [pingData, setPingData] = useState<any>(null);
  const [traceData, setTraceData] = useState<any>(null);
  const [dnsData, setDnsData] = useState<any>(null);
  const [diagnosis, setDiagnosis] = useState<any>(null);
  const [pingPackets, setPingPackets] = useState<any[]>([]);
  const [traceHops, setTraceHops] = useState<any[]>([]);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const wsUrl = API_URL.replace("http", "ws") + "/ws";
    const ws = new WebSocket(wsUrl);
    wsRef.current = ws;

    ws.onmessage = (event) => {
      const msg = JSON.parse(event.data);
      switch (msg.type) {
        case "ping_packet":
          setPingPackets((prev) => [...prev, msg.data]);
          break;
        case "ping_complete":
          setPingData(msg.data);
          break;
        case "traceroute_hop":
          setTraceHops((prev) => [...prev, msg.data]);
          break;
        case "traceroute_complete":
          setTraceData(msg.data);
          break;
        case "dns_complete":
          setDnsData(msg.data);
          break;
        case "diagnosis":
          setDiagnosis(msg.data);
          onComplete();
          break;
      }
    };

    return () => ws.close();
  }, [target]);

  useEffect(() => {
    setPingPackets([]);
    setTraceHops([]);
    setPingData(null);
    setTraceData(null);
    setDnsData(null);
    setDiagnosis(null);

    fetch(`${API_URL}/api/diagnose`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ target }),
    });
  }, [target]);

  return (
    <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Ping</h2>
        <PingChart packets={pingPackets} result={pingData} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">DNS Benchmark</h2>
        <DnsBenchmark data={dnsData} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800 lg:col-span-2">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Traceroute Map</h2>
        <TracerouteMap hops={traceHops} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Network Topology</h2>
        <NetworkTopology hops={traceHops} />
      </div>

      <div className="bg-gray-900 rounded-xl p-6 border border-gray-800">
        <h2 className="text-xl font-semibold mb-4 text-emerald-400">Diagnosis</h2>
        <IssuePanel diagnosis={diagnosis} />
      </div>
    </div>
  );
}
"use client";

import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";

interface PingChartProps {
  packets: { seq: number; rtt: number; ttl: number }[];
  result: any;
}

export default function PingChart({ packets, result }: PingChartProps) {
  if (packets.length === 0) {
    return <p className="text-gray-500">Waiting for ping data...</p>;
  }

  const chartData = packets.map((p) => ({
    seq: p.seq,
    rtt: Math.round(p.rtt * 100) / 100,
  }));

  return (
    <div>
      <ResponsiveContainer width="100%" height={200}>
        <LineChart data={chartData}>
          <CartesianGrid strokeDasharray="3 3" stroke="#374151" />
          <XAxis dataKey="seq" stroke="#9ca3af" />
          <YAxis stroke="#9ca3af" unit="ms" />
          <Tooltip
            contentStyle={{ backgroundColor: "#1f2937", border: "1px solid #374151" }}
          />
          <Line type="monotone" dataKey="rtt" stroke="#10b981" strokeWidth={2} dot={false} />
        </LineChart>
      </ResponsiveContainer>

      {result && (
        <div className="grid grid-cols-4 gap-4 mt-4 text-sm">
          <div>
            <p className="text-gray-400">Avg</p>
            <p className="text-lg font-semibold">{result.avg_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Min</p>
            <p className="text-lg font-semibold">{result.min_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Max</p>
            <p className="text-lg font-semibold">{result.max_rtt_ms.toFixed(1)}ms</p>
          </div>
          <div>
            <p className="text-gray-400">Loss</p>
            <p className={`text-lg font-semibold ${result.packet_loss > 0 ? "text-red-400" : "text-emerald-400"}`}>
              {result.packet_loss.toFixed(1)}%
            </p>
          </div>
        </div>
      )}
    </div>
  );
}
"use client";

import { useEffect, useRef } from "react";
import maplibregl from "maplibre-gl";
import "maplibre-gl/dist/maplibre-gl.css";

interface Hop {
  ttl: number;
  ip: string;
  host: string;
  latency_ms: number;
  geo?: { lat: number; lon: number; city: string; country: string; isp: string };
}

export default function TracerouteMap({ hops }: { hops: Hop[] }) {
  const mapContainer = useRef<HTMLDivElement>(null);
  const map = useRef<maplibregl.Map | null>(null);

  useEffect(() => {
    if (!mapContainer.current || map.current) return;

    map.current = new maplibregl.Map({
      container: mapContainer.current,
      style: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json",
      center: [0, 20],
      zoom: 1.5,
    });

    return () => {
      map.current?.remove();
      map.current = null;
    };
  }, []);

  useEffect(() => {
    if (!map.current) return;

    const geoHops = hops.filter((h) => h.geo && h.geo.lat !== 0);
    if (geoHops.length < 2) return;

    const existingMarkers = document.querySelectorAll(".hop-marker");
    existingMarkers.forEach((m) => m.remove());

    const coordinates: [number, number][] = geoHops.map((h) => [h.geo!.lon, h.geo!.lat]);

    if (map.current.getSource("route")) {
      (map.current.getSource("route") as maplibregl.GeoJSONSource).setData({
        type: "Feature",
        properties: {},
        geometry: { type: "LineString", coordinates },
      });
    } else {
      map.current.addSource("route", {
        type: "geojson",
        data: {
          type: "Feature",
          properties: {},
          geometry: { type: "LineString", coordinates },
        },
      });
      map.current.addLayer({
        id: "route",
        type: "line",
        source: "route",
        layout: { "line-join": "round", "line-cap": "round" },
        paint: { "line-color": "#10b981", "line-width": 3, "line-opacity": 0.8 },
      });
    }

    geoHops.forEach((hop) => {
      const el = document.createElement("div");
      el.className = "hop-marker";
      el.style.width = "12px";
      el.style.height = "12px";
      el.style.borderRadius = "50%";
      el.style.backgroundColor = hop.latency_ms > 100 ? "#ef4444" : "#10b981";
      el.style.border = "2px solid white";

      new maplibregl.Marker({ element: el })
        .setLngLat([hop.geo!.lon, hop.geo!.lat])
        .setPopup(
          new maplibregl.Popup({ offset: 10 }).setHTML(
            `<div style="color:#000"><strong>Hop ${hop.ttl}</strong><br/>
            ${hop.geo!.city}, ${hop.geo!.country}<br/>
            ${hop.ip} (${hop.host})<br/>
            Latency: ${hop.latency_ms.toFixed(1)}ms<br/>
            ISP: ${hop.geo!.isp}</div>`
          )
        )
        .addTo(map.current!);
    });

    const bounds = new maplibregl.LngLatBounds();
    coordinates.forEach((c) => bounds.extend(c));
    map.current.fitBounds(bounds, { padding: 50 });
  }, [hops]);

  return <div ref={mapContainer} style={{ height: "400px", width: "100%" }} />;
}
"use client";

interface DnsBenchmarkProps {
  data: {
    resolvers: { name: string; address: string; avg_ms: number; p95_ms: number; answers: string[] }[];
    fastest: string;
  } | null;
}

export default function DnsBenchmark({ data }: DnsBenchmarkProps) {
  if (!data) {
    return <p className="text-gray-500">Waiting for DNS benchmark...</p>;
  }

  const maxMs = Math.max(...data.resolvers.map((r) => r.avg_ms));

  return (
    <div className="space-y-3">
      {data.resolvers.map((resolver) => (
        <div key={resolver.name} className="space-y-1">
          <div className="flex justify-between text-sm">
            <span className={resolver.name === data.fastest ? "text-emerald-400 font-semibold" : ""}>
              {resolver.name} {resolver.name === data.fastest && "\u26A1"}
            </span>
            <span className="text-gray-400">{resolver.avg_ms.toFixed(1)}ms</span>
          </div>
          <div className="h-2 bg-gray-800 rounded-full overflow-hidden">
            <div
              className={`h-full rounded-full ${resolver.name === data.fastest ? "bg-emerald-500" : "bg-blue-500"}`}
              style={{ width: `${(resolver.avg_ms / maxMs) * 100}%` }}
            />
          </div>
        </div>
      ))}
    </div>
  );
}
"use client";

import { useEffect, useRef } from "react";
import * as d3 from "d3";

interface Hop {
  ttl: number;
  ip: string;
  host: string;
  latency_ms: number;
  geo?: { city: string; country: string; isp: string };
}

export default function NetworkTopology({ hops }: { hops: Hop[] }) {
  const svgRef = useRef<SVGSVGElement>(null);

  useEffect(() => {
    if (!svgRef.current || hops.length === 0) return;

    const width = svgRef.current.clientWidth;
    const height = 300;

    d3.select(svgRef.current).selectAll("*").remove();

    const svg = d3.select(svgRef.current).attr("width", width).attr("height", height);

    const nodes = hops.map((h, i) => ({
      id: h.ip || `hop-${h.ttl}`,
      label: h.host || h.ip || `*`,
      latency: h.latency_ms,
      ttl: h.ttl,
      type: i === 0 ? "source" : i === hops.length - 1 ? "destination" : "hop",
    }));

    const links = nodes.slice(0, -1).map((_, i) => ({
      source: nodes[i].id,
      target: nodes[i + 1].id,
      latency: nodes[i + 1].latency,
    }));

    const simulation = d3
      .forceSimulation(nodes as any)
      .force("link", d3.forceLink(links).id((d: any) => d.id).distance(60))
      .force("charge", d3.forceManyBody().strength(-100))
      .force("x", d3.forceX(width / 2).strength(0.1))
      .force("y", d3.forceY(height / 2).strength(0.3));

    const link = svg
      .selectAll("line")
      .data(links)
      .enter()
      .append("line")
      .attr("stroke", "#4b5563")
      .attr("stroke-width", 2);

    const node = svg
      .selectAll("circle")
      .data(nodes)
      .enter()
      .append("circle")
      .attr("r", (d) => (d.type === "source" || d.type === "destination" ? 10 : 7))
      .attr("fill", (d) => {
        if (d.type === "source") return "#10b981";
        if (d.type === "destination") return "#f59e0b";
        if (d.latency > 100) return "#ef4444";
        return "#6366f1";
      });

    const label = svg
      .selectAll("text")
      .data(nodes)
      .enter()
      .append("text")
      .text((d) => d.label.substring(0, 15))
      .attr("font-size", "10px")
      .attr("fill", "#9ca3af")
      .attr("dx", 12)
      .attr("dy", 4);

    simulation.on("tick", () => {
      link
        .attr("x1", (d: any) => d.source.x)
        .attr("y1", (d: any) => d.source.y)
        .attr("x2", (d: any) => d.target.x)
        .attr("y2", (d: any) => d.target.y);
      node.attr("cx", (d: any) => d.x).attr("cy", (d: any) => d.y);
      label.attr("x", (d: any) => d.x).attr("y", (d: any) => d.y);
    });
  }, [hops]);

  if (hops.length === 0) {
    return <p className="text-gray-500">Waiting for traceroute data...</p>;
  }

  return <svg ref={svgRef} className="w-full" style={{ height: "300px" }} />;
}
"use client";

const categoryColors: Record<string, string> = {
  DNS: "text-yellow-400 bg-yellow-400/10 border-yellow-400/30",
  "Packet Loss": "text-red-400 bg-red-400/10 border-red-400/30",
  "Wi-Fi": "text-orange-400 bg-orange-400/10 border-orange-400/30",
  Routing: "text-purple-400 bg-purple-400/10 border-purple-400/30",
  ISP: "text-blue-400 bg-blue-400/10 border-blue-400/30",
  "Server-Side": "text-pink-400 bg-pink-400/10 border-pink-400/30",
  Healthy: "text-emerald-400 bg-emerald-400/10 border-emerald-400/30",
  Unknown: "text-gray-400 bg-gray-400/10 border-gray-400/30",
};

interface IssuePanelProps {
  diagnosis: {
    category: string;
    confidence: number;
    explanation: string;
    details: Record<string, any>;
  } | null;
}

export default function IssuePanel({ diagnosis }: IssuePanelProps) {
  if (!diagnosis) {
    return <p className="text-gray-500">Running diagnostics...</p>;
  }

  const colors = categoryColors[diagnosis.category] || categoryColors.Unknown;

  return (
    <div className={`rounded-lg border p-4 ${colors}`}>
      <div className="flex items-center justify-between mb-2">
        <span className="text-lg font-bold">{diagnosis.category}</span>
        <span className="text-sm opacity-70">
          {Math.round(diagnosis.confidence * 100)}% confidence
        </span>
      </div>
      <p className="text-sm opacity-90 mb-3">{diagnosis.explanation}</p>
      {diagnosis.details && (
        <div className="text-xs opacity-70 space-y-1">
          {Object.entries(diagnosis.details).map(([key, value]) => (
            <div key={key}>
              <span className="font-mono">{key}</span>: {JSON.stringify(value)}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Your entire frontend is built and running locally. Next up, you will containerize the full stack with Docker Compose and deploy it to your EC2 instance.

Containerize with Docker Compose

Your Go backend and Next.js frontend both work locally. The next challenge is packaging the backend into a portable container that runs identically on your laptop and on the EC2 instance you set up earlier.

In this step, you will write a multi-stage Dockerfile for the Go binary, then orchestrate it alongside PostgreSQL and Caddy using Docker Compose. Caddy handles reverse proxying and automatic HTTPS via Let's Encrypt.

In this step, get ready to:
  • Write a multi-stage Dockerfile that compiles the Go backend into a tiny Alpine image.
  • Create a docker-compose.yml that wires together your backend, PostgreSQL, and Caddy.
  • Add a Caddyfile for automatic reverse proxy and TLS termination.
Write the multi-stage Dockerfile

A multi-stage Docker build keeps your final image small. The first stage compiles your Go source into a static binary. The second stage copies only that binary into a minimal Alpine image with no compiler toolchain.

  • Create a file called Dockerfile in your netprobe project root (the same folder that contains go.mod).
  • Paste the following into Dockerfile:
FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o netprobe ./cmd/server

FROM alpine:latest

RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/netprobe .

EXPOSE 8080
CMD ["./netprobe"]

What does this Dockerfile do?

  • The builder stage starts from golang:1.23-alpine, downloads dependencies first (leveraging Docker layer caching), then compiles your Go source into a statically linked binary.
  • CGO_ENABLED=0 ensures no C library dependencies, making the binary fully portable.
  • The run stage copies only the compiled binary into a clean alpine:latest image. The ca-certificates package is needed for HTTPS calls to external APIs like ip-api.com.
  • Save Dockerfile.

Why multi-stage builds?

The Go compiler and source code add hundreds of megabytes. A multi-stage build discards all of that after compilation. Your final image is typically under 20 MB, which means faster uploads to EC2 and quicker container restarts.

Create the Docker Compose stack

Docker Compose lets you define multiple services in a single file. Your stack needs three containers: the Go backend, a PostgreSQL database, and Caddy as a reverse proxy.

  • Create a file called docker-compose.yml in your netprobe project root (next to your Dockerfile).
  • Paste the following into docker-compose.yml:
services:
  netprobe:
    build: .
    ports:
      - "8080:8080"
    cap_add:
      - NET_RAW
    environment:
      - DATABASE_URL=postgres://netprobe:netprobe@postgres:5432/netprobe?sslmode=disable
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: netprobe
      POSTGRES_PASSWORD: netprobe
      POSTGRES_DB: netprobe
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U netprobe"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - netprobe
    restart: unless-stopped

volumes:
  pgdata:
  caddy_data:
  caddy_config:

What does this compose file do?

  • netprobe builds from your Dockerfile. cap_add: NET_RAW grants the container permission to send raw ICMP packets for ping and traceroute.
  • depends_on with condition: service_healthy ensures Postgres is fully ready before the backend starts, preventing connection errors on boot.
  • postgres uses a named volume pgdata so your diagnostic history persists across container restarts.
  • caddy mounts your Caddyfile from the host and stores TLS certificates in caddy_data. It exposes ports 80 and 443 for HTTP/HTTPS traffic.
  • Save docker-compose.yml.

Why does the container need NET_RAW?

ICMP ping requires raw socket access, which Docker containers block by default for security. The cap_add: NET_RAW directive grants this specific Linux capability without giving the container full root privileges.

Add the Caddyfile for reverse proxy

Caddy handles TLS certificates automatically via Let's Encrypt. You only need to tell it which domain to serve and where to forward traffic.

  • Create a file called Caddyfile (no file extension) in your netprobe project root.
  • Paste the following into Caddyfile:
{$DOMAIN:localhost} {
    reverse_proxy netprobe:8080
}

What does this Caddyfile do?

  • {$DOMAIN:localhost} reads a DOMAIN environment variable. If none is set, it defaults to localhost for local development.
  • reverse_proxy netprobe:8080 forwards all incoming traffic to the Go backend container on its internal Docker network port.
  • When you set DOMAIN to your EC2 public DNS later, Caddy automatically obtains and renews a Let's Encrypt TLS certificate.
  • Save Caddyfile.
  • Verify the Docker build works by running this from your netprobe project root:
docker compose build

You should see Docker pull the Go and Alpine base images, compile your binary, and report a successful build for the netprobe service.

Build failing?

  • If you see a go.sum missing error, run go mod tidy in your project root first to generate the go.sum file.
  • If Docker is not found, make sure Docker Desktop is running. Press Cmd+Space (macOS) or the Windows key (Windows), type Docker, and open it.
  • If the Dockerfile cannot find ./cmd/server, make sure you are running the command from the netprobe folder that contains go.mod and the cmd/ directory.

Help me debug my Docker Compose build error.

✔️ Awesome, I've got everything!

Double check you have saved all three files: Dockerfile, docker-compose.yml, and Caddyfile.

ⓧ I'd like to double check the full code

Here are the three files you should have in your project root:

FROM golang:1.23-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o netprobe ./cmd/server

FROM alpine:latest

RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/netprobe .

EXPOSE 8080
CMD ["./netprobe"]
services:
  netprobe:
    build: .
    ports:
      - "8080:8080"
    cap_add:
      - NET_RAW
    environment:
      - DATABASE_URL=postgres://netprobe:netprobe@postgres:5432/netprobe?sslmode=disable
    depends_on:
      postgres:
        condition: service_healthy
    restart: unless-stopped

  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: netprobe
      POSTGRES_PASSWORD: netprobe
      POSTGRES_DB: netprobe
    volumes:
      - pgdata:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U netprobe"]
      interval: 5s
      timeout: 5s
      retries: 5
    restart: unless-stopped

  caddy:
    image: caddy:2-alpine
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile
      - caddy_data:/data
      - caddy_config:/config
    depends_on:
      - netprobe
    restart: unless-stopped

volumes:
  pgdata:
  caddy_data:
  caddy_config:
{$DOMAIN:localhost} {
    reverse_proxy netprobe:8080
}

Your entire backend is now containerized and ready to ship. Next up, you will deploy this stack to your EC2 instance and get a live public URL.

Deploy to AWS EC2 and Vercel

Your entire backend is containerized and tested locally. Now it is time to get it live on the internet.

In this step, you will deploy the Docker Compose stack to your EC2 instance, attach a stable Elastic IP address, and then deploy the Next.js frontend to Vercel. By the end, you will have a fully public diagnostic platform with a live URL.

In this step, get ready to:
  • Deploy the Docker Compose stack to EC2 with an Elastic IP.
  • Verify the backend health endpoint and run a test diagnostic.
  • Deploy the Next.js frontend to Vercel and confirm end-to-end connectivity.
Push your project to GitHub and deploy to EC2

Your EC2 instance already has Docker and Docker Compose installed. The fastest way to get the code onto the server is to push it to a GitHub repository and clone it from EC2.

  • On your local machine, initialize a Git repository in your netprobe project folder (the Go backend) by running:
cd ~/Desktop/netprobe
git init
git add .
git commit -m "Initial commit"
  • Create a new repository on GitHub named netprobe (public or private).
  • Push your local code to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/netprobe.git"]]
git branch -M main
git push -u origin main
  • SSH into your EC2 instance by running:
ssh -i [[PEMPATH="path/to/netprobe-key.pem"]] ec2-user@[[EC2_PUBLIC_IP="your EC2 public IPv4 address"]]
  • Once connected to EC2, clone your repository by running:
git clone [[YOUR_REPO_URL="https://github.com/YOUR_USERNAME/netprobe.git"]]
cd netprobe
  • Set the DOMAIN environment variable for Caddy. This tells Caddy which hostname to serve HTTPS for. Export it by running:
export DOMAIN=[[EC2_PUBLIC_IP="your Elastic IP or EC2 public DNS"]]

Why set the DOMAIN variable?

Your Caddyfile uses {$DOMAIN:localhost} as the site address. When DOMAIN is set to your server's public address, Caddy automatically provisions a TLS certificate from Let's Encrypt and serves traffic over HTTPS.

  • Start the full stack in detached mode by running:
docker compose up -d

What does this command do?

The -d flag runs all containers in the background. Docker Compose will build the Go image, start PostgreSQL, wait for the health check, then start the netprobe service and Caddy reverse proxy.

  • Verify all three containers are running by running:
docker compose ps

You should see three services listed (netprobe, postgres, caddy) all showing a status of "Up" or "running".

Containers not starting?

Run docker compose logs netprobe to check for build errors. Common causes include missing go.sum (run go mod tidy locally and push again) or the DATABASE_URL not resolving because PostgreSQL has not finished starting.

If the Caddy container fails, check docker compose logs caddy for TLS provisioning errors. Caddy needs ports 80 and 443 open in your security group to complete the ACME challenge.

help me debug Docker Compose container failures on EC2

Allocate an Elastic IP and verify the backend

EC2 instances get a new public IP every time they restart. An Elastic IP gives your server a permanent address that stays the same across reboots.

  • Open the AWS EC2 console at https://console.aws.amazon.com/ec2/.
  • In the left sidebar, click **Network & Security**, then **Elastic IPs**.
  • Click **Allocate Elastic IP address**.
  • Leave the defaults (Amazon's pool of IPv4 addresses) and click **Allocate**.
  • Select the newly allocated Elastic IP and click **Actions**, then **Associate Elastic IP address**.
  • For **Resource type**, choose **Instance**.
  • Select your NetProbe EC2 instance from the dropdown.
  • Click **Associate**.

What is an Elastic IP?

An Elastic IP is a static public IPv4 address that you own until you release it. It is free while attached to a running instance. If you stop the instance without releasing the IP, AWS charges approximately $0.005/hour.

After associating the Elastic IP, note the IP address shown in the console. This is your permanent backend address.

  • From your local machine, verify the backend health endpoint by running:
curl http://[[ELASTIC_IP="your Elastic IP address"]]:8080/api/health

You should see this response:

{"status":"ok","service":"netprobe"}
  • Run a test diagnostic against the API by running:
curl -X POST http://[[ELASTIC_IP="your Elastic IP address"]]:8080/api/ping \
  -H "Content-Type: application/json" \
  -d '{"target":"google.com"}'

You should see a JSON response with ping statistics including packets_sent, avg_rtt_ms, and packet_loss fields.

Health endpoint not responding?

Make sure your EC2 security group allows inbound traffic on port 8080. Check the inbound rules in the AWS console under **Security Groups**.

If you recently associated the Elastic IP, the old SSH connection may have dropped. Reconnect using the new Elastic IP address.

help me troubleshoot why my EC2 health endpoint is not responding

Deploy the frontend to Vercel

The backend is live on EC2. Now you will deploy the Next.js frontend to Vercel so it has its own public URL with automatic HTTPS and a global CDN.

  • On your local machine, initialize a separate Git repository for the frontend by running:
cd ~/Desktop/netprobe/netprobe-ui
git init
git add .
git commit -m "Initial frontend commit"
  • Create a new repository on GitHub named netprobe-ui.
  • Push the frontend code to GitHub by running:
git remote add origin [[YOUR_UI_REPO_URL="https://github.com/YOUR_USERNAME/netprobe-ui.git"]]
git branch -M main
git push -u origin main
  • Open https://vercel.com and sign in with your GitHub account. If you do not have a Vercel account, sign up for free using GitHub.
  • From the Vercel dashboard, click **Add New**, then **Project**.
  • Select your netprobe-ui repository from the list. If it does not appear, click **Adjust GitHub App Permissions** and grant Vercel access.
  • Vercel auto-detects the Next.js framework. Leave the framework preset as **Next.js**.
  • Expand **Environment Variables** and add a new variable:
  • Set the **Name** to NEXT_PUBLIC_API_URL.
  • Set the **Value** to your EC2 HTTPS endpoint (for example, https://52.12.34.56 using your Elastic IP). If Caddy's HTTPS is not yet active, use http:// with port 8080 for now.

Why set NEXT_PUBLIC_API_URL?

The Dashboard.tsx component uses this environment variable to know where the Go backend lives. The NEXT_PUBLIC_ prefix exposes the variable to the browser at build time.

  • Click **Deploy**. Vercel will clone the repo, install dependencies, and build the Next.js app.

After a minute or two, Vercel shows a success screen with your live URL (something like netprobe-ui.vercel.app).

  • Open your Vercel URL in a browser.
  • Type google.com in the target input and click **Diagnose**.
  • Confirm that the ping chart updates in real time, traceroute hops appear on the map, DNS benchmark bars fill in, and the diagnosis panel shows a result.

Frontend loads but no data appears?

This usually means the WebSocket connection to your EC2 backend is failing. Open your browser's DevTools console and look for connection errors.

Common causes: the NEXT_PUBLIC_API_URL value is wrong (check for typos or a missing protocol), your EC2 security group blocks port 443 (Caddy needs this for HTTPS), or CORS headers are not reaching the browser.

help me debug why my Vercel frontend cannot connect to my EC2 backend via WebSocket

Your internet diagnostics platform is now fully deployed. The Go backend runs on EC2 with persistent storage, and the Next.js frontend is served globally via Vercel's CDN.

Secret mission

Multi-Target Comparison Mode

What if you could diagnose multiple targets at the same time and see where their paths diverge? Add a comparison mode that reveals shared ISP infrastructure and pinpoints exactly where one destination becomes slower than another.

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses AWS resources that are free within the AWS Free Tier (first 12 months), but will cost approximately $8-10/month after that.

Cost warning

Your EC2 t2.micro instance is free for 750 hours/month during your first 12 months of AWS. After that, it costs approximately $8-10/month to keep running.

Your Elastic IP is free while attached to a running instance. If you stop the instance without releasing the Elastic IP, it costs approximately $0.005/hour.

Resources you used:

  • 1x EC2 t2.micro instance (free for 750 hrs/month on AWS Free Tier).
  • 1x Elastic IP address (free while attached to a running instance).
  • 1x Security group with custom inbound rules for ports 22, 80, 443, and 8080.
  • Vercel deployment (free forever, no action needed).
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep the platform live as a portfolio piece.

  • Leave the EC2 instance running. It stays free as long as you are within the AWS Free Tier (750 hours/month for the first 12 months).
  • Monitor your AWS billing dashboard monthly to confirm you remain within free tier limits.
  • Your Vercel frontend deployment is free forever and requires no monitoring.

Pause - I'll come back to this later

Shut down running processes to save costs while keeping your files and configuration intact.

  • Open the AWS EC2 console and select your NetProbe instance.
  • Click **Instance state**, then **Stop instance**.
  • Release your Elastic IP to avoid charges while the instance is stopped. Go to **Elastic IPs** in the left sidebar, select your IP, then click **Actions** and **Release Elastic IP address**.
  • When you are ready to resume, start the instance again, allocate a new Elastic IP, and associate it. Then SSH in and run docker compose up -d to bring your services back up.

Delete - I don't want to use this again

Remove all AWS resources completely. Your code still lives on GitHub and can be redeployed anytime.

  • Open the AWS EC2 console. Select your NetProbe instance, click **Instance state**, then **Terminate instance**.
  • Go to **Elastic IPs** in the left sidebar. Select your Elastic IP, click **Actions**, then **Release Elastic IP address**.
  • Go to **Security Groups** in the left sidebar. Select the security group you created for this project, click **Actions**, then **Delete security groups**.
  • Optionally delete the Vercel project from your Vercel dashboard if you no longer want the frontend hosted.
  • Optionally delete the local project folder from your Desktop.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade internet diagnostics platform from scratch and deployed it live to the internet.

You've learned how to:

  • Build a Go backend that performs real ICMP ping, UDP traceroute, DNS benchmarking, speed testing, and intelligent issue classification.
  • Implement real-time WebSocket streaming with the Hub pattern and create an interactive Next.js dashboard featuring D3.js network topology and a MapLibre GL world map showing hop-by-hop traffic paths.
  • Containerize a full stack with Docker Compose and deploy the backend to AWS EC2 with automatic HTTPS via Caddy, plus deploy the frontend to Vercel with a live public URL.
  • Added multi-target comparison mode that runs parallel diagnostics and highlights where network paths diverge.

Ready to quiz yourself?