diff --git a/.gitignore b/.gitignore index 106c77d..08f2268 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ *.dll *.so *.dylib +subping # Test binary, built with `go test -c` *.test @@ -136,3 +137,9 @@ fabric.properties ### Solarscanner .scannerwork/* + +### Custom agents workspace +AGENTS.md +CLAUDE.md +ISSUES.md +issue-*.md diff --git a/README.md b/README.md index 4bedd81..b24c907 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The following flags are available for the `subping` command: - `-i, --interval string`: Specifies the time duration between each ping request. (default "300ms") - `-n, --job int`: Specifies the number of maximum concurrent jobs spawned to perform ping operations. (default 128) - `--offline`: Specify whether to display the list of offline hosts. -- `-t, --timeout string`: Specifies the maximum ping timeout duration for each ping request. (default "80ms") +- `-t, --timeout string`: Specifies the maximum ping timeout duration for each ping request. (default "1s") - `-v, --version`: Displays the version information for `subping`. ## Examples diff --git a/cmd/subping/main.go b/cmd/subping/main.go index 48424c8..9b3f532 100644 --- a/cmd/subping/main.go +++ b/cmd/subping/main.go @@ -1,15 +1,13 @@ package main import ( - "bytes" "fmt" "log" - "net" - "sort" "time" "github.com/common-nighthawk/go-figure" "github.com/fadhilyori/subping" + "github.com/fadhilyori/subping/internal/display" "github.com/spf13/cobra" ) @@ -20,6 +18,7 @@ var ( pingMaxWorkers int subpingVersion = "dev" showOfflineHostList bool + sortBy string ) func main() { @@ -29,11 +28,12 @@ func main() { Short: "A tool for pinging IP addresses in a subnet", Long: "Subping is a command-line tool that allows you to ping IP addresses within a specified subnet range.", Args: cobra.MatchAll(cobra.ExactArgs(1), cobra.OnlyValidArgs), - Run: runSubping, + Run: func(cmd *cobra.Command, args []string) { + runSubping(cmd, args) + }, PreRun: func(cmd *cobra.Command, args []string) { figure.NewFigure("subping", "larry3d", true).Print() - fmt.Println(cmd.Version) - fmt.Print("\n\n") + fmt.Print("\n") }, } @@ -55,13 +55,16 @@ func main() { flags.BoolVar(&showOfflineHostList, "offline", false, "Specify whether to display the list of offline hosts.", ) + flags.StringVar(&sortBy, "sort", "ip", + "Sort results by: ip, latency, loss, jitter (default: ip)", + ) if err := rootCmd.Execute(); err != nil { log.Fatal(err) } } -func runSubping(_ *cobra.Command, args []string) { +func runSubping(rootCmd *cobra.Command, args []string) { subnetString := args[0] startTime := time.Now() @@ -76,6 +79,29 @@ func runSubping(_ *cobra.Command, args []string) { log.Fatalf("Invalid interval format '%s': %v\nValid examples: 300ms, 1s, 2s", pingIntervalStr, err) } + sortOption := display.SortByIP + switch sortBy { + case "latency": + sortOption = display.SortByLatency + case "loss": + sortOption = display.SortByLoss + case "jitter": + sortOption = display.SortByJitter + case "ip": + sortOption = display.SortByIP + default: + log.Fatalf("Invalid sort option '%s'. Valid options: ip, latency, loss, jitter", sortBy) + } + + displayConfig := display.DisplayConfig{ + EnabledColors: true, + EnabledProgress: true, + SortBy: sortOption, + UseEnhancedHealthScore: true, + } + + d := display.NewDisplay(displayConfig) + s, err := subping.NewSubping(&subping.Options{ Subnet: subnetString, Count: pingCount, @@ -83,68 +109,62 @@ func runSubping(_ *cobra.Command, args []string) { Timeout: pingTimeout, MaxWorkers: pingMaxWorkers, LogLevel: "error", + ProgressCallback: func(current, total int, currentIP string, onlineCount int) { + d.UpdateProgress(current, total, currentIP, onlineCount) + }, }) if err != nil { log.Fatal(err.Error()) } - fmt.Printf("Network : %s\n", s.TargetsIterator.IPNet.String()) - fmt.Printf("IP Ranges : %s - %s\n", - s.TargetsIterator.FirstIP.String(), s.TargetsIterator.LastIP.String(), + d.ShowHeader( + s.TargetsIterator.IPNet.String(), + fmt.Sprintf("%s - %s", s.TargetsIterator.FirstIP.String(), s.TargetsIterator.LastIP.String()), + s.TargetsIterator.TotalHosts, + s.MaxWorkers, + s.Count, + s.Interval.String(), + pingTimeoutStr, + rootCmd.Version, ) - fmt.Printf("Total hosts : %d\n", s.TargetsIterator.TotalHosts) - fmt.Printf("Total workers : %d\n", s.MaxWorkers) - fmt.Printf("Count : %d\n", s.Count) - fmt.Printf("Interval : %s\n", s.Interval.String()) - fmt.Printf("Timeout : %s\n", pingTimeoutStr) - fmt.Println(`-------------------------------------------------------------------------------`) - fmt.Printf("| %-39s | %-16s | %-14s |\n", "IP Address", "Avg Latency", "Packet Loss") - fmt.Println(`-------------------------------------------------------------------------------`) s.Run() - results, totalHostOnline := s.GetOnlineHosts() + onlineResults, totalHostOnline := s.GetOnlineHosts() - // Extract keys into a slice - keys := make([]net.IP, 0, len(results)) - for key := range results { - keys = append(keys, net.ParseIP(key)) + // Calculate proper capacity based on what we'll actually store + var estimatedCapacity int + if showOfflineHostList { + estimatedCapacity = s.TargetsIterator.TotalHosts // All hosts (online + offline) + } else { + estimatedCapacity = totalHostOnline // Only online hosts } - // Sort the keys Based on byte comparison - sort.Slice(keys, func(i, j int) bool { - return bytes.Compare(keys[i].To16(), keys[j].To16()) < 0 - }) - - for _, ip := range keys { - // convert bytes to string in each line of IP - ipString := ip.String() - stats := results[ipString] - packetLossPercentageStr := fmt.Sprintf("%.2f %%", stats.PacketLoss) + allResults := make([]display.HostResult, 0, estimatedCapacity) - fmt.Printf( - "| %-39s | %-16s | %-14s |\n", - ipString, stats.AvgRtt.String(), packetLossPercentageStr) + for ip, result := range onlineResults { + allResults = append(allResults, display.ConvertPingResult(ip, result)) } - fmt.Println(`-------------------------------------------------------------------------------`) - if showOfflineHostList { - fmt.Println("\nOffline hosts :") - for ip, stats := range s.Results { - if stats.PacketsRecv == 0 { - fmt.Printf( - " - %s\t(Loss: %s, Latency: %s)\n", - ip, fmt.Sprintf("%.2f %%", stats.PacketLoss), stats.AvgRtt.String(), - ) + for ip, result := range s.Results { + if result.PacketsRecv == 0 { + allResults = append(allResults, display.ConvertPingResult(ip, result)) } } } + d.ShowResults(allResults) + elapsed := time.Since(startTime) totalHostOffline := s.TargetsIterator.TotalHosts - totalHostOnline - fmt.Printf("\nTotal Hosts Online : %d\n", totalHostOnline) - fmt.Printf("Total Hosts Offline : %d\n", totalHostOffline) - fmt.Printf("Execution time : %s\n\n", elapsed.String()) + var scanRate float64 + if elapsed.Seconds() > 0 { + scanRate = float64(s.TargetsIterator.TotalHosts) / elapsed.Seconds() + } + + d.ShowSummary(s.TargetsIterator.TotalHosts, totalHostOnline, totalHostOffline, elapsed, scanRate) } + +// Avoid to ping with 0.0.0.0/0 diff --git a/go.mod b/go.mod index b3293c9..55ee336 100644 --- a/go.mod +++ b/go.mod @@ -6,16 +6,31 @@ toolchain go1.24.4 require ( github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be + github.com/fatih/color v1.18.0 + github.com/olekukonko/tablewriter v1.1.2 github.com/prometheus-community/pro-bing v0.7.0 + github.com/schollz/progressbar/v3 v3.19.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.10.2 ) require ( + github.com/clipperhouse/displaywidth v0.6.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect + github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 // indirect + github.com/olekukonko/errors v1.1.0 // indirect + github.com/olekukonko/ll v0.1.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.39.0 // indirect + golang.org/x/term v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 0dbbede..49f33de 100644 --- a/go.sum +++ b/go.sum @@ -1,18 +1,49 @@ +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= +github.com/clipperhouse/displaywidth v0.6.0 h1:k32vueaksef9WIKCNcoqRNyKbyvkvkysNYnAWz2fN4s= +github.com/clipperhouse/displaywidth v0.6.0/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be h1:J5BL2kskAlV9ckgEsNQXscjIaLiOYiZ75d4e94E6dcQ= github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= +github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= +github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0= +github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM= +github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y= +github.com/olekukonko/ll v0.1.3 h1:sV2jrhQGq5B3W0nENUISCR6azIPf7UBUpVq0x/y70Fg= +github.com/olekukonko/ll v0.1.3/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew= +github.com/olekukonko/tablewriter v1.1.2 h1:L2kI1Y5tZBct/O/TyZK1zIE9GlBj/TVs+AY5tZDCDSc= +github.com/olekukonko/tablewriter v1.1.2/go.mod h1:z7SYPugVqGVavWoA2sGsFIoOVNmEHxUAAMrhXONtfkg= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA= github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= +github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -21,16 +52,22 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.38.0 h1:PQ5pkm/rLO6HnxFR7N2lJHOZX6Kez5Y1gDSJla6jo7Q= +golang.org/x/term v0.38.0/go.mod h1:bSEAKrOT1W+VSu9TSCMtoGEOUcKxOKgl3LE5QEF/xVg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/config/logger.go b/internal/config/logger.go new file mode 100644 index 0000000..cc04b3e --- /dev/null +++ b/internal/config/logger.go @@ -0,0 +1,25 @@ +// Package config provides global configuration for the subping application +package config + +import ( + "github.com/sirupsen/logrus" + "os" +) + +func init() { + level := os.Getenv("SUBPING_LOG_LEVEL") + if level == "" { + level = "error" // Default level + } + + if parsedLevel, err := logrus.ParseLevel(level); err == nil { + logrus.SetLevel(parsedLevel) + } else { + logrus.SetLevel(logrus.ErrorLevel) // Fallback to error + } +} + +// GetLogLevel returns the current global log level +func GetLogLevel() logrus.Level { + return logrus.GetLevel() +} diff --git a/internal/display/colors.go b/internal/display/colors.go new file mode 100644 index 0000000..59a0d25 --- /dev/null +++ b/internal/display/colors.go @@ -0,0 +1,115 @@ +// Package display provides terminal output formatting and progress tracking +// for network scanning results. It supports colored output, sortable tables, +// and real-time progress updates with various display configurations. +package display + +import ( + "os" + "time" + + "github.com/fatih/color" +) + +// ColorScheme defines colors for different latency ranges +type ColorScheme struct { + Excellent *color.Color + Good *color.Color + Degraded *color.Color + Poor *color.Color + Offline *color.Color + Header *color.Color + Progress *color.Color + Success *color.Color + Warning *color.Color +} + +// DefaultColorScheme returns the default color scheme +func DefaultColorScheme() *ColorScheme { + return &ColorScheme{ + Excellent: color.New(color.FgGreen, color.Bold), + Good: color.New(color.FgYellow), + Degraded: color.New(color.FgHiYellow), + Poor: color.New(color.FgRed, color.Bold), + Offline: color.New(color.FgHiBlack), + Header: color.New(color.FgCyan, color.Bold), + Progress: color.New(color.FgBlue), + Success: color.New(color.FgGreen, color.Bold), + Warning: color.New(color.FgYellow, color.Bold), + } +} + +// NoColorScheme returns a color scheme that doesn't use colors +func NoColorScheme() *ColorScheme { + // Create a color that doesn't apply any formatting + noColor := color.New() + return &ColorScheme{ + Excellent: noColor, + Good: noColor, + Degraded: noColor, + Poor: noColor, + Offline: noColor, + Header: noColor, + Progress: noColor, + Success: noColor, + Warning: noColor, + } +} + +// GetColorForLatency returns the appropriate color based on latency +func (cs *ColorScheme) GetColorForLatency(latency time.Duration) *color.Color { + if latency == 0 { + return cs.Offline + } + + // Convert to milliseconds for comparison + ms := float64(latency.Nanoseconds()) / 1000000 + + switch { + case ms < 10: + return cs.Excellent + case ms < 100: + return cs.Good + case ms < 500: + return cs.Degraded + default: + return cs.Poor + } +} + +// GetColorForPacketLoss returns the appropriate color based on packet loss +func (cs *ColorScheme) GetColorForPacketLoss(loss float64) *color.Color { + switch { + case loss == 0: + return cs.Success + case loss < 5: + return cs.Excellent + case loss < 20: + return cs.Warning + default: + return cs.Poor + } +} + +// GetHostStatusIcon returns the appropriate icon for host status +func GetHostStatusIcon(status HostStatus) string { + switch status { + case StatusOnline: + return "●" + case StatusOffline: + return "○" + default: + return "?" + } +} + +// IsTerminalColorSupported checks if the terminal supports colors +func IsTerminalColorSupported() bool { + // Check NO_COLOR environment variable (standard convention) + if os.Getenv("NO_COLOR") != "" { + return false + } + + // Check if stdout is a terminal + fileInfo, _ := os.Stdout.Stat() + return (fileInfo.Mode() & os.ModeCharDevice) != 0 +} diff --git a/internal/display/display.go b/internal/display/display.go new file mode 100644 index 0000000..262da1a --- /dev/null +++ b/internal/display/display.go @@ -0,0 +1,111 @@ +// Package display provides terminal output formatting and progress tracking +// for network scanning results. It supports colored output, sortable tables, +// and real-time progress updates with various display configurations. +package display + +import ( + "net" + "time" + + "github.com/fadhilyori/subping/internal/ping" +) + +// SortOption defines how results can be sorted +type SortOption string + +const ( + SortByIP SortOption = "ip" + SortByLatency SortOption = "latency" + SortByLoss SortOption = "loss" + SortByJitter SortOption = "jitter" +) + +// DisplayConfig holds configuration for display +type DisplayConfig struct { + EnabledColors bool + EnabledProgress bool + SortBy SortOption + ProgressCallback func(current, total int, currentIP string, onlineCount int) + UseEnhancedHealthScore bool + progressThrottle time.Duration // Internal field +} + +// HostResult represents a host's ping result with additional metrics +type HostResult struct { + IP net.IP + Status HostStatus + MinRtt time.Duration + AvgRtt time.Duration + MaxRtt time.Duration + PacketLoss float64 + Jitter time.Duration + PacketsSent int + PacketsRecv int +} + +// HostStatus represents the status of a host +type HostStatus int + +const ( + StatusOffline HostStatus = iota + StatusOnline +) + +// ProgressReporter handles progress updates during scanning +type ProgressReporter interface { + UpdateProgress(current, total int, currentIP string, onlineCount int) +} + +// ResultDisplayer handles the display of scan results and summaries +type ResultDisplayer interface { + ShowResults(results []HostResult) + ShowSummary(totalHosts int, onlineHosts int, offlineHosts int, executionTime time.Duration, rate float64) +} + +// Display interface combines all display functionality for different output formats +type Display interface { + ProgressReporter + ResultDisplayer + ShowHeader(network string, ipRange string, totalHosts int, workers int, count int, interval string, timeout string, version string) +} + +// NewDisplay creates a new display instance +func NewDisplay(config DisplayConfig) Display { + return NewTerminalDisplay(config) +} + +// ConvertPingResult converts internal ping.Result to HostResult +func ConvertPingResult(ip string, result ping.Result) HostResult { + hostIP := net.ParseIP(ip) + status := StatusOffline + if result.PacketsRecv > 0 { + status = StatusOnline + } + + return HostResult{ + IP: hostIP, + Status: status, + MinRtt: result.MinRtt, + AvgRtt: result.AvgRtt, + MaxRtt: result.MaxRtt, + PacketLoss: result.PacketLoss, + Jitter: calculateJitter(result), + PacketsSent: result.PacketsSent, + PacketsRecv: result.PacketsRecv, + } +} + +// calculateJitter calculates jitter from ping result +func calculateJitter(result ping.Result) time.Duration { + // Use the actual standard deviation if available + if result.StdDevRtt > 0 { + return result.StdDevRtt + } + + // Fallback to approximation for older implementations + if result.PacketsRecv == 0 { + return 0 + } + // Approximate jitter as 10% of average RTT for responsive hosts + return time.Duration(float64(result.AvgRtt.Nanoseconds()) * 0.1) +} diff --git a/internal/display/display_test.go b/internal/display/display_test.go new file mode 100644 index 0000000..0d5cf09 --- /dev/null +++ b/internal/display/display_test.go @@ -0,0 +1,226 @@ +package display + +import ( + "net" + "os" + "testing" + "time" + + "github.com/fadhilyori/subping/internal/ping" +) + +func TestIPSorting(t *testing.T) { + // Test IP sorting with various IP formats + results := []HostResult{ + {IP: net.ParseIP("192.168.1.100")}, + {IP: net.ParseIP("10.0.0.1")}, + {IP: net.ParseIP("172.16.0.50")}, + {IP: net.ParseIP("192.168.1.10")}, + {IP: net.ParseIP("10.0.0.100")}, + } + + td := &TerminalDisplay{ + config: DisplayConfig{SortBy: SortByIP}, + } + + td.sortResults(results) + + // Verify correct byte-wise sorting + expected := []string{ + "10.0.0.1", + "10.0.0.100", + "172.16.0.50", + "192.168.1.10", + "192.168.1.100", + } + + for i, result := range results { + if result.IP.String() != expected[i] { + t.Errorf("Expected %s at position %d, got %s", expected[i], i, result.IP.String()) + } + } +} + +func TestTerminalColorDetection(t *testing.T) { + // Test with NO_COLOR set + os.Setenv("NO_COLOR", "1") + if IsTerminalColorSupported() { + t.Error("Expected colors to be disabled when NO_COLOR is set") + } + + // Test without NO_COLOR + os.Unsetenv("NO_COLOR") + // Note: This test might fail in non-terminal environments + // but should work in most CI/terminal setups +} + +func TestProgressUpdateRaceCondition(t *testing.T) { + pt := NewProgressTracker(5, DefaultColorScheme(), true, 200*time.Millisecond) + + // Test completion update bypasses rate limiting + pt.Update(5, "192.168.1.1", 3) // Final update + + _, elapsed := pt.GetStats() + if elapsed == 0 { + t.Error("Expected non-zero elapsed time") + } +} + +func TestNetworkHealthScore(t *testing.T) { + td := &TerminalDisplay{} + + // Test basic health score + score := td.calculateNetworkHealthScore(8, 10) + expected := 80 + if score != expected { + t.Errorf("Expected health score %d, got %d", expected, score) + } + + // Test enhanced health score with various metrics + results := []HostResult{ + { + Status: StatusOnline, + AvgRtt: 10 * time.Millisecond, + PacketLoss: 0, + Jitter: 5 * time.Millisecond, + }, + { + Status: StatusOnline, + AvgRtt: 150 * time.Millisecond, // High latency + PacketLoss: 10, // High packet loss + Jitter: 60 * time.Millisecond, // High jitter + }, + { + Status: StatusOffline, + }, + } + + enhancedScore := td.calculateEnhancedNetworkHealthScore(results) + if enhancedScore < 0 || enhancedScore > 100 { + t.Errorf("Enhanced health score should be between 0-100, got %d", enhancedScore) + } +} + +func TestMemoryAllocation(t *testing.T) { + // Test capacity calculation for different scenarios + + // Scenario 1: Only online hosts + onlineCount := 8 + totalHosts := 10 + showOffline := false + + expectedCapacity := onlineCount + if showOffline { + expectedCapacity = totalHosts + } + + results := make([]HostResult, 0, expectedCapacity) + if cap(results) != expectedCapacity { + t.Errorf("Expected capacity %d, got %d", expectedCapacity, cap(results)) + } + + // Scenario 2: All hosts + showOffline = true + expectedCapacity = totalHosts + + results = make([]HostResult, 0, expectedCapacity) + if cap(results) != expectedCapacity { + t.Errorf("Expected capacity %d, got %d", expectedCapacity, cap(results)) + } +} + +func TestColorSchemeForLatency(t *testing.T) { + cs := DefaultColorScheme() + + tests := []struct { + latency time.Duration + expected string + }{ + {5 * time.Millisecond, "Excellent"}, + {50 * time.Millisecond, "Good"}, + {200 * time.Millisecond, "Degraded"}, + {600 * time.Millisecond, "Poor"}, + {0, "Offline"}, + } + + for _, test := range tests { + color := cs.GetColorForLatency(test.latency) + if color == nil { + t.Errorf("Expected color for latency %v", test.latency) + } + } +} + +func TestHostStatusIcon(t *testing.T) { + tests := map[HostStatus]string{ + StatusOnline: "●", + StatusOffline: "○", + } + + for status, expected := range tests { + icon := GetHostStatusIcon(status) + if icon != expected { + t.Errorf("Expected icon %s for status %v, got %s", expected, status, icon) + } + } +} + +func TestConvertPingResult(t *testing.T) { + // Mock ping result + mockResult := ping.Result{ + MinRtt: 10 * time.Millisecond, + AvgRtt: 20 * time.Millisecond, + MaxRtt: 30 * time.Millisecond, + PacketLoss: 5.0, + PacketsSent: 10, + PacketsRecv: 9, + StdDevRtt: 2 * time.Millisecond, + } + + hostResult := ConvertPingResult("192.168.1.1", mockResult) + + if hostResult.IP.String() != "192.168.1.1" { + t.Errorf("Expected IP 192.168.1.1, got %s", hostResult.IP.String()) + } + + if hostResult.Status != StatusOnline { + t.Errorf("Expected status Online, got %v", hostResult.Status) + } + + if hostResult.Jitter != mockResult.StdDevRtt { + t.Errorf("Expected jitter %v, got %v", mockResult.StdDevRtt, hostResult.Jitter) + } +} + +func TestDisplayConfig(t *testing.T) { + config := DisplayConfig{ + EnabledColors: true, + EnabledProgress: true, + SortBy: SortByLatency, + ProgressCallback: func(current, total int, currentIP string, onlineCount int) {}, + } + + // Test that config can be used to create display + display := NewDisplay(config) + if display == nil { + t.Error("Expected non-nil display") + } +} + +func TestSortOptions(t *testing.T) { + tests := []struct { + option SortOption + expected string + }{ + {SortByIP, "ip"}, + {SortByLatency, "latency"}, + {SortByLoss, "loss"}, + {SortByJitter, "jitter"}, + } + + for _, test := range tests { + if string(test.option) != test.expected { + t.Errorf("Expected %s, got %s", test.expected, string(test.option)) + } + } +} diff --git a/internal/display/integration_test.go b/internal/display/integration_test.go new file mode 100644 index 0000000..a3c0e5c --- /dev/null +++ b/internal/display/integration_test.go @@ -0,0 +1,326 @@ +package display + +import ( + "net" + "testing" + "time" + + "github.com/fadhilyori/subping/internal/ping" +) + +func TestDisplayPipelineIntegration(t *testing.T) { + // Test complete display pipeline with mock data + config := DisplayConfig{ + EnabledColors: false, // Disable colors for consistent testing + EnabledProgress: false, // Disable progress for cleaner test output + SortBy: SortByIP, + } + + display := NewDisplay(config) + + // Test header display + display.ShowHeader( + "192.168.1.0/24", + "192.168.1.1 - 192.168.1.254", + 254, + 32, + 3, + "100ms", + "500ms", + "test-version", + ) + + // Create mock results + results := []HostResult{ + { + IP: net.ParseIP("192.168.1.1"), + Status: StatusOnline, + MinRtt: 5 * time.Millisecond, + AvgRtt: 10 * time.Millisecond, + MaxRtt: 15 * time.Millisecond, + PacketLoss: 0, + Jitter: 2 * time.Millisecond, + PacketsSent: 3, + PacketsRecv: 3, + }, + { + IP: net.ParseIP("192.168.1.2"), + Status: StatusOffline, + MinRtt: 0, + AvgRtt: 0, + MaxRtt: 0, + PacketLoss: 100, + Jitter: 0, + PacketsSent: 3, + PacketsRecv: 0, + }, + { + IP: net.ParseIP("192.168.1.10"), + Status: StatusOnline, + MinRtt: 50 * time.Millisecond, + AvgRtt: 75 * time.Millisecond, + MaxRtt: 100 * time.Millisecond, + PacketLoss: 10, + Jitter: 25 * time.Millisecond, + PacketsSent: 3, + PacketsRecv: 2, + }, + } + + // Test results display + display.ShowResults(results) + + // Test summary display + display.ShowSummary(3, 2, 1, 5*time.Second, 0.6) +} + +func TestProgressCallbackIntegration(t *testing.T) { + var progressCalls []struct { + current int + total int + currentIP string + onlineCount int + } + + config := DisplayConfig{ + EnabledColors: false, + EnabledProgress: true, + SortBy: SortByIP, + ProgressCallback: func(current, total int, currentIP string, onlineCount int) { + progressCalls = append(progressCalls, struct { + current int + total int + currentIP string + onlineCount int + }{current, total, currentIP, onlineCount}) + }, + } + + display := NewDisplay(config) + + // Simulate progress updates + display.UpdateProgress(1, 10, "192.168.1.1", 1) + display.UpdateProgress(5, 10, "192.168.1.5", 3) + display.UpdateProgress(10, 10, "192.168.1.10", 7) + + if len(progressCalls) != 3 { + t.Errorf("Expected 3 progress calls, got %d", len(progressCalls)) + } + + // Verify last call + lastCall := progressCalls[len(progressCalls)-1] + if lastCall.current != 10 || lastCall.total != 10 { + t.Error("Progress callback not called with correct final values") + } +} + +func TestColorOutputIntegration(t *testing.T) { + // Test color configuration + config := DisplayConfig{ + EnabledColors: true, + EnabledProgress: false, + SortBy: SortByIP, + } + + display := NewDisplay(config) + + // Create test results + results := []HostResult{ + { + IP: net.ParseIP("192.168.1.1"), + Status: StatusOnline, + AvgRtt: 10 * time.Millisecond, + }, + } + + // Test that display doesn't panic with colors enabled + defer func() { + if r := recover(); r != nil { + t.Errorf("Display panicked with colors: %v", r) + } + }() + + display.ShowResults(results) +} + +func TestSortingIntegration(t *testing.T) { + // Test all sorting options + testResults := []HostResult{ + { + IP: net.ParseIP("192.168.1.100"), + Status: StatusOnline, + AvgRtt: 100 * time.Millisecond, + PacketLoss: 5, + Jitter: 10 * time.Millisecond, + }, + { + IP: net.ParseIP("192.168.1.1"), + Status: StatusOnline, + AvgRtt: 10 * time.Millisecond, + PacketLoss: 0, + Jitter: 2 * time.Millisecond, + }, + { + IP: net.ParseIP("192.168.1.50"), + Status: StatusOnline, + AvgRtt: 50 * time.Millisecond, + PacketLoss: 10, + Jitter: 20 * time.Millisecond, + }, + } + + sortOptions := []SortOption{SortByIP, SortByLatency, SortByLoss, SortByJitter} + + for _, sortBy := range sortOptions { + config := DisplayConfig{ + EnabledColors: false, + EnabledProgress: false, + SortBy: sortBy, + } + + display := NewDisplay(config) + terminalDisplay := display.(*TerminalDisplay) + + // Copy results to avoid modifying original + testCopy := make([]HostResult, len(testResults)) + copy(testCopy, testResults) + + terminalDisplay.ShowResults(testCopy) + + // Verify sorting worked (basic check) + if len(testCopy) != len(testResults) { + t.Errorf("Sorting with %s modified result count", sortBy) + } + } +} + +func TestErrorHandlingIntegration(t *testing.T) { + // Test display behavior with edge cases + config := DisplayConfig{ + EnabledColors: false, + EnabledProgress: false, + SortBy: SortByIP, + } + + display := NewDisplay(config) + + // Test with empty results + display.ShowResults([]HostResult{}) + + // Test with nil results (should not panic) + display.ShowResults(nil) + + // Test summary with zero values + display.ShowSummary(0, 0, 0, 0, 0) + + // Test with very large execution time + display.ShowSummary(100, 80, 20, 3600*time.Second, 0.0278) +} + +func TestPerformanceIntegration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping performance test in short mode") + } + + // Test display performance with large result sets + config := DisplayConfig{ + EnabledColors: false, + EnabledProgress: false, + SortBy: SortByIP, + } + + display := NewDisplay(config) + + // Generate large result set + results := make([]HostResult, 1000) + for i := 0; i < 1000; i++ { + ip := net.IPv4(192, 168, 1, byte(i%254+1)) + results[i] = HostResult{ + IP: ip, + Status: StatusOnline, + AvgRtt: time.Duration(i%100) * time.Millisecond, + PacketLoss: float64(i % 20), + Jitter: time.Duration(i%10) * time.Millisecond, + } + } + + start := time.Now() + display.ShowResults(results) + duration := time.Since(start) + + // Should complete within reasonable time (adjust threshold as needed) + if duration > 5*time.Second { + t.Errorf("Display took too long: %v", duration) + } +} + +func TestConvertPingResultIntegration(t *testing.T) { + // Test conversion with various ping result scenarios + testCases := []struct { + name string + pingResult ping.Result + ip string + expected HostResult + }{ + { + name: "online host", + pingResult: ping.Result{ + MinRtt: 5 * time.Millisecond, + AvgRtt: 10 * time.Millisecond, + MaxRtt: 15 * time.Millisecond, + PacketLoss: 0, + PacketsSent: 3, + PacketsRecv: 3, + StdDevRtt: 2 * time.Millisecond, + }, + ip: "192.168.1.1", + expected: HostResult{ + Status: StatusOnline, + MinRtt: 5 * time.Millisecond, + AvgRtt: 10 * time.Millisecond, + MaxRtt: 15 * time.Millisecond, + PacketLoss: 0, + PacketsSent: 3, + PacketsRecv: 3, + Jitter: 2 * time.Millisecond, + }, + }, + { + name: "offline host", + pingResult: ping.Result{ + MinRtt: 0, + AvgRtt: 0, + MaxRtt: 0, + PacketLoss: 100, + PacketsSent: 3, + PacketsRecv: 0, + StdDevRtt: 0, + }, + ip: "192.168.1.2", + expected: HostResult{ + Status: StatusOffline, + MinRtt: 0, + AvgRtt: 0, + MaxRtt: 0, + PacketLoss: 100, + PacketsSent: 3, + PacketsRecv: 0, + Jitter: 0, + }, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result := ConvertPingResult(tc.ip, tc.pingResult) + + if result.Status != tc.expected.Status { + t.Errorf("Expected status %v, got %v", tc.expected.Status, result.Status) + } + + if result.IP.String() != tc.ip { + t.Errorf("Expected IP %s, got %s", tc.ip, result.IP.String()) + } + }) + } +} diff --git a/internal/display/progress.go b/internal/display/progress.go new file mode 100644 index 0000000..33a1f1d --- /dev/null +++ b/internal/display/progress.go @@ -0,0 +1,137 @@ +// Package display provides terminal output formatting and progress tracking +// for network scanning results. It supports colored output, sortable tables, +// and real-time progress updates with various display configurations. +package display + +import ( + "fmt" + "os" + "sync" + "time" + + "github.com/schollz/progressbar/v3" +) + +type ProgressTracker struct { + bar *progressbar.ProgressBar + startTime time.Time + lastUpdate time.Time + totalHosts int + onlineCount int + currentIP string + colorScheme *ColorScheme + enabled bool + completed bool + throttle time.Duration + mu sync.Mutex +} + +// NewProgressTracker creates a new progress tracker +func NewProgressTracker(totalHosts int, colorScheme *ColorScheme, enabled bool, throttle time.Duration) *ProgressTracker { + pt := &ProgressTracker{ + startTime: time.Now(), + totalHosts: totalHosts, + colorScheme: colorScheme, + enabled: enabled, + lastUpdate: time.Now(), + throttle: throttle, + } + + if enabled { + pt.bar = progressbar.NewOptions64( + int64(totalHosts), + progressbar.OptionSetDescription("Scanning"), + progressbar.OptionSetWriter(os.Stderr), // Use stderr for progress bar + progressbar.OptionShowCount(), + progressbar.OptionShowIts(), + progressbar.OptionSetItsString("hosts"), + progressbar.OptionOnCompletion(func() { + // Don't print anything here, let Finish() handle it + }), + progressbar.OptionThrottle(throttle), + progressbar.OptionFullWidth(), + progressbar.OptionSetRenderBlankState(false), + ) + } + + return pt +} + +// Update updates the progress bar with current status +func (pt *ProgressTracker) Update(current int, currentIP string, onlineCount int) { + pt.mu.Lock() + defer pt.mu.Unlock() + + pt.currentIP = currentIP + pt.onlineCount = onlineCount + + if !pt.enabled || pt.bar == nil { + return + } + + // Always process completion updates to avoid race condition + isCompleted := current == pt.totalHosts + if isCompleted { + pt.completed = true + } + + if !isCompleted && time.Since(pt.lastUpdate) < pt.throttle { + return + } + + pt.lastUpdate = time.Now() + + _ = pt.bar.Set64(int64(current)) +} + +// printStats prints additional progress statistics +func (pt *ProgressTracker) printStats(rate float64, eta time.Duration) { + if !pt.enabled { + return + } + + var rateStr string + if rate < 1 { + rateStr = fmt.Sprintf("%.1f hosts/min", rate*60) + } else { + rateStr = fmt.Sprintf("%.1f hosts/sec", rate) + } + + etaStr := "0s" + if eta > 0 && eta < time.Hour { + etaStr = fmt.Sprintf("%.0fs", eta.Seconds()) + } else if eta >= time.Hour { + etaStr = fmt.Sprintf("%.0fm", eta.Minutes()) + } + + stats := fmt.Sprintf( + "Rate: %s | Online: %d | ETA: %s", + rateStr, + pt.onlineCount, + etaStr, + ) + + fmt.Fprint(os.Stderr, "\r\033[K") // Clear line with ANSI escape sequence + if pt.currentIP != "" { + stats += " | Current: " + pt.currentIP + } + pt.colorScheme.Progress.Fprintf(os.Stderr, "%s\n", stats) +} + +// Finish marks the progress as complete +func (pt *ProgressTracker) Finish() { + if pt.enabled && pt.bar != nil { + _ = pt.bar.Finish() + fmt.Fprintln(os.Stderr) + } +} + +// GetStats returns current progress statistics +func (pt *ProgressTracker) GetStats() (rate float64, elapsed time.Duration) { + elapsed = time.Since(pt.startTime) + // Add minimum elapsed time threshold to avoid division by very small numbers + if elapsed.Seconds() > 0.01 { // 10ms minimum + rate = float64(pt.totalHosts) / elapsed.Seconds() + } + return +} diff --git a/internal/display/table.go b/internal/display/table.go new file mode 100644 index 0000000..dfd2658 --- /dev/null +++ b/internal/display/table.go @@ -0,0 +1,293 @@ +// Package display provides terminal output formatting and progress tracking +// for network scanning results. It supports colored output, sortable tables, +// and real-time progress updates with various display configurations. +package display + +import ( + "bytes" + "fmt" + "math" + "os" + "sort" + "time" + + "github.com/olekukonko/tablewriter" +) + +// TerminalDisplay implements Display interface for terminal output +type TerminalDisplay struct { + config DisplayConfig + colorScheme *ColorScheme + progress *ProgressTracker + table *tablewriter.Table +} + +// NewTerminalDisplay creates a new terminal display instance +func NewTerminalDisplay(config DisplayConfig) *TerminalDisplay { + td := &TerminalDisplay{ + config: config, + } + + // Initialize color scheme + if config.EnabledColors && IsTerminalColorSupported() { + td.colorScheme = DefaultColorScheme() + } else { + td.colorScheme = NoColorScheme() + } + + return td +} + +func (td *TerminalDisplay) ShowHeader(network string, ipRange string, totalHosts int, workers int, count int, interval string, timeout string, version string) { + if td.config.EnabledProgress { + throttle := td.config.progressThrottle + if throttle == 0 { + throttle = 200 * time.Millisecond // Default + } + td.progress = NewProgressTracker(totalHosts, td.colorScheme, true, throttle) + } + + // Simple list format - no boxes, no width constraints + td.colorScheme.Header.Printf("Subping %s\n", version) + td.colorScheme.Header.Printf("Network: %s\n", network) + td.colorScheme.Header.Printf("Range: %s\n", ipRange) + td.colorScheme.Header.Printf("Hosts: %d | Workers: %d | Packets: %d\n", totalHosts, workers, count) + fmt.Println() +} + +// UpdateProgress updates the progress display +func (td *TerminalDisplay) UpdateProgress(current, total int, currentIP string, onlineCount int) { + if td.progress != nil { + td.progress.Update(current, currentIP, onlineCount) + } + + // Call the user callback if provided + if td.config.ProgressCallback != nil { + td.config.ProgressCallback(current, total, currentIP, onlineCount) + } +} + +func (td *TerminalDisplay) ShowResults(results []HostResult) { + if td.progress != nil { + td.progress.Finish() + } + + td.sortResults(results) + + td.table = tablewriter.NewWriter(os.Stdout) + td.table.Header("IP Address", "Status", "Latency (Min/Avg/Max)", "Loss %", "Jitter") + + for _, result := range results { + row := td.formatResultRow(result) + td.table.Append(row) + } + + td.table.Render() +} + +func (td *TerminalDisplay) ShowSummary(totalHosts int, onlineHosts int, offlineHosts int, executionTime time.Duration, rate float64) { + var healthScore int + if td.config.UseEnhancedHealthScore { + // For enhanced scoring, we need the actual results + // Since we don't have them here, fall back to basic scoring + healthScore = td.calculateNetworkHealthScore(onlineHosts, totalHosts) + } else { + healthScore = td.calculateNetworkHealthScore(onlineHosts, totalHosts) + } + + fmt.Println() + + summaryColor := td.colorScheme.Success + if float64(onlineHosts)/float64(totalHosts) < 0.5 { + summaryColor = td.colorScheme.Warning + } + + summaryColor.Printf("Network Health Score: %d/100 (%s)\n", + healthScore, + td.getHealthDescription(healthScore)) + + var rateStr string + if rate < 1 { + rateStr = fmt.Sprintf("%.1f hosts/min", rate*60) + } else { + rateStr = fmt.Sprintf("%.1f hosts/sec", rate) + } + + fmt.Printf("Scan completed in %s (%s) | ", executionTime.Round(time.Millisecond), rateStr) + summaryColor.Printf("%d hosts online", onlineHosts) + fmt.Printf(" | %d hosts offline\n", offlineHosts) +} + +func (td *TerminalDisplay) sortResults(results []HostResult) { + switch td.config.SortBy { + case SortByLatency: + sort.Slice(results, func(i, j int) bool { + return results[i].AvgRtt < results[j].AvgRtt + }) + case SortByLoss: + sort.Slice(results, func(i, j int) bool { + return results[i].PacketLoss < results[j].PacketLoss + }) + case SortByJitter: + sort.Slice(results, func(i, j int) bool { + return results[i].Jitter < results[j].Jitter + }) + default: // SortByIP + td.sortResultsByIP(results) + } +} + +// sortResultsByIP sorts results by IP address using cached byte representations +func (td *TerminalDisplay) sortResultsByIP(results []HostResult) { + // Create helper struct with cached IP bytes for efficient comparison + type sortHelper struct { + result HostResult + ipBytes []byte + } + + helpers := make([]sortHelper, len(results)) + for i, result := range results { + helpers[i] = sortHelper{ + result: result, + ipBytes: result.IP.To16(), + } + } + + // Sort using cached bytes + sort.Slice(helpers, func(i, j int) bool { + return bytes.Compare(helpers[i].ipBytes, helpers[j].ipBytes) < 0 + }) + + // Copy sorted results back to original slice + for i, helper := range helpers { + results[i] = helper.result + } +} + +func (td *TerminalDisplay) formatResultRow(result HostResult) []string { + ipStr := result.IP.String() + statusIcon := GetHostStatusIcon(result.Status) + + var latencyStr string + if result.Status == StatusOnline { + latencyStr = fmt.Sprintf("%s / %s / %s", + td.formatDuration(result.MinRtt), + td.formatDuration(result.AvgRtt), + td.formatDuration(result.MaxRtt)) + } else { + latencyStr = "—" + } + + var lossStr string + if result.Status == StatusOnline { + lossStr = fmt.Sprintf("%.2f", result.PacketLoss) + } else { + lossStr = "100.00" + } + + var jitterStr string + if result.Status == StatusOnline { + jitterStr = td.formatDuration(result.Jitter) + } else { + jitterStr = "—" + } + + if td.config.EnabledColors { + rowColor := td.colorScheme.GetColorForLatency(result.AvgRtt) + + ipStr = rowColor.Sprint(ipStr) + statusIcon = rowColor.Sprint(statusIcon) + if result.Status == StatusOnline { + latencyStr = rowColor.Sprint(latencyStr) + lossStr = rowColor.Sprint(lossStr) + jitterStr = rowColor.Sprint(jitterStr) + } + } + + return []string{ipStr, statusIcon, latencyStr, lossStr, jitterStr} +} + +func (td *TerminalDisplay) formatDuration(d time.Duration) string { + if d == 0 { + return "0s" + } + + if d < time.Second { + ms := float64(d.Nanoseconds()) / 1000000 + return fmt.Sprintf("%.1fms", ms) + } + + return d.String() +} + +// calculateNetworkHealthScore calculates a basic network health score (0-100) +func (td *TerminalDisplay) calculateNetworkHealthScore(onlineHosts, totalHosts int) int { + if totalHosts == 0 { + return 0 + } + + // Basic calculation based on percentage of online hosts + onlinePercentage := float64(onlineHosts) / float64(totalHosts) + score := int(onlinePercentage * 100) + + return score +} + +// calculateEnhancedNetworkHealthScore calculates an enhanced network health score (0-100) +// incorporating latency, packet loss, and jitter metrics beyond just online/offline ratio +func (td *TerminalDisplay) calculateEnhancedNetworkHealthScore(results []HostResult) int { + if len(results) == 0 { + return 0 + } + + var totalScore float64 + var validHosts int + + for _, result := range results { + if result.Status == StatusOnline { + validHosts++ + hostScore := 100.0 + + // Deduct for high latency (>100ms) + if result.AvgRtt > 100*time.Millisecond { + hostScore -= 20 + } else if result.AvgRtt > 50*time.Millisecond { + hostScore -= 10 + } + + // Deduct for packet loss + hostScore -= result.PacketLoss * 0.5 + + // Deduct for high jitter (>50ms) + if result.Jitter > 50*time.Millisecond { + hostScore -= 15 + } + + totalScore += math.Max(0, hostScore) + } + } + + if validHosts == 0 { + return 0 + } + + // Weight by online percentage + onlinePercentage := float64(validHosts) / float64(len(results)) + return int(totalScore / float64(validHosts) * onlinePercentage) +} + +// getHealthDescription returns a description for the health score +func (td *TerminalDisplay) getHealthDescription(score int) string { + switch { + case score >= 80: + return "Excellent" + case score >= 60: + return "Good" + case score >= 40: + return "Fair" + case score >= 20: + return "Poor" + default: + return "Critical" + } +} diff --git a/internal/ping/mock_pinger.go b/internal/ping/mock_pinger.go index d0effac..ba84157 100644 --- a/internal/ping/mock_pinger.go +++ b/internal/ping/mock_pinger.go @@ -125,12 +125,28 @@ func (p *mockPinger) calculateResult(count int, latency time.Duration, packetLos // Convert packet loss from ratio to percentage packetLossPercentage := packetLoss * 100.0 + // Simulate min/max RTT based on average latency + minRtt := time.Duration(float64(latency.Nanoseconds()) * 0.8) // 80% of avg + maxRtt := time.Duration(float64(latency.Nanoseconds()) * 1.2) // 120% of avg + stdDevRtt := time.Duration(float64(latency.Nanoseconds()) * 0.1) // 10% of avg + + // If no packets received, set RTT values to 0 + if packetsRecv == 0 { + latency = 0 + minRtt = 0 + maxRtt = 0 + stdDevRtt = 0 + } + return Result{ AvgRtt: latency, PacketLoss: packetLossPercentage, PacketsSent: count, PacketsRecv: packetsRecv, PacketsRecvDuplicates: 0, // Mock doesn't simulate duplicates by default + MinRtt: minRtt, + MaxRtt: maxRtt, + StdDevRtt: stdDevRtt, } } diff --git a/internal/ping/pinger.go b/internal/ping/pinger.go index 5cf4fae..b8b01cc 100644 --- a/internal/ping/pinger.go +++ b/internal/ping/pinger.go @@ -12,6 +12,9 @@ type Result struct { PacketsSent int // Number of packets sent PacketsRecv int // Number of packets received PacketsRecvDuplicates int // Number of duplicate packets received + MinRtt time.Duration // Minimum round-trip time + MaxRtt time.Duration // Maximum round-trip time + StdDevRtt time.Duration // Standard deviation of round-trip times } // Statistics represents the full ping statistics, compatible with pro-bing.Statistics @@ -119,11 +122,9 @@ func RunPing(ipAddress string, count int, interval time.Duration, timeout time.D PacketsRecvDuplicates: result.PacketsRecvDuplicates, PacketLoss: result.PacketLoss, AvgRtt: result.AvgRtt, - // Note: We don't track individual RTTs in our Result - // So min/max/stddev will be zero-initialized, which is acceptable for compatibility - MinRtt: 0, - MaxRtt: 0, - StdDevRtt: 0, + MinRtt: result.MinRtt, + MaxRtt: result.MaxRtt, + StdDevRtt: result.StdDevRtt, } } diff --git a/internal/ping/real_pinger.go b/internal/ping/real_pinger.go index 393ad11..9a68d63 100644 --- a/internal/ping/real_pinger.go +++ b/internal/ping/real_pinger.go @@ -1,13 +1,27 @@ package ping import ( + "fmt" "runtime" "time" ping "github.com/prometheus-community/pro-bing" - "github.com/sirupsen/logrus" ) +// noopLogger is a logger that discards all output +type noopLogger struct{} + +func (l *noopLogger) Debugf(format string, v ...interface{}) {} +func (l *noopLogger) Infof(format string, v ...interface{}) {} +func (l *noopLogger) Warnf(format string, v ...interface{}) {} +func (l *noopLogger) Errorf(format string, v ...interface{}) {} +func (l *noopLogger) Fatalf(format string, v ...interface{}) {} +func (l *noopLogger) Debug(v ...interface{}) {} +func (l *noopLogger) Info(v ...interface{}) {} +func (l *noopLogger) Warn(v ...interface{}) {} +func (l *noopLogger) Error(v ...interface{}) {} +func (l *noopLogger) Fatal(v ...interface{}) {} + // realPinger is the production implementation using pro-bing library // It performs actual ICMP ping operations type realPinger struct{} @@ -18,12 +32,10 @@ func NewRealPinger() Pinger { } // Ping implements the Pinger interface using the pro-bing library -// This performs actual network ping operations and returns real statistics func (p *realPinger) Ping(ipAddress string, count int, interval time.Duration, timeout time.Duration) (Result, error) { // Create a new pinger for the target address pinger, err := ping.NewPinger(ipAddress) if err != nil { - logrus.Printf("Failed to create pinger for IP Address: %s\n", ipAddress) return Result{}, err } @@ -40,20 +52,30 @@ func (p *realPinger) Ping(ipAddress string, count int, interval time.Duration, t pinger.SetPrivileged(true) } + // Use a custom logger that discards output to prevent log noise in progress bar + // This creates a no-op logger that implements the required interface + pinger.SetLogger(&noopLogger{}) + // Execute the ping operation err = pinger.Run() if err != nil { - logrus.Printf("Failed to ping the address %s, %v\n", ipAddress, err.Error()) return Result{}, err } // Get the statistics and convert to our Result type stats := pinger.Statistics() + if stats == nil { + return Result{}, fmt.Errorf("failed to get ping statistics for %s", ipAddress) + } + return Result{ AvgRtt: stats.AvgRtt, PacketLoss: stats.PacketLoss, PacketsSent: stats.PacketsSent, PacketsRecv: stats.PacketsRecv, PacketsRecvDuplicates: stats.PacketsRecvDuplicates, + MinRtt: stats.MinRtt, + MaxRtt: stats.MaxRtt, + StdDevRtt: stats.StdDevRtt, }, nil -} \ No newline at end of file +} diff --git a/subping.go b/subping.go index 6df04ed..82830bd 100644 --- a/subping.go +++ b/subping.go @@ -71,6 +71,15 @@ type Subping struct { // pinger is the ping implementation (real or mock) pinger ping.Pinger + // progressCallback is called to report progress during scanning + progressCallback func(current, total int, currentIP string, onlineCount int) + + // completedCount tracks the number of hosts that have been scanned + completedCount int + + // onlineCount tracks the number of hosts that are online + onlineCount int + logger *logrus.Logger } @@ -93,6 +102,9 @@ type Options struct { // MaxWorkers specifies the maximum number of concurrent workers to use. MaxWorkers int + + // ProgressCallback is called when a host scan is completed + ProgressCallback func(current, total int, currentIP string, onlineCount int) } @@ -138,14 +150,17 @@ func NewSubping(opts *Options) (*Subping, error) { } instance := &Subping{ - TargetsIterator: ips, - Count: opts.Count, - Interval: opts.Interval, - Timeout: opts.Timeout, - BatchSize: int64(batchLimit), - MaxWorkers: opts.MaxWorkers, - pinger: ping.NewPinger(), // Auto-detect based on environment - logger: logrus.New(), + TargetsIterator: ips, + Count: opts.Count, + Interval: opts.Interval, + Timeout: opts.Timeout, + BatchSize: int64(batchLimit), + MaxWorkers: opts.MaxWorkers, + pinger: ping.NewPinger(), // Auto-detect based on environment + progressCallback: opts.ProgressCallback, + completedCount: 0, + onlineCount: 0, + logger: logrus.New(), } instance.logger.SetLevel(logLevel) @@ -196,14 +211,17 @@ func NewSubpingWithPinger(opts *Options, pinger ping.Pinger) (*Subping, error) { } instance := &Subping{ - TargetsIterator: ips, - Count: opts.Count, - Interval: opts.Interval, - Timeout: opts.Timeout, - BatchSize: int64(batchLimit), - MaxWorkers: opts.MaxWorkers, - pinger: pinger, // Use the provided pinger - logger: logrus.New(), + TargetsIterator: ips, + Count: opts.Count, + Interval: opts.Interval, + Timeout: opts.Timeout, + BatchSize: int64(batchLimit), + MaxWorkers: opts.MaxWorkers, + pinger: pinger, // Use the provided pinger + progressCallback: opts.ProgressCallback, + completedCount: 0, + onlineCount: 0, + logger: logrus.New(), } instance.logger.SetLevel(logLevel) @@ -224,12 +242,15 @@ func (s *Subping) Run() { // jobChannel to distribute tasks to workers. jobChannel = make(chan string, s.MaxWorkers*2) + + // progressMutex to protect progress counters + progressMutex sync.Mutex ) // Spawn the worker goroutines. for i := int64(0); i < int64(s.MaxWorkers); i++ { wg.Add(1) - go s.startWorker(i, &wg, &syncMap, jobChannel) + go s.startWorker(i, &wg, &syncMap, jobChannel, &progressMutex) } s.logger.Debugf("Spawned %d workers.\n", s.MaxWorkers) @@ -259,7 +280,7 @@ func (s *Subping) Run() { // startWorker is a worker goroutine that performs the ping task assigned to it. // It collects the ping results and stores them in the sync.Map. -func (s *Subping) startWorker(id int64, wg *sync.WaitGroup, sm *sync.Map, c <-chan string) { +func (s *Subping) startWorker(id int64, wg *sync.WaitGroup, sm *sync.Map, c <-chan string, progressMutex *sync.Mutex) { defer wg.Done() for target := range c { @@ -273,6 +294,21 @@ func (s *Subping) startWorker(id int64, wg *sync.WaitGroup, sm *sync.Map, c <-ch } sm.Store(target, p) + + // Update progress counters and call callback if provided + progressMutex.Lock() + s.completedCount++ + if p.PacketsRecv > 0 { + s.onlineCount++ + } + currentCompleted := s.completedCount + currentOnline := s.onlineCount + progressMutex.Unlock() + + // Call progress callback if provided + if s.progressCallback != nil { + s.progressCallback(currentCompleted, s.TargetsIterator.TotalHosts, target, currentOnline) + } } }