Browse Source

feat(tools): add live packet-by-packet streaming SSE for Ping and Traceroute with Start/Stop button

Proxmox Router 1 tuần trước cách đây
mục cha
commit
bbf0cd7502

+ 4 - 0
internal/api/api.go

@@ -263,7 +263,11 @@ func (s *Server) registerRoutes() {
 
 	// Troubleshooting Tools (/tool ping, traceroute, mtr, dns, port-probe)
 	s.mux.HandleFunc("POST /api/tools/ping", s.handleToolsPing)
+	s.mux.HandleFunc("POST /api/tools/ping/stream", s.handleToolsPingStream)
+	s.mux.HandleFunc("GET /api/tools/ping/stream", s.handleToolsPingStream)
 	s.mux.HandleFunc("POST /api/tools/traceroute", s.handleToolsTraceroute)
+	s.mux.HandleFunc("POST /api/tools/traceroute/stream", s.handleToolsTracerouteStream)
+	s.mux.HandleFunc("GET /api/tools/traceroute/stream", s.handleToolsTracerouteStream)
 	s.mux.HandleFunc("POST /api/tools/dns", s.handleToolsDNS)
 	s.mux.HandleFunc("POST /api/tools/port-probe", s.handleToolsPortProbe)
 	s.mux.HandleFunc("POST /api/tools/mtr", s.handleToolsMTR)

+ 89 - 0
internal/api/tools_handlers.go

@@ -2,7 +2,9 @@ package api
 
 import (
 	"encoding/json"
+	"fmt"
 	"net/http"
+	"strconv"
 	"time"
 
 	"proxmox-router/internal/tools"
@@ -36,6 +38,50 @@ func (s *Server) handleToolsPing(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, resp)
 }
 
+func (s *Server) handleToolsPingStream(w http.ResponseWriter, r *http.Request) {
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+		return
+	}
+
+	var req tools.PingRequest
+	if r.Method == http.MethodPost && r.Body != nil {
+		_ = json.NewDecoder(r.Body).Decode(&req)
+	}
+	if req.Target == "" {
+		q := r.URL.Query()
+		req.Target = q.Get("target")
+		req.Count, _ = strconv.Atoi(q.Get("count"))
+		req.Size, _ = strconv.Atoi(q.Get("size"))
+		req.Timeout, _ = strconv.Atoi(q.Get("timeout"))
+		req.Interface = q.Get("interface")
+		req.DoNotFrag = q.Get("dont_frag") == "true" || q.Get("dont_frag") == "1"
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("X-Accel-Buffering", "no")
+
+	sendSSE := func(evt tools.PingStreamEvent) {
+		b, err := json.Marshal(evt)
+		if err == nil {
+			fmt.Fprintf(w, "data: %s\n\n", b)
+			flusher.Flush()
+		}
+	}
+
+	err := s.toolsMgr.StreamPing(r.Context(), req, sendSSE)
+	if err != nil {
+		sendSSE(tools.PingStreamEvent{
+			Type:    "error",
+			Message: err.Error(),
+			Raw:     "Error: " + err.Error(),
+		})
+	}
+}
+
 func (s *Server) handleToolsTraceroute(w http.ResponseWriter, r *http.Request) {
 	var req tools.TracerouteRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -52,6 +98,49 @@ func (s *Server) handleToolsTraceroute(w http.ResponseWriter, r *http.Request) {
 	writeJSON(w, http.StatusOK, resp)
 }
 
+func (s *Server) handleToolsTracerouteStream(w http.ResponseWriter, r *http.Request) {
+	flusher, ok := w.(http.Flusher)
+	if !ok {
+		http.Error(w, "Streaming unsupported", http.StatusInternalServerError)
+		return
+	}
+
+	var req tools.TracerouteRequest
+	if r.Method == http.MethodPost && r.Body != nil {
+		_ = json.NewDecoder(r.Body).Decode(&req)
+	}
+	if req.Target == "" {
+		q := r.URL.Query()
+		req.Target = q.Get("target")
+		req.MaxHops, _ = strconv.Atoi(q.Get("max_hops"))
+		req.Protocol = q.Get("protocol")
+		req.Interface = q.Get("interface")
+		req.Resolve = q.Get("resolve") == "true" || q.Get("resolve") == "1"
+	}
+
+	w.Header().Set("Content-Type", "text/event-stream")
+	w.Header().Set("Cache-Control", "no-cache")
+	w.Header().Set("Connection", "keep-alive")
+	w.Header().Set("X-Accel-Buffering", "no")
+
+	sendSSE := func(evt tools.TracerouteStreamEvent) {
+		b, err := json.Marshal(evt)
+		if err == nil {
+			fmt.Fprintf(w, "data: %s\n\n", b)
+			flusher.Flush()
+		}
+	}
+
+	err := s.toolsMgr.StreamTraceroute(r.Context(), req, sendSSE)
+	if err != nil {
+		sendSSE(tools.TracerouteStreamEvent{
+			Type:    "error",
+			Message: err.Error(),
+			Raw:     "Error: " + err.Error(),
+		})
+	}
+}
+
 func (s *Server) handleToolsDNS(w http.ResponseWriter, r *http.Request) {
 	var req tools.DNSRequest
 	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {

+ 383 - 5
internal/tools/manager.go

@@ -1,6 +1,7 @@
 package tools
 
 import (
+	"bufio"
 	"context"
 	"fmt"
 	"net"
@@ -12,11 +13,13 @@ import (
 )
 
 var (
-	validHostRegex = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
-	pingReplyRegex = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`)
-	pingStatRegex  = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`)
-	pingRttRegex   = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`)
-	mtrHopRegex    = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`)
+	validHostRegex   = regexp.MustCompile(`^[a-zA-Z0-9.:_-]+$`)
+	pingReplyRegex   = regexp.MustCompile(`(\d+)\s+bytes from\s+([^:]+):\s+icmp_seq=(\d+)\s+ttl=(\d+)\s+time=([\d.]+)\s+ms`)
+	pingTimeoutRegex = regexp.MustCompile(`(?:no answer yet for icmp_seq=|Request timeout for icmp_seq\s+)(\d+)`)
+	pingUnreachRegex = regexp.MustCompile(`From\s+([^\s:]+)\s+icmp_seq=(\d+)\s+(.*)`)
+	pingStatRegex    = regexp.MustCompile(`(\d+)\s+packets transmitted,\s+(\d+)\s+(?:packets\s+)?received,\s+([\d.]+)%\s+packet loss`)
+	pingRttRegex     = regexp.MustCompile(`rtt min/avg/max/mdev = ([\d.]+)/([\d.]+)/([\d.]+)/([\d.]+)\s+ms`)
+	mtrHopRegex      = regexp.MustCompile(`^\s*(\d+)\.\|--\s+(\S+)\s+([\d.]+)%?\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)`)
 )
 
 type Manager struct{}
@@ -141,6 +144,255 @@ func (m *Manager) RunPing(ctx context.Context, req PingRequest) (*PingResponse,
 	return resp, nil
 }
 
+// StreamPing executes ICMP ping and streams real-time events line-by-line
+func (m *Manager) StreamPing(ctx context.Context, req PingRequest, onEvent func(PingStreamEvent)) error {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return err
+	}
+
+	size := req.Size
+	if size <= 0 {
+		size = 56
+	}
+	if size > 65500 {
+		size = 65500
+	}
+
+	timeout := req.Timeout
+	if timeout <= 0 {
+		timeout = 3
+	}
+	if timeout > 30 {
+		timeout = 30
+	}
+
+	args := []string{
+		"-O", // report outstanding replies/timeouts in real time
+		"-s", strconv.Itoa(size),
+		"-W", strconv.Itoa(timeout),
+	}
+
+	if req.Count > 0 {
+		args = append(args, "-c", strconv.Itoa(req.Count))
+	}
+
+	if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
+		args = append(args, "-I", req.Interface)
+	}
+
+	if req.DoNotFrag {
+		args = append(args, "-M", "do")
+	}
+
+	args = append(args, target)
+
+	cmd := exec.CommandContext(ctx, "ping", args...)
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	cmd.Stderr = cmd.Stdout
+
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	packetsSent := 0
+	packetsRecv := 0
+	var minRTT, maxRTT, sumRTT float64
+
+	scanner := bufio.NewScanner(stdout)
+	for scanner.Scan() {
+		line := scanner.Text()
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+
+		if strings.HasPrefix(trimmed, "PING ") {
+			onEvent(PingStreamEvent{
+				Type: "header",
+				Raw:  line,
+			})
+			continue
+		}
+
+		// Match reply
+		if match := pingReplyRegex.FindStringSubmatch(trimmed); len(match) >= 6 {
+			bytes, _ := strconv.Atoi(match[1])
+			from := match[2]
+			seq, _ := strconv.Atoi(match[3])
+			ttl, _ := strconv.Atoi(match[4])
+			timeMs, _ := strconv.ParseFloat(match[5], 64)
+
+			packetsSent++
+			packetsRecv++
+			sumRTT += timeMs
+			if packetsRecv == 1 || timeMs < minRTT {
+				minRTT = timeMs
+			}
+			if timeMs > maxRTT {
+				maxRTT = timeMs
+			}
+			avgRTT := sumRTT / float64(packetsRecv)
+			lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
+
+			onEvent(PingStreamEvent{
+				Type:          "reply",
+				Seq:           seq,
+				Bytes:         bytes,
+				From:          from,
+				TTL:           ttl,
+				TimeMs:        timeMs,
+				PacketsSent:   packetsSent,
+				PacketsRecv:   packetsRecv,
+				PacketLossPct: lossPct,
+				MinRTT:        minRTT,
+				AvgRTT:        avgRTT,
+				MaxRTT:        maxRTT,
+				Raw:           line,
+			})
+			continue
+		}
+
+		// Match timeout (no answer yet for icmp_seq=X)
+		if match := pingTimeoutRegex.FindStringSubmatch(trimmed); len(match) >= 2 {
+			seq, _ := strconv.Atoi(match[1])
+			packetsSent++
+			lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
+			avgRTT := 0.0
+			if packetsRecv > 0 {
+				avgRTT = sumRTT / float64(packetsRecv)
+			}
+
+			onEvent(PingStreamEvent{
+				Type:          "timeout",
+				Seq:           seq,
+				PacketsSent:   packetsSent,
+				PacketsRecv:   packetsRecv,
+				PacketLossPct: lossPct,
+				MinRTT:        minRTT,
+				AvgRTT:        avgRTT,
+				MaxRTT:        maxRTT,
+				Message:       "Request timed out",
+				Raw:           line,
+			})
+			continue
+		}
+
+		// Match ICMP unreach error
+		if match := pingUnreachRegex.FindStringSubmatch(trimmed); len(match) >= 4 {
+			from := match[1]
+			seq, _ := strconv.Atoi(match[2])
+			msg := match[3]
+			packetsSent++
+			lossPct := float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
+			avgRTT := 0.0
+			if packetsRecv > 0 {
+				avgRTT = sumRTT / float64(packetsRecv)
+			}
+
+			onEvent(PingStreamEvent{
+				Type:          "unreach",
+				Seq:           seq,
+				From:          from,
+				PacketsSent:   packetsSent,
+				PacketsRecv:   packetsRecv,
+				PacketLossPct: lossPct,
+				MinRTT:        minRTT,
+				AvgRTT:        avgRTT,
+				MaxRTT:        maxRTT,
+				Message:       msg,
+				Raw:           line,
+			})
+			continue
+		}
+
+		// Match overall stats summary
+		if match := pingStatRegex.FindStringSubmatch(trimmed); len(match) >= 4 {
+			sSent, _ := strconv.Atoi(match[1])
+			sRecv, _ := strconv.Atoi(match[2])
+			sLoss, _ := strconv.ParseFloat(match[3], 64)
+			packetsSent = sSent
+			packetsRecv = sRecv
+
+			avgRTT := 0.0
+			if packetsRecv > 0 {
+				avgRTT = sumRTT / float64(packetsRecv)
+			}
+
+			onEvent(PingStreamEvent{
+				Type:          "stats",
+				PacketsSent:   packetsSent,
+				PacketsRecv:   packetsRecv,
+				PacketLossPct: sLoss,
+				MinRTT:        minRTT,
+				AvgRTT:        avgRTT,
+				MaxRTT:        maxRTT,
+				Raw:           line,
+			})
+			continue
+		}
+
+		// Match RTT min/avg/max/mdev
+		if match := pingRttRegex.FindStringSubmatch(trimmed); len(match) >= 5 {
+			sMin, _ := strconv.ParseFloat(match[1], 64)
+			sAvg, _ := strconv.ParseFloat(match[2], 64)
+			sMax, _ := strconv.ParseFloat(match[3], 64)
+			sMdev, _ := strconv.ParseFloat(match[4], 64)
+
+			lossPct := 0.0
+			if packetsSent > 0 {
+				lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
+			}
+
+			onEvent(PingStreamEvent{
+				Type:          "stats",
+				PacketsSent:   packetsSent,
+				PacketsRecv:   packetsRecv,
+				PacketLossPct: lossPct,
+				MinRTT:        sMin,
+				AvgRTT:        sAvg,
+				MaxRTT:        sMax,
+				MdevRTT:       sMdev,
+				Raw:           line,
+			})
+			continue
+		}
+
+		// Generic line
+		onEvent(PingStreamEvent{
+			Type: "raw",
+			Raw:  line,
+		})
+	}
+
+	_ = cmd.Wait()
+
+	lossPct := 0.0
+	if packetsSent > 0 {
+		lossPct = float64(packetsSent-packetsRecv) * 100.0 / float64(packetsSent)
+	}
+	avgRTT := 0.0
+	if packetsRecv > 0 {
+		avgRTT = sumRTT / float64(packetsRecv)
+	}
+
+	onEvent(PingStreamEvent{
+		Type:          "done",
+		PacketsSent:   packetsSent,
+		PacketsRecv:   packetsRecv,
+		PacketLossPct: lossPct,
+		MinRTT:        minRTT,
+		AvgRTT:        avgRTT,
+		MaxRTT:        maxRTT,
+		Raw:           "Ping finished",
+	})
+
+	return nil
+}
+
 // RunTraceroute traces the route to a target
 func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*TracerouteResponse, error) {
 	target, err := sanitizeTarget(req.Target)
@@ -240,6 +492,132 @@ func (m *Manager) RunTraceroute(ctx context.Context, req TracerouteRequest) (*Tr
 	return resp, nil
 }
 
+// StreamTraceroute executes traceroute and streams hop events line-by-line
+func (m *Manager) StreamTraceroute(ctx context.Context, req TracerouteRequest, onEvent func(TracerouteStreamEvent)) error {
+	target, err := sanitizeTarget(req.Target)
+	if err != nil {
+		return err
+	}
+
+	maxHops := req.MaxHops
+	if maxHops <= 0 {
+		maxHops = 15
+	}
+	if maxHops > 30 {
+		maxHops = 30
+	}
+
+	args := []string{
+		"-m", strconv.Itoa(maxHops),
+		"-q", "2",
+		"-w", "1",
+	}
+
+	if !req.Resolve {
+		args = append(args, "-n")
+	}
+
+	if req.Protocol == "icmp" {
+		args = append(args, "-I")
+	}
+
+	if req.Interface != "" && validHostRegex.MatchString(req.Interface) {
+		args = append(args, "-i", req.Interface)
+	}
+
+	args = append(args, target)
+
+	cmd := exec.CommandContext(ctx, "traceroute", args...)
+	stdout, err := cmd.StdoutPipe()
+	if err != nil {
+		return err
+	}
+	cmd.Stderr = cmd.Stdout
+
+	if err := cmd.Start(); err != nil {
+		return err
+	}
+
+	hopsCount := 0
+	scanner := bufio.NewScanner(stdout)
+	isHeader := true
+
+	for scanner.Scan() {
+		line := scanner.Text()
+		trimmed := strings.TrimSpace(line)
+		if trimmed == "" {
+			continue
+		}
+
+		if isHeader {
+			isHeader = false
+			onEvent(TracerouteStreamEvent{
+				Type: "header",
+				Raw:  line,
+			})
+			continue
+		}
+
+		fields := strings.Fields(trimmed)
+		if len(fields) < 2 {
+			continue
+		}
+
+		hopNum, err := strconv.Atoi(fields[0])
+		if err != nil {
+			continue
+		}
+
+		hopsCount++
+		var hop TracerouteHop
+		if fields[1] == "*" {
+			hop = TracerouteHop{
+				Hop:     hopNum,
+				Host:    "*",
+				IP:      "*",
+				TimesMs: []string{"*"},
+				Status:  "timeout",
+			}
+		} else {
+			hostOrIP := fields[1]
+			ip := hostOrIP
+			if len(fields) >= 3 && strings.HasPrefix(fields[2], "(") && strings.HasSuffix(fields[2], ")") {
+				ip = strings.Trim(fields[2], "()")
+			}
+			times := make([]string, 0)
+			for _, f := range fields[2:] {
+				if f != "ms" && !strings.HasPrefix(f, "(") {
+					times = append(times, f)
+				}
+			}
+			hop = TracerouteHop{
+				Hop:     hopNum,
+				Host:    hostOrIP,
+				IP:      ip,
+				TimesMs: times,
+				Status:  "ok",
+			}
+		}
+
+		onEvent(TracerouteStreamEvent{
+			Type:      "hop",
+			Hop:       &hop,
+			HopsCount: hopsCount,
+			Raw:       line,
+		})
+	}
+
+	_ = cmd.Wait()
+
+	onEvent(TracerouteStreamEvent{
+		Type:      "done",
+		HopsCount: hopsCount,
+		Raw:       "Traceroute finished",
+	})
+
+	return nil
+}
+
 // RunDNSLookup performs DNS resolution via dig
 func (m *Manager) RunDNSLookup(ctx context.Context, req DNSRequest) (*DNSResponse, error) {
 	domain, err := sanitizeTarget(req.Domain)

+ 26 - 0
internal/tools/types.go

@@ -32,6 +32,24 @@ type PingResponse struct {
 	Error         string      `json:"error,omitempty"`
 }
 
+type PingStreamEvent struct {
+	Type          string   `json:"type"` // "header", "reply", "timeout", "unreach", "stats", "done", "error"
+	Seq           int      `json:"seq,omitempty"`
+	Bytes         int      `json:"bytes,omitempty"`
+	From          string   `json:"from,omitempty"`
+	TTL           int      `json:"ttl,omitempty"`
+	TimeMs        float64  `json:"time_ms,omitempty"`
+	PacketsSent   int      `json:"packets_sent,omitempty"`
+	PacketsRecv   int      `json:"packets_recv,omitempty"`
+	PacketLossPct float64  `json:"packet_loss_pct,omitempty"`
+	MinRTT        float64  `json:"min_rtt_ms,omitempty"`
+	AvgRTT        float64  `json:"avg_rtt_ms,omitempty"`
+	MaxRTT        float64  `json:"max_rtt_ms,omitempty"`
+	MdevRTT       float64  `json:"mdev_rtt_ms,omitempty"`
+	Message       string   `json:"message,omitempty"`
+	Raw           string   `json:"raw"`
+}
+
 type TracerouteRequest struct {
 	Target    string `json:"target"`
 	MaxHops   int    `json:"max_hops"`   // default 15, max 30
@@ -55,6 +73,14 @@ type TracerouteResponse struct {
 	Error     string          `json:"error,omitempty"`
 }
 
+type TracerouteStreamEvent struct {
+	Type      string         `json:"type"` // "header", "hop", "done", "error"
+	Hop       *TracerouteHop `json:"hop,omitempty"`
+	HopsCount int            `json:"hops_count,omitempty"`
+	Message   string         `json:"message,omitempty"`
+	Raw       string         `json:"raw"`
+}
+
 type DNSRequest struct {
 	Domain string `json:"domain"`
 	Type   string `json:"type"`   // A, AAAA, MX, TXT, NS, CNAME, ANY

+ 321 - 107
internal/web/static/app.js

@@ -7105,12 +7105,104 @@ function setPortPreset(port) {
   if (input) input.value = port;
 }
 
+let pingAbortController = null;
+let tracerouteAbortController = null;
+
+function clearPingTable() {
+  const tbody = document.getElementById('tools-ping-tbody');
+  if (tbody) tbody.innerHTML = '';
+  const rawEl = document.getElementById('tools-ping-raw');
+  if (rawEl) rawEl.innerText = '';
+  const sentEl = document.getElementById('tools-ping-stat-sent');
+  if (sentEl) sentEl.innerText = '0';
+  const recvEl = document.getElementById('tools-ping-stat-recv');
+  if (recvEl) recvEl.innerText = '0';
+  const lossEl = document.getElementById('tools-ping-stat-loss');
+  if (lossEl) lossEl.innerText = '0%';
+  const avgEl = document.getElementById('tools-ping-stat-avg');
+  if (avgEl) avgEl.innerText = '0 ms';
+  const minMaxEl = document.getElementById('tools-ping-stat-minmax');
+  if (minMaxEl) minMaxEl.innerText = '-';
+  const badge = document.getElementById('tools-ping-status-badge');
+  if (badge) {
+    badge.className = '';
+    badge.innerText = '';
+  }
+}
+
+function clearTracerouteTable() {
+  const tbody = document.getElementById('tools-tr-tbody');
+  if (tbody) tbody.innerHTML = '';
+  const rawEl = document.getElementById('tools-tr-raw');
+  if (rawEl) rawEl.innerText = '';
+  const statHops = document.getElementById('tools-tr-hops-count');
+  if (statHops) statHops.innerText = '0 Hops';
+}
+
+function resetPingButton() {
+  const btn = document.getElementById('tools-ping-btn');
+  const btnText = document.getElementById('tools-ping-btn-text');
+  const spinner = document.getElementById('tools-ping-spinner');
+  if (btn) {
+    btn.disabled = false;
+    btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2';
+  }
+  if (btnText) btnText.innerText = 'Start Ping';
+  if (spinner) spinner.classList.add('hidden');
+}
+
+function setPingRunningButton() {
+  const btn = document.getElementById('tools-ping-btn');
+  const btnText = document.getElementById('tools-ping-btn-text');
+  const spinner = document.getElementById('tools-ping-spinner');
+  if (btn) {
+    btn.disabled = false;
+    btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
+  }
+  if (btnText) btnText.innerText = 'Stop Ping';
+  if (spinner) spinner.classList.remove('hidden');
+}
+
+function resetTracerouteButton() {
+  const btn = document.getElementById('tools-tr-btn');
+  const btnText = document.getElementById('tools-tr-btn-text');
+  const spinner = document.getElementById('tools-tr-spinner');
+  if (btn) {
+    btn.disabled = false;
+    btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2';
+  }
+  if (btnText) btnText.innerText = 'Start Traceroute';
+  if (spinner) spinner.classList.add('hidden');
+}
+
+function setTracerouteRunningButton() {
+  const btn = document.getElementById('tools-tr-btn');
+  const btnText = document.getElementById('tools-tr-btn-text');
+  const spinner = document.getElementById('tools-tr-spinner');
+  if (btn) {
+    btn.disabled = false;
+    btn.className = 'px-5 py-2 rounded-lg font-semibold text-xs bg-rose-600 hover:bg-rose-500 text-white shadow-lg shadow-rose-600/30 transition flex items-center gap-2';
+  }
+  if (btnText) btnText.innerText = 'Stop Traceroute';
+  if (spinner) spinner.classList.remove('hidden');
+}
+
 async function executePing(e) {
   if (e) e.preventDefault();
+
+  // If already running, clicking the button stops the stream!
+  if (pingAbortController) {
+    pingAbortController.abort();
+    pingAbortController = null;
+    resetPingButton();
+    showToast('Ping Stopped', 'Live ping stopped by user', 'info');
+    return;
+  }
+
   const target = document.getElementById('tools-ping-target')?.value?.trim();
   const count = parseInt(document.getElementById('tools-ping-count')?.value || '4', 10);
   const size = parseInt(document.getElementById('tools-ping-size')?.value || '56', 10);
-  const timeout = parseInt(document.getElementById('tools-ping-timeout')?.value || '5', 10);
+  const timeout = parseInt(document.getElementById('tools-ping-timeout')?.value || '3', 10);
   const iface = document.getElementById('tools-ping-iface')?.value || '';
   const dontFrag = document.getElementById('tools-ping-df')?.checked || false;
 
@@ -7119,13 +7211,15 @@ async function executePing(e) {
     return;
   }
 
-  const btn = document.getElementById('tools-ping-btn');
-  const spinner = document.getElementById('tools-ping-spinner');
-  if (btn) btn.disabled = true;
-  if (spinner) spinner.classList.remove('hidden');
+  clearPingTable();
+  const resCard = document.getElementById('tools-ping-results');
+  if (resCard) resCard.classList.remove('hidden');
+
+  pingAbortController = new AbortController();
+  setPingRunningButton();
 
   try {
-    const res = await fetch(`${API_BASE}/tools/ping`, {
+    const res = await fetch(`${API_BASE}/tools/ping/stream`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
@@ -7135,91 +7229,177 @@ async function executePing(e) {
         timeout,
         interface: iface,
         dont_frag: dontFrag
-      })
+      }),
+      signal: pingAbortController.signal
     });
 
-    const data = await res.json();
     if (!res.ok) {
-      showToast('Ping Error', data.error || 'Failed to execute ping', 'error');
+      const errText = await res.text();
+      showToast('Ping Error', errText || 'Failed to start live ping', 'error');
+      resetPingButton();
+      pingAbortController = null;
       return;
     }
 
-    const resCard = document.getElementById('tools-ping-results');
-    if (resCard) resCard.classList.remove('hidden');
+    const reader = res.body.getReader();
+    const decoder = new TextDecoder('utf-8');
+    let buffer = '';
 
-    const sentEl = document.getElementById('tools-ping-stat-sent');
-    if (sentEl) sentEl.innerText = data.packets_sent ?? 0;
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
 
-    const recvEl = document.getElementById('tools-ping-stat-recv');
-    if (recvEl) recvEl.innerText = data.packets_recv ?? 0;
+      buffer += decoder.decode(value, { stream: true });
+      const blocks = buffer.split('\n\n');
+      buffer = blocks.pop() || '';
 
-    const lossEl = document.getElementById('tools-ping-stat-loss');
-    if (lossEl) {
-      const loss = data.packet_loss_pct ?? 0;
-      lossEl.innerText = `${loss.toFixed(0)}%`;
-      lossEl.className = loss === 0 
-        ? 'text-base font-bold font-mono text-emerald-400 mt-1' 
-        : (loss < 50 ? 'text-base font-bold font-mono text-amber-400 mt-1' : 'text-base font-bold font-mono text-rose-400 mt-1');
+      for (const block of blocks) {
+        const trimmed = block.trim();
+        if (!trimmed) continue;
+        const line = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
+        try {
+          const evt = JSON.parse(line);
+          handleLivePingEvent(evt);
+        } catch (parseErr) {
+          console.warn('Ping SSE parse error:', parseErr, line);
+        }
+      }
     }
+  } catch (err) {
+    if (err.name === 'AbortError') {
+      // User aborted normally
+    } else {
+      showToast('Ping Error', err.message, 'error');
+    }
+  } finally {
+    pingAbortController = null;
+    resetPingButton();
+  }
+}
 
-    const avgEl = document.getElementById('tools-ping-stat-avg');
-    if (avgEl) avgEl.innerText = data.avg_rtt_ms ? `${data.avg_rtt_ms.toFixed(2)} ms` : '--';
+function handleLivePingEvent(evt) {
+  const sentEl = document.getElementById('tools-ping-stat-sent');
+  const recvEl = document.getElementById('tools-ping-stat-recv');
+  const lossEl = document.getElementById('tools-ping-stat-loss');
+  const avgEl = document.getElementById('tools-ping-stat-avg');
+  const minMaxEl = document.getElementById('tools-ping-stat-minmax');
+  const badge = document.getElementById('tools-ping-status-badge');
+  const tbody = document.getElementById('tools-ping-tbody');
+  const rawEl = document.getElementById('tools-ping-raw');
 
-    const minMaxEl = document.getElementById('tools-ping-stat-minmax');
-    if (minMaxEl) {
-      if (data.min_rtt_ms !== undefined && data.max_rtt_ms !== undefined && data.min_rtt_ms > 0) {
-        minMaxEl.innerText = `${data.min_rtt_ms.toFixed(1)} / ${data.max_rtt_ms.toFixed(1)} ms`;
-      } else {
-        minMaxEl.innerText = '--';
-      }
-    }
+  if (evt.raw && rawEl) {
+    rawEl.innerText += (rawEl.innerText ? '\n' : '') + evt.raw;
+    rawEl.scrollTop = rawEl.scrollHeight;
+  }
 
-    const badge = document.getElementById('tools-ping-status-badge');
+  if (evt.type === 'reply') {
+    if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent;
+    if (recvEl && evt.packets_recv !== undefined) recvEl.innerText = evt.packets_recv;
+    if (lossEl && evt.packet_loss_pct !== undefined) {
+      lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`;
+      lossEl.className = evt.packet_loss_pct === 0 
+        ? 'text-base font-bold font-mono text-emerald-400 mt-1' 
+        : (evt.packet_loss_pct < 50 ? 'text-base font-bold font-mono text-amber-400 mt-1' : 'text-base font-bold font-mono text-rose-400 mt-1');
+    }
+    if (avgEl && evt.avg_rtt_ms !== undefined) avgEl.innerText = `${evt.avg_rtt_ms.toFixed(2)} ms`;
+    if (minMaxEl && evt.min_rtt_ms !== undefined && evt.max_rtt_ms !== undefined) {
+      minMaxEl.innerText = `${evt.min_rtt_ms.toFixed(1)} / ${evt.max_rtt_ms.toFixed(1)} ms`;
+    }
     if (badge) {
-      if (data.packet_loss_pct === 0) {
+      if (evt.packet_loss_pct === 0) {
         badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-emerald-950 text-emerald-300 border border-emerald-800/60';
         badge.innerText = '100% OK';
-      } else if (data.packets_recv > 0) {
-        badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-950 text-amber-300 border border-amber-800/60';
-        badge.innerText = `${data.packet_loss_pct}% LOSS`;
       } else {
-        badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-rose-950 text-rose-300 border border-rose-800/60';
-        badge.innerText = 'UNREACHABLE';
+        badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-amber-950 text-amber-300 border border-amber-800/60';
+        badge.innerText = `${evt.packet_loss_pct.toFixed(0)}% LOSS`;
       }
     }
 
-    const tbody = document.getElementById('tools-ping-tbody') || document.getElementById('tools-ping-table-body');
     if (tbody) {
-      if (Array.isArray(data.replies) && data.replies.length > 0) {
-        tbody.innerHTML = data.replies.map(r => `
-          <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs">
-            <td class="py-2 px-3 text-slate-300">#${r.seq}</td>
-            <td class="py-2 px-3 font-semibold text-white">${escapeHtml(r.from)}</td>
-            <td class="py-2 px-3 text-slate-400">${r.bytes} bytes</td>
-            <td class="py-2 px-3 text-slate-300">${r.ttl}</td>
-            <td class="py-2 px-3 font-bold text-amber-300">${r.time_ms.toFixed(2)} ms</td>
-            <td class="py-2 px-3 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">REPLY</span></td>
-          </tr>
-        `).join('');
-      } else {
-        tbody.innerHTML = `<tr><td colspan="6" class="p-4 text-center text-xs text-rose-400 font-mono">No replies received (100% packet loss or timed out)</td></tr>`;
-      }
+      const tr = document.createElement('tr');
+      tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs';
+      tr.innerHTML = `
+        <td class="py-2 px-3 text-slate-300">#${evt.seq}</td>
+        <td class="py-2 px-3 font-semibold text-white">${escapeHtml(evt.from)}</td>
+        <td class="py-2 px-3 text-slate-400">${evt.bytes} bytes</td>
+        <td class="py-2 px-3 text-slate-300">${evt.ttl}</td>
+        <td class="py-2 px-3 font-bold text-amber-300">${evt.time_ms.toFixed(2)} ms</td>
+        <td class="py-2 px-3 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">REPLY</span></td>
+      `;
+      tbody.appendChild(tr);
+    }
+  } else if (evt.type === 'timeout') {
+    if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent;
+    if (lossEl && evt.packet_loss_pct !== undefined) {
+      lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`;
+      lossEl.className = evt.packet_loss_pct < 50 ? 'text-base font-bold font-mono text-amber-400 mt-1' : 'text-base font-bold font-mono text-rose-400 mt-1';
+    }
+    if (badge) {
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-rose-950 text-rose-300 border border-rose-800/60';
+      badge.innerText = `${evt.packet_loss_pct?.toFixed(0) || 100}% LOSS`;
     }
 
-    const rawEl = document.getElementById('tools-ping-raw');
-    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
+    if (tbody) {
+      const tr = document.createElement('tr');
+      tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs opacity-75';
+      tr.innerHTML = `
+        <td class="py-2 px-3 text-slate-400">#${evt.seq}</td>
+        <td class="py-2 px-3 font-semibold text-slate-500 italic">Request timed out</td>
+        <td class="py-2 px-3 text-slate-600">--</td>
+        <td class="py-2 px-3 text-slate-600">--</td>
+        <td class="py-2 px-3 font-bold text-slate-500">* * *</td>
+        <td class="py-2 px-3 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">TIMEOUT</span></td>
+      `;
+      tbody.appendChild(tr);
+    }
+  } else if (evt.type === 'unreach') {
+    if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent;
+    if (lossEl && evt.packet_loss_pct !== undefined) {
+      lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`;
+      lossEl.className = 'text-base font-bold font-mono text-rose-400 mt-1';
+    }
+    if (badge) {
+      badge.className = 'px-2 py-0.5 rounded text-[10px] font-mono font-semibold bg-rose-950 text-rose-300 border border-rose-800/60';
+      badge.innerText = 'UNREACHABLE';
+    }
 
-    showToast('Ping Finished', `Sent ${data.packets_sent}, received ${data.packets_recv} (${data.packet_loss_pct}% loss)`, data.packet_loss_pct === 0 ? 'success' : 'info');
-  } catch (err) {
-    showToast('Ping Error', err.message, 'error');
-  } finally {
-    if (btn) btn.disabled = false;
-    if (spinner) spinner.classList.add('hidden');
+    if (tbody) {
+      const tr = document.createElement('tr');
+      tr.className = 'hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs';
+      tr.innerHTML = `
+        <td class="py-2 px-3 text-slate-400">#${evt.seq}</td>
+        <td class="py-2 px-3 font-semibold text-rose-400">${escapeHtml(evt.from || 'Gateway')}</td>
+        <td class="py-2 px-3 text-slate-600">--</td>
+        <td class="py-2 px-3 text-slate-600">--</td>
+        <td class="py-2 px-3 font-bold text-rose-400 text-xs">${escapeHtml(evt.message || 'Unreachable')}</td>
+        <td class="py-2 px-3 text-right"><span class="px-2 py-0.5 rounded text-[10px] font-bold bg-rose-950 text-rose-300 border border-rose-800/60">UNREACH</span></td>
+      `;
+      tbody.appendChild(tr);
+    }
+  } else if (evt.type === 'stats') {
+    if (sentEl && evt.packets_sent !== undefined) sentEl.innerText = evt.packets_sent;
+    if (recvEl && evt.packets_recv !== undefined) recvEl.innerText = evt.packets_recv;
+    if (lossEl && evt.packet_loss_pct !== undefined) lossEl.innerText = `${evt.packet_loss_pct.toFixed(0)}%`;
+    if (avgEl && evt.avg_rtt_ms !== undefined) avgEl.innerText = `${evt.avg_rtt_ms.toFixed(2)} ms`;
+    if (minMaxEl && evt.min_rtt_ms !== undefined && evt.max_rtt_ms !== undefined) {
+      minMaxEl.innerText = `${evt.min_rtt_ms.toFixed(1)} / ${evt.max_rtt_ms.toFixed(1)} ms`;
+    }
+  } else if (evt.type === 'done') {
+    showToast('Ping Finished', `Sent ${evt.packets_sent}, received ${evt.packets_recv} (${evt.packet_loss_pct?.toFixed(0)}% loss)`, evt.packet_loss_pct === 0 ? 'success' : 'info');
   }
 }
 
 async function executeTraceroute(e) {
   if (e) e.preventDefault();
+
+  if (tracerouteAbortController) {
+    tracerouteAbortController.abort();
+    tracerouteAbortController = null;
+    resetTracerouteButton();
+    showToast('Traceroute Stopped', 'Traceroute stopped by user', 'info');
+    return;
+  }
+
   const target = document.getElementById('tools-tr-target')?.value?.trim();
   const maxHops = parseInt(document.getElementById('tools-tr-maxhops')?.value || '15', 10);
   const protocol = document.getElementById('tools-tr-proto')?.value || 'icmp';
@@ -7231,13 +7411,15 @@ async function executeTraceroute(e) {
     return;
   }
 
-  const btn = document.getElementById('tools-tr-btn');
-  const spinner = document.getElementById('tools-tr-spinner');
-  if (btn) btn.disabled = true;
-  if (spinner) spinner.classList.remove('hidden');
+  clearTracerouteTable();
+  const resCard = document.getElementById('tools-tr-results');
+  if (resCard) resCard.classList.remove('hidden');
+
+  tracerouteAbortController = new AbortController();
+  setTracerouteRunningButton();
 
   try {
-    const res = await fetch(`${API_BASE}/tools/traceroute`, {
+    const res = await fetch(`${API_BASE}/tools/traceroute/stream`, {
       method: 'POST',
       headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({
@@ -7246,59 +7428,91 @@ async function executeTraceroute(e) {
         protocol,
         interface: iface,
         resolve
-      })
+      }),
+      signal: tracerouteAbortController.signal
     });
 
-    const data = await res.json();
     if (!res.ok) {
-      showToast('Traceroute Error', data.error || 'Failed to execute traceroute', 'error');
+      const errText = await res.text();
+      showToast('Traceroute Error', errText || 'Failed to start traceroute', 'error');
+      resetTracerouteButton();
+      tracerouteAbortController = null;
       return;
     }
 
-    const resCard = document.getElementById('tools-tr-results');
-    if (resCard) resCard.classList.remove('hidden');
+    const reader = res.body.getReader();
+    const decoder = new TextDecoder('utf-8');
+    let buffer = '';
 
-    const hopsCount = Array.isArray(data.hops) ? data.hops.length : 0;
-    const statHops = document.getElementById('tools-tr-hops-count');
-    if (statHops) statHops.innerText = `${hopsCount} Hops`;
+    while (true) {
+      const { done, value } = await reader.read();
+      if (done) break;
 
-    const tbody = document.getElementById('tools-tr-tbody');
-    if (tbody) {
-      if (hopsCount > 0) {
-        tbody.innerHTML = data.hops.map(h => {
-          const isTimeout = h.status === 'timeout' || (!h.ip && !h.host);
-          const rtt1 = Array.isArray(h.times_ms) && h.times_ms[0] ? `${h.times_ms[0]} ms` : (isTimeout ? '*' : '--');
-          const rtt2 = Array.isArray(h.times_ms) && h.times_ms[1] ? `${h.times_ms[1]} ms` : (isTimeout ? '*' : '--');
-          const displayHost = h.host && h.host !== h.ip ? `${escapeHtml(h.host)} (${escapeHtml(h.ip || '')})` : escapeHtml(h.ip || (isTimeout ? 'Request timed out' : '--'));
+      buffer += decoder.decode(value, { stream: true });
+      const blocks = buffer.split('\n\n');
+      buffer = blocks.pop() || '';
 
-          return `
-            <tr class="hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs ${isTimeout ? 'opacity-60' : ''}">
-              <td class="py-2.5 px-3 text-slate-300 font-bold">${h.hop}</td>
-              <td class="py-2.5 px-3 font-semibold ${isTimeout ? 'text-slate-500 italic' : 'text-cyan-300'}">${displayHost}</td>
-              <td class="py-2.5 px-3 font-bold ${isTimeout ? 'text-slate-500' : 'text-amber-300'}">${escapeHtml(rtt1)}</td>
-              <td class="py-2.5 px-3 font-bold ${isTimeout ? 'text-slate-500' : 'text-amber-300'}">${escapeHtml(rtt2)}</td>
-              <td class="py-2.5 px-3 text-right">
-                ${isTimeout 
-                  ? '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">TIMEOUT</span>' 
-                  : '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">OK</span>'}
-              </td>
-            </tr>
-          `;
-        }).join('');
-      } else {
-        tbody.innerHTML = `<tr><td colspan="5" class="p-4 text-center text-xs text-rose-400 font-mono">No route or hops discovered</td></tr>`;
+      for (const block of blocks) {
+        const trimmed = block.trim();
+        if (!trimmed) continue;
+        const line = trimmed.startsWith('data: ') ? trimmed.slice(6) : trimmed;
+        try {
+          const evt = JSON.parse(line);
+          handleLiveTracerouteEvent(evt);
+        } catch (parseErr) {
+          console.warn('Traceroute SSE parse error:', parseErr, line);
+        }
       }
     }
-
-    const rawEl = document.getElementById('tools-tr-raw');
-    if (rawEl) rawEl.innerText = data.raw_output || 'No output';
-
-    showToast('Traceroute Finished', `Traced ${hopsCount} hops to ${target}`, 'info');
   } catch (err) {
-    showToast('Traceroute Error', err.message, 'error');
+    if (err.name === 'AbortError') {
+      // User aborted normally
+    } else {
+      showToast('Traceroute Error', err.message, 'error');
+    }
   } finally {
-    if (btn) btn.disabled = false;
-    if (spinner) spinner.classList.add('hidden');
+    tracerouteAbortController = null;
+    resetTracerouteButton();
+  }
+}
+
+function handleLiveTracerouteEvent(evt) {
+  const statHops = document.getElementById('tools-tr-hops-count');
+  const tbody = document.getElementById('tools-tr-tbody');
+  const rawEl = document.getElementById('tools-tr-raw');
+
+  if (evt.raw && rawEl) {
+    rawEl.innerText += (rawEl.innerText ? '\n' : '') + evt.raw;
+    rawEl.scrollTop = rawEl.scrollHeight;
+  }
+
+  if (evt.hops_count !== undefined && statHops) {
+    statHops.innerText = `${evt.hops_count} Hops`;
+  }
+
+  if (evt.type === 'hop' && evt.hop && tbody) {
+    const h = evt.hop;
+    const isTimeout = h.status === 'timeout' || (!h.ip && !h.host);
+    const rtt1 = Array.isArray(h.times_ms) && h.times_ms[0] ? `${h.times_ms[0]} ms` : (isTimeout ? '*' : '--');
+    const rtt2 = Array.isArray(h.times_ms) && h.times_ms[1] ? `${h.times_ms[1]} ms` : (isTimeout ? '*' : '--');
+    const displayHost = h.host && h.host !== h.ip ? `${escapeHtml(h.host)} (${escapeHtml(h.ip || '')})` : escapeHtml(h.ip || (isTimeout ? 'Request timed out' : '--'));
+
+    const tr = document.createElement('tr');
+    tr.className = `hover:bg-slate-800/40 border-b border-slate-800/60 font-mono text-xs ${isTimeout ? 'opacity-60' : ''}`;
+    tr.innerHTML = `
+      <td class="py-2.5 px-3 text-slate-300 font-bold">${h.hop}</td>
+      <td class="py-2.5 px-3 font-semibold ${isTimeout ? 'text-slate-500 italic' : 'text-cyan-300'}">${displayHost}</td>
+      <td class="py-2.5 px-3 font-bold ${isTimeout ? 'text-slate-500' : 'text-amber-300'}">${escapeHtml(rtt1)}</td>
+      <td class="py-2.5 px-3 font-bold ${isTimeout ? 'text-slate-500' : 'text-amber-300'}">${escapeHtml(rtt2)}</td>
+      <td class="py-2.5 px-3 text-right">
+        ${isTimeout 
+          ? '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400 border border-slate-700">TIMEOUT</span>' 
+          : '<span class="px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-950 text-emerald-300 border border-emerald-800/60">OK</span>'}
+      </td>
+    `;
+    tbody.appendChild(tr);
+  } else if (evt.type === 'done') {
+    showToast('Traceroute Finished', `Traced ${evt.hops_count || 0} hops`, 'info');
   }
 }
 

+ 18 - 9
internal/web/static/index.html

@@ -2398,10 +2398,13 @@
                   <div>
                     <label class="block text-xs font-semibold text-slate-300 mb-1.5">Count</label>
                     <select id="tools-ping-count" class="w-full bg-slate-800 border border-slate-700 rounded-lg px-3 py-2 text-xs text-white font-mono">
+                      <option value="0">Continuous (Until Stopped)</option>
                       <option value="4" selected>4 packets</option>
                       <option value="5">5 packets</option>
                       <option value="10">10 packets</option>
                       <option value="20">20 packets</option>
+                      <option value="50">50 packets</option>
+                      <option value="100">100 packets</option>
                     </select>
                   </div>
                   <div>
@@ -2425,8 +2428,8 @@
                   </div>
 
                   <button type="submit" id="tools-ping-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
-                    <span id="tools-ping-spinner" class="hidden">↻</span>
-                    <span>Start Ping</span>
+                    <span id="tools-ping-spinner" class="hidden animate-spin">↻</span>
+                    <span id="tools-ping-btn-text">Start Ping</span>
                   </button>
                 </div>
               </form>
@@ -2461,8 +2464,11 @@
               <!-- Replies Table -->
               <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
                 <div class="p-3 border-b border-slate-800 flex items-center justify-between">
-                  <span class="text-xs font-bold text-white">ICMP Echo Replies</span>
-                  <span id="tools-ping-status-badge" class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold"></span>
+                  <div class="flex items-center gap-2.5">
+                    <span class="text-xs font-bold text-white">ICMP Echo Replies</span>
+                    <span id="tools-ping-status-badge" class="px-2 py-0.5 rounded text-[10px] font-mono font-semibold"></span>
+                  </div>
+                  <button type="button" onclick="clearPingTable()" class="text-[11px] text-slate-400 hover:text-white px-2 py-0.5 rounded bg-slate-800/60 hover:bg-slate-800 border border-slate-700/60 transition">Clear</button>
                 </div>
                 <div class="overflow-x-auto">
                   <table class="w-full text-left text-xs font-mono">
@@ -2524,8 +2530,8 @@
                   </label>
 
                   <button type="submit" id="tools-tr-btn" class="px-5 py-2 rounded-lg font-semibold text-xs bg-amber-500 hover:bg-amber-400 text-slate-950 shadow-lg shadow-amber-500/20 transition flex items-center gap-2">
-                    <span id="tools-tr-spinner" class="hidden">↻</span>
-                    <span>Start Traceroute</span>
+                    <span id="tools-tr-spinner" class="hidden animate-spin">↻</span>
+                    <span id="tools-tr-btn-text">Start Traceroute</span>
                   </button>
                 </div>
               </form>
@@ -2534,8 +2540,11 @@
             <div id="tools-tr-results" class="hidden space-y-4">
               <div class="bg-slate-900/90 border border-slate-800 rounded-xl overflow-hidden">
                 <div class="p-3 border-b border-slate-800 flex items-center justify-between">
-                  <span class="text-xs font-bold text-white">Route Hops</span>
-                  <span id="tools-tr-hops-count" class="text-xs font-mono text-slate-400">0 Hops</span>
+                  <div class="flex items-center gap-2.5">
+                    <span class="text-xs font-bold text-white">Route Hops</span>
+                    <span id="tools-tr-hops-count" class="text-xs font-mono text-slate-400">0 Hops</span>
+                  </div>
+                  <button type="button" onclick="clearTracerouteTable()" class="text-[11px] text-slate-400 hover:text-white px-2 py-0.5 rounded bg-slate-800/60 hover:bg-slate-800 border border-slate-700/60 transition">Clear</button>
                 </div>
                 <div class="overflow-x-auto">
                   <table class="w-full text-left text-xs font-mono">
@@ -4966,6 +4975,6 @@
     </div>
   </div>
 
-  <script src="app.js?v=3.8"></script>
+  <script src="app.js?v=3.9"></script>
 </body>
 </html>