diff --git a/.gitignore b/.gitignore index 28f42ba..2555162 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,5 @@ /mping - -dist/ - dist/ +Makefile +/mping* +example-config.yml diff --git a/example/mping.yml b/example/mping.yml new file mode 100644 index 0000000..e42a4f4 --- /dev/null +++ b/example/mping.yml @@ -0,0 +1,169 @@ +# mping configuration example +# Place this file at ~/.mping.yml to customize behavior + +# Default prober used for bare hostnames (without protocol prefix) +# If not specified, defaults to 'icmpv4' +default: http + +# Prober configurations +prober: + # Built-in probers (default configurations) + icmpv4: + probe: icmpv4 + icmp: + body: "mping" + source_interface: "" + + icmpv6: + probe: icmpv6 + icmp: + body: "mping" + source_interface: "" + + http: + probe: http + http: + expect_code: 200 + expect_body: "" + + https: + probe: http # Same probe type, but with TLS config + http: + expect_code: 200 + expect_body: "" + tls: + skip_verify: true # TLS configuration determines HTTPS + + tcp: + probe: tcp + tcp: + source_interface: "" + timeout: "5000ms" + + dns: + probe: dns + dns: + server: "8.8.8.8" + port: 53 + record_type: "A" + use_tcp: false + timeout: "5000ms" + + # Custom prober examples + # These demonstrate how to create specialized probers + + # HTTP API monitoring with flexible status codes + web-api: + probe: http + http: + expect_codes: "200-299" # Accept any 2xx status code + expect_body: "" + + # HTTPS with strict status code checking + web-secure: + probe: http # Uses TLS config for HTTPS + http: + expect_code: 200 # Only 200 is acceptable + tls: + skip_verify: false # Strict TLS verification + + # Web service with redirects allowed + web-redirects: + probe: http + http: + expect_codes: "200,301,302" # Accept success and redirects + + # REST API with multiple success codes + rest-api: + probe: http + http: + expect_codes: "200,201,202,204" # Various success responses + + # DNS over TCP + dns-tcp: + probe: dns + dns: + server: "1.1.1.1" + port: 53 + record_type: "A" + use_tcp: true + timeout: "3000ms" + + # DNS with recursion desired (default behavior) + dns-recursive: + probe: dns + dns: + server: "8.8.8.8" + port: 53 + record_type: "A" + use_tcp: false + recursion_desired: true + + # DNS authoritative query (no recursion) + dns-auth: + probe: dns + dns: + server: "ns1.google.com" + port: 53 + record_type: "A" + use_tcp: false + recursion_desired: false + + # DNS with flexible response code matching + dns-flexible: + probe: dns + dns: + server: "8.8.8.8" + port: 53 + record_type: "A" + expect_codes: "0,2,3" # Accept NOERROR, SERVFAIL, NXDOMAIN + + # DNS testing server errors + dns-errors: + probe: dns + dns: + server: "test.dns.server" + port: 53 + record_type: "A" + expect_codes: "1-5" # Accept various error codes + + # Fast ICMP for low-latency monitoring + icmp-fast: + probe: icmpv4 + icmp: + body: "fast" + timeout: "1000ms" + +# UI configuration +ui: + cui: + border: true # テーブルボーダーの表示 + enable_colors: true # 色付けの有効化(true/false) + colors: + header: "dodgerblue" # ヘッダーの色 + footer: "gray" # フッターの色 + success: "green" # 成功時の色 + warning: "yellow" # 警告時の色 + error: "red" # エラー時の色 + modal_border: "white" # モーダルボーダーの色 + +# 色付けを無効にする場合: +# ui: +# cui: +# enable_colors: false + +# 使用可能な色名例: +# black, red, green, yellow, blue, magenta, cyan, white, gray, +# dodgerblue, darkgreen, orange, purple, brown, pink, lime, +# navy, olive, teal, silver, maroon, etc. + +# Usage examples: +# mping example.com # Uses default_prober (http) +# mping web-api://api.example.com # Uses flexible 200-299 status code matching +# mping web-redirects://site.com # Accepts 200, 301, 302 status codes +# mping rest-api://api.service.com # Accepts 200, 201, 202, 204 responses +# mping dns-tcp://google.com # Uses DNS over TCP +# mping dns-auth://ns1.google.com/google.com # Authoritative DNS query (no recursion) +# mping dns-flexible://8.8.8.8/google.com # Accepts NOERROR, SERVFAIL, NXDOMAIN +# mping dns-errors://test.server/example.com # Tests server error handling (codes 1-5) +# mping icmp-fast://target.com # Uses fast ICMP configuration diff --git a/go.mod b/go.mod index 5412cb9..9b6fd19 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/servak/mping go 1.24 require ( - github.com/awesome-gocui/gocui v1.1.0 + github.com/gdamore/tcell/v2 v2.8.1 github.com/jedib0t/go-pretty/v6 v6.4.6 github.com/miekg/dns v1.1.66 + github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb github.com/spf13/cobra v1.7.0 golang.org/x/net v0.41.0 gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/gdamore/encoding v1.0.0 // indirect - github.com/gdamore/tcell/v2 v2.6.0 // indirect + github.com/gdamore/encoding v1.0.1 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mattn/go-runewidth v0.0.14 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect - github.com/rivo/uniseg v0.4.3 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.8.2 // indirect golang.org/x/mod v0.25.0 // indirect diff --git a/go.sum b/go.sum index 92563b7..a9b3692 100644 --- a/go.sum +++ b/go.sum @@ -1,15 +1,12 @@ -github.com/awesome-gocui/gocui v1.1.0 h1:db2j7yFEoHZjpQFeE2xqiatS8bm1lO3THeLwE6MzOII= -github.com/awesome-gocui/gocui v1.1.0/go.mod h1:M2BXkrp7PR97CKnPRT7Rk0+rtswChPtksw/vRAESGpg= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko= -github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= -github.com/gdamore/tcell/v2 v2.4.0/go.mod h1:cTTuF84Dlj/RqmaCIV5p4w8uG1zWdk0SF6oBpwHp4fU= -github.com/gdamore/tcell/v2 v2.6.0 h1:OKbluoP9VYmJwZwq/iLb4BxwKcwGthaa1YNBJIyCySg= -github.com/gdamore/tcell/v2 v2.6.0/go.mod h1:be9omFATkdr0D9qewWW3d+MEvl5dha+Etb5y65J2H8Y= +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= @@ -20,13 +17,11 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/lucasb-eyer/go-colorful v1.0.3/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= -github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/miekg/dns v1.1.66 h1:FeZXOS3VCVsKnEAd+wBkjMC3D2K+ww66Cq3VnCINuJE= github.com/miekg/dns v1.1.66/go.mod h1:jGFzBsSNbJw6z1HYut1RKBKHA9PBdxeHrZG8J+gC2WE= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= @@ -34,10 +29,12 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLA github.com/pkg/profile v1.6.0/go.mod h1:qBsxPvzyUincmltOk6iyRVxHYg4adc0OFOv72ZdLa18= 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/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8= +github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3 h1:utMvzDsuh3suAEnhH0RdHmoPbU648o6CvXxTx4SBMOw= github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= @@ -54,16 +51,33 @@ github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -73,23 +87,41 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/command/batch.go b/internal/command/batch.go index 60f9d13..82fe3b6 100644 --- a/internal/command/batch.go +++ b/internal/command/batch.go @@ -12,7 +12,7 @@ import ( "github.com/servak/mping/internal/config" "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" - "github.com/servak/mping/internal/ui" + "github.com/servak/mping/internal/ui/shared" ) func NewPingBatchCmd() *cobra.Command { @@ -99,7 +99,9 @@ mping batch dns://8.8.8.8/google.com`, // Stop probing probeManager.Stop() cmd.Print("\r") - t := ui.TableRender(metricsManager, stats.Success) + metrics := metricsManager.SortBy(stats.Success, true) + tableData := shared.NewTableData(metrics, stats.Success, true) + t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) cmd.Println(t.Render()) return nil diff --git a/internal/command/mping.go b/internal/command/mping.go index e155064..56ebb49 100644 --- a/internal/command/mping.go +++ b/internal/command/mping.go @@ -12,7 +12,8 @@ import ( "github.com/servak/mping/internal/config" "github.com/servak/mping/internal/prober" "github.com/servak/mping/internal/stats" - "github.com/servak/mping/internal/ui" + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui" ) func NewPingCmd() *cobra.Command { @@ -76,34 +77,36 @@ mping dns://8.8.8.8/google.com`, // Create ProbeManager and MetricsManager probeManager := prober.NewProbeManager(cfg.Prober, cfg.Default) metricsManager := stats.NewMetricsManager() - + // Add targets err = probeManager.AddTargets(hosts...) if err != nil { return fmt.Errorf("failed to add targets: %w", err) } - + // Subscribe to events for metrics collection metricsManager.Subscribe(probeManager.Events()) - + // Start probing in background ctx, cancel := context.WithCancel(context.Background()) defer cancel() - + go func() { if err := probeManager.Run(ctx, _interval, _timeout); err != nil { fmt.Printf("ProbeManager error: %v\n", err) } }() - + // Start TUI - startCUI(metricsManager, cfg.UI.CUI, _interval) - + startTUI(metricsManager, cfg.UI, _interval, _timeout) + // Stop probing when TUI exits probeManager.Stop() - + // Final results - t := ui.TableRender(metricsManager, stats.Success) + metrics := metricsManager.SortBy(stats.Success, true) + tableData := shared.NewTableData(metrics, stats.Success, true) + t := tableData.ToGoPrettyTable() t.SetStyle(table.StyleLight) cmd.Println(t.Render()) return nil @@ -121,33 +124,18 @@ mping dns://8.8.8.8/google.com`, return cmd } - - -func startCUI(manager *stats.MetricsManager, cui *ui.CUIConfig, interval time.Duration) { - r, err := ui.NewCUI(manager, cui, interval) - if err != nil { - fmt.Println(err) - return - } +func startTUI(manager *stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) { + app := tui.NewTUIApp(manager, cfg, interval, timeout) refreshTime := time.Millisecond * 250 // Minimum refresh time that can be set if refreshTime < (interval / 2) { refreshTime = interval / 2 } - go func() { for { + app.Update() time.Sleep(refreshTime) - r.Update() } }() - - r.Run() + app.Run() } - - - - - - - diff --git a/internal/config/config.go b/internal/config/config.go index 38985ce..6fff036 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,7 +11,7 @@ import ( "gopkg.in/yaml.v3" "github.com/servak/mping/internal/prober" - "github.com/servak/mping/internal/ui" + "github.com/servak/mping/internal/ui/shared" ) const DefaultICMPBody = "mping" @@ -49,11 +49,11 @@ func (ve *ValidationErrors) HasErrors() bool { type Config struct { Prober map[string]*prober.ProberConfig `yaml:"prober"` Default string `yaml:"default"` - UI *ui.UIConfig `yaml:"ui"` + UI *shared.Config `yaml:"ui"` } func (c *Config) SetTitle(t string) { - c.UI.CUI.Title = t + c.UI.Title = t } func (c *Config) SetSourceInterface(sourceInterface string) { @@ -149,11 +149,7 @@ func DefaultConfig() *Config { }, }, }, - UI: &ui.UIConfig{ - CUI: &ui.CUIConfig{ - Border: true, - }, - }, + UI: shared.DefaultConfig(), } } diff --git a/internal/prober/ntp.go b/internal/prober/ntp.go index da193b9..5945b8d 100644 --- a/internal/prober/ntp.go +++ b/internal/prober/ntp.go @@ -53,11 +53,11 @@ func (cfg *NTPConfig) Validate() error { if cfg.Server == "" { return fmt.Errorf("NTP server is required") } - + if cfg.Port <= 0 || cfg.Port > 65535 { return fmt.Errorf("invalid NTP server port: %d (must be 1-65535)", cfg.Port) } - + return nil } @@ -97,7 +97,7 @@ func (p *NTPProber) Accept(target string) error { func (p *NTPProber) parseTarget(target string) (string, int, error) { originalTarget := target - + // Remove ntp:// or ntp: prefix if strings.HasPrefix(target, p.prefix+"://") { target = strings.TrimPrefix(target, p.prefix+"://") @@ -108,7 +108,7 @@ func (p *NTPProber) parseTarget(target string) (string, int, error) { // Parse server and port server := p.config.Server port := p.config.Port - + if target != "" { if strings.Contains(target, ":") { host, portStr, err := net.SplitHostPort(target) @@ -223,7 +223,7 @@ func (p *NTPProber) sendProbe(result chan *Event, serverAddr string, timeout tim // Calculate RTT and offset rtt := time.Since(now) - + // Convert NTP timestamp to time.Time serverTime := ntpTimeToTime(resp.TxTimeSec, resp.TxTimeFrac) offset := serverTime.Sub(now.Add(rtt / 2)) @@ -280,11 +280,11 @@ func ntpTimeFromTime(t time.Time) (uint32, uint32) { // NTP epoch is January 1, 1900, Unix epoch is January 1, 1970 const ntpEpochOffset = 2208988800 // seconds between 1900 and 1970 const ntpFracScale = 1 << 32 // 2^32 for NTP fraction conversion - + unix := t.Unix() sec := uint32(unix + ntpEpochOffset) frac := uint32(int64(t.Nanosecond()) * ntpFracScale / 1000000000) // Convert nanoseconds to NTP fraction - + return sec, frac } @@ -292,9 +292,9 @@ func ntpTimeFromTime(t time.Time) (uint32, uint32) { func ntpTimeToTime(sec, frac uint32) time.Time { const ntpEpochOffset = 2208988800 // seconds between 1900 and 1970 const ntpFracScale = 1 << 32 // 2^32 for NTP fraction conversion - + unix := int64(sec) - ntpEpochOffset nsec := int64(frac) * 1000000000 / ntpFracScale - + return time.Unix(unix, nsec) -} \ No newline at end of file +} diff --git a/internal/stats/manager.go b/internal/stats/manager.go index 2fd25ab..cf97729 100644 --- a/internal/stats/manager.go +++ b/internal/stats/manager.go @@ -113,7 +113,7 @@ func (mm *MetricsManager) autoRegister(key, displayName string) { } } -func (mm *MetricsManager) GetSortedMetricsByKey(k Key) []Metrics { +func (mm *MetricsManager) SortBy(k Key, ascending bool) []Metrics { mm.mu.Lock() var res []Metrics for _, m := range mm.metrics { @@ -128,41 +128,52 @@ func (mm *MetricsManager) GetSortedMetricsByKey(k Key) []Metrics { sort.SliceStable(res, func(i, j int) bool { mi := res[i] mj := res[j] + var result bool switch k { case Host: - return res[i].Name < res[j].Name + result = res[i].Name < res[j].Name case Sent: - return mi.Total > mj.Total + result = mi.Total < mj.Total // 昇順:小さい値が先 case Success: - return mi.Successful > mj.Successful + result = mi.Successful < mj.Successful // 昇順:小さい値が先 case Loss: - return mi.Loss > mj.Loss + result = mi.Loss < mj.Loss // 昇順:小さい値が先 case Fail: - return mi.Failed > mj.Failed + result = mi.Failed < mj.Failed // 昇順:小さい値が先 case Last: - return rejectLess(mi.LastRTT, mj.LastRTT) + result = rejectLessAscending(mi.LastRTT, mj.LastRTT) // 昇順対応 case Avg: - return rejectLess(mi.AverageRTT, mj.AverageRTT) + result = rejectLessAscending(mi.AverageRTT, mj.AverageRTT) // 昇順対応 case Best: - return rejectLess(mi.MinimumRTT, mj.MinimumRTT) + result = rejectLessAscending(mi.MinimumRTT, mj.MinimumRTT) // 昇順対応 case Worst: - return rejectLess(mi.MaximumRTT, mj.MaximumRTT) + result = rejectLessAscending(mi.MaximumRTT, mj.MaximumRTT) // 昇順対応 case LastSuccTime: - return mi.LastSuccTime.After(mj.LastSuccTime) + result = mi.LastSuccTime.Before(mj.LastSuccTime) // 昇順:古い時刻が先 case LastFailTime: - return mi.LastFailTime.After(mj.LastFailTime) + result = mi.LastFailTime.Before(mj.LastFailTime) // 昇順:古い時刻が先 + default: + return false + } + + // ascending=falseの場合は結果を反転 + if ascending { + return result + } else { + return !result } - return false }) return res } -func rejectLess(i, j time.Duration) bool { +// rejectLessAscending は昇順ソート用のRTT比較関数 +// 0値(未測定)は常に後ろに配置される +func rejectLessAscending(i, j time.Duration) bool { if i == 0 { - return false + return false // i が 0 なら j を先に } if j == 0 { - return true + return true // j が 0 なら i を先に } - return i < j + return i < j // 両方とも 0 でないなら小さい方を先に } diff --git a/internal/ui/config.go b/internal/ui/config.go deleted file mode 100644 index 9004115..0000000 --- a/internal/ui/config.go +++ /dev/null @@ -1,5 +0,0 @@ -package ui - -type UIConfig struct { - CUI *CUIConfig `yaml:"cui"` -} diff --git a/internal/ui/cui.go b/internal/ui/cui.go deleted file mode 100644 index ac2096a..0000000 --- a/internal/ui/cui.go +++ /dev/null @@ -1,266 +0,0 @@ -package ui - -import ( - "errors" - "fmt" - "strings" - "time" - - "github.com/jedib0t/go-pretty/v6/table" - - "github.com/awesome-gocui/gocui" - - "github.com/servak/mping/internal/stats" -) - -const MAIN_VIEW = "main" - -type ( - CUI struct { - g *gocui.Gui - mm *stats.MetricsManager - config *CUIConfig - interval time.Duration - key stats.Key - } - - CUIConfig struct { - Title string `yaml:"-"` - Border bool `yaml:"border"` - } -) - -func NewCUI(mm *stats.MetricsManager, cfg *CUIConfig, interval time.Duration) (*CUI, error) { - g, err := gocui.NewGui(gocui.OutputNormal, true) - if err != nil { - return nil, err - } - return &CUI{ - g: g, - mm: mm, - config: cfg, - interval: interval, - key: stats.Success, - }, nil -} - -func (c *CUI) render() string { - t := TableRender(c.mm, c.key) - if c.config.Border { - t.SetStyle(table.StyleLight) - } else { - t.SetStyle(table.Style{ - Box: table.StyleBoxLight, - Options: table.Options{ - DrawBorder: false, - SeparateColumns: false, - }, - }) - } - return t.Render() -} - -func (c *CUI) Run() error { - layout := func(g *gocui.Gui) error { - maxX, maxY := g.Size() - if v, err := g.SetView("header", 0, -1, maxX, 1, 0); err != nil { - if !errors.Is(err, gocui.ErrUnknownView) { - return err - } - v.Highlight = true - v.Frame = false - v.Clear() - v.SelFgColor = gocui.ColorMagenta - msg := fmt.Sprintf("Sort: %s, Interval: %dms", c.key, c.interval.Milliseconds()) - marginN := (maxX/2 - len(c.config.Title)/2) - len(msg) - if marginN < 0 { - marginN = 1 - } - margin := strings.Repeat(" ", marginN) - fmt.Fprintln(v, msg+margin+c.config.Title) - } - if v, err := g.SetView(MAIN_VIEW, 0, 0, maxX, maxY-1, 0); err != nil { - if !errors.Is(err, gocui.ErrUnknownView) { - return err - } - if _, err := g.SetCurrentView(MAIN_VIEW); err != nil { - return err - } - v.Frame = false - v.Clear() - fmt.Fprintln(v, c.render()) - } - if v, err := g.SetView("footer", 0, maxY-2, maxX, maxY, 0); err != nil { - if !errors.Is(err, gocui.ErrUnknownView) { - return err - } - v.Frame = false - v.Clear() - fmt.Fprintln(v, "q:quit, s:sort, R:reset, move(k:up, j:down, g:top, G:bottom, u:pageUp, d:pageDown)") - } - return nil - } - - c.g.SetManagerFunc(layout) - err := c.keybindings() - if err != nil { - return err - } - if err = c.g.MainLoop(); err != nil && err != gocui.ErrQuit { - return err - } - return nil -} - -func (c *CUI) Update() { - c.g.Update(func(g *gocui.Gui) error { - v, err := g.View(MAIN_VIEW) - if err != nil { - return err - } - ox, oy := v.Origin() - v.Clear() - v.SetOrigin(ox, oy) - fmt.Fprint(v, c.render()) - return nil - }) -} - -func (c *CUI) Close() { - c.g.Close() -} - -func (c *CUI) keybindings() error { - keymaps := map[string]func(*gocui.Gui, *gocui.View) error{ - "q": c.quit, - "s": c.changeSort(false), - "S": c.changeSort(true), - "j": originDown, - "k": originUp, - "g": originGoTop, - "G": originGoBottom, - "u": originPageUp, - "d": originPageDown, - "R": c.reset, - } - for k, v := range keymaps { - keyForced, modForced := gocui.MustParse(k) - if err := c.g.SetKeybinding("", keyForced, modForced, v); err != nil { - return err - } - } - return nil -} - -func (c CUI) quit(g *gocui.Gui, v *gocui.View) error { - c.Close() - return gocui.ErrQuit -} - -func (c *CUI) changeSort(reverse bool) func(*gocui.Gui, *gocui.View) error { - return func(g *gocui.Gui, v *gocui.View) error { - if reverse { - if int(c.key) == 0 { - c.key = stats.Key(len(stats.Keys()) - 1) - } else { - c.key-- - } - } else { - if int(c.key+1) < len(stats.Keys()) { - c.key++ - } else { - c.key = 0 - } - } - - c.g.Update(func(g *gocui.Gui) error { - v, err := g.View("header") - if err != nil { - return err - } - maxX, _ := g.Size() - v.Clear() - msg := fmt.Sprintf("Sort: %s, Interval: %dms", c.key, c.interval.Milliseconds()) - marginN := (maxX/2 - len(c.config.Title)/2) - len(msg) - if marginN < 0 { - marginN = 1 - } - margin := strings.Repeat(" ", marginN) - fmt.Fprintln(v, msg+margin+c.config.Title) - return nil - }) - return nil - } -} - -func (c *CUI) reset(g *gocui.Gui, v *gocui.View) error { - c.mm.ResetAllMetrics() - return nil -} - -func originDown(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - _, wy := v.Size() - _, oy := v.Origin() - bottom := len(v.ViewBufferLines()) - if (bottom - oy) <= wy { - return nil - } - return v.SetOrigin(0, oy+1) -} - -func originUp(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - _, oy := v.Origin() - if oy == 0 { - return nil - } - return v.SetOrigin(0, oy-1) -} - -func originGoTop(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - return v.SetOrigin(0, 0) -} - -func originGoBottom(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - _, wy := v.Size() - bottom := len(v.ViewBufferLines()) - return v.SetOrigin(0, bottom-wy) -} - -func originPageDown(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - _, wy := v.Size() - _, oy := v.Origin() - slide := wy + oy - bottom := len(v.ViewBufferLines()) - if (bottom - wy) < slide { - return v.SetOrigin(0, (bottom - wy)) - } - return v.SetOrigin(0, slide) -} - -func originPageUp(g *gocui.Gui, v *gocui.View) error { - if v == nil { - return nil - } - _, wy := v.Size() - _, oy := v.Origin() - slide := oy - wy - if 0 > slide { - return v.SetOrigin(0, 0) - } - return v.SetOrigin(0, slide) -} diff --git a/internal/ui/shared/config.go b/internal/ui/shared/config.go new file mode 100644 index 0000000..5e9aa1e --- /dev/null +++ b/internal/ui/shared/config.go @@ -0,0 +1,35 @@ +package shared + +// Config manages UI settings +type Config struct { + Title string `yaml:"-"` + Border bool `yaml:"border"` + EnableColors bool `yaml:"enable_colors"` + Colors struct { + Header string `yaml:"header"` + Footer string `yaml:"footer"` + Success string `yaml:"success"` + Warning string `yaml:"warning"` + Error string `yaml:"error"` + ModalBorder string `yaml:"modal_border"` + } `yaml:"colors"` +} + +// DefaultConfig returns default configuration +func DefaultConfig() *Config { + cfg := &Config{ + Title: "mping", + Border: true, + EnableColors: true, // Enable colors by default + } + + // Use color names available in tview + cfg.Colors.Header = "dodgerblue" + cfg.Colors.Footer = "gray" + cfg.Colors.Success = "green" + cfg.Colors.Warning = "yellow" + cfg.Colors.Error = "red" + cfg.Colors.ModalBorder = "white" + + return cfg +} \ No newline at end of file diff --git a/internal/ui/shared/config_test.go b/internal/ui/shared/config_test.go new file mode 100644 index 0000000..58cdcc3 --- /dev/null +++ b/internal/ui/shared/config_test.go @@ -0,0 +1,73 @@ +package shared + +import ( + "testing" +) + +func TestDefaultConfig(t *testing.T) { + cfg := DefaultConfig() + + // Verify default values + if cfg.Title != "mping" { + t.Errorf("Expected title 'mping', got '%s'", cfg.Title) + } + + if !cfg.Border { + t.Error("Expected border to be true by default") + } + + if !cfg.EnableColors { + t.Error("Expected EnableColors to be true by default") + } + + // Verify color settings + expectedColors := map[string]string{ + "Header": "dodgerblue", + "Footer": "gray", + "Success": "green", + "Warning": "yellow", + "Error": "red", + "ModalBorder": "white", + } + + actualColors := map[string]string{ + "Header": cfg.Colors.Header, + "Footer": cfg.Colors.Footer, + "Success": cfg.Colors.Success, + "Warning": cfg.Colors.Warning, + "Error": cfg.Colors.Error, + "ModalBorder": cfg.Colors.ModalBorder, + } + + for key, expected := range expectedColors { + if actual := actualColors[key]; actual != expected { + t.Errorf("Expected %s color '%s', got '%s'", key, expected, actual) + } + } +} + +func TestConfigCustomization(t *testing.T) { + cfg := DefaultConfig() + + // Test that we can modify config values + cfg.Title = "custom-mping" + cfg.Border = false + cfg.EnableColors = false + cfg.Colors.Header = "red" + + if cfg.Title != "custom-mping" { + t.Errorf("Expected title to be modifiable, got '%s'", cfg.Title) + } + + if cfg.Border { + t.Error("Expected border to be modifiable") + } + + if cfg.EnableColors { + t.Error("Expected EnableColors to be modifiable") + } + + if cfg.Colors.Header != "red" { + t.Errorf("Expected header color to be modifiable, got '%s'", cfg.Colors.Header) + } +} \ No newline at end of file diff --git a/internal/ui/shared/filters.go b/internal/ui/shared/filters.go new file mode 100644 index 0000000..2c66bf7 --- /dev/null +++ b/internal/ui/shared/filters.go @@ -0,0 +1,23 @@ +package shared + +import ( + "strings" + + "github.com/servak/mping/internal/stats" +) + +// FilterMetrics filters metrics based on filter text +func FilterMetrics(metrics []stats.Metrics, filterText string) []stats.Metrics { + if filterText == "" { + return metrics + } + + filtered := []stats.Metrics{} + filterLower := strings.ToLower(filterText) + for _, m := range metrics { + if strings.Contains(strings.ToLower(m.Name), filterLower) { + filtered = append(filtered, m) + } + } + return filtered +} \ No newline at end of file diff --git a/internal/ui/shared/filters_test.go b/internal/ui/shared/filters_test.go new file mode 100644 index 0000000..ffea9e5 --- /dev/null +++ b/internal/ui/shared/filters_test.go @@ -0,0 +1,183 @@ +package shared + +import ( + "testing" + "time" + + "github.com/servak/mping/internal/stats" +) + +func TestFilterMetrics(t *testing.T) { + // Create test metrics + metrics := []stats.Metrics{ + { + Name: "google.com", + Total: 100, + Successful: 95, + Failed: 5, + LastSuccTime: time.Now(), + }, + { + Name: "yahoo.com", + Total: 50, + Successful: 48, + Failed: 2, + LastSuccTime: time.Now(), + }, + { + Name: "example.org", + Total: 25, + Successful: 25, + Failed: 0, + LastSuccTime: time.Now(), + }, + { + Name: "test.net", + Total: 10, + Successful: 8, + Failed: 2, + LastSuccTime: time.Now(), + }, + } + + tests := []struct { + name string + filterText string + expectedCount int + expectedNames []string + }{ + { + name: "empty filter returns all metrics", + filterText: "", + expectedCount: 4, + expectedNames: []string{"google.com", "yahoo.com", "example.org", "test.net"}, + }, + { + name: "filter by partial domain name", + filterText: "goo", + expectedCount: 1, + expectedNames: []string{"google.com"}, + }, + { + name: "filter by TLD", + filterText: ".com", + expectedCount: 2, + expectedNames: []string{"google.com", "yahoo.com"}, + }, + { + name: "case insensitive filtering", + filterText: "GOOGLE", + expectedCount: 1, + expectedNames: []string{"google.com"}, + }, + { + name: "filter matches multiple hosts", + filterText: "e", + expectedCount: 3, + expectedNames: []string{"google.com", "example.org", "test.net"}, + }, + { + name: "filter matches no hosts", + filterText: "nonexistent", + expectedCount: 0, + expectedNames: []string{}, + }, + { + name: "filter by exact match", + filterText: "test.net", + expectedCount: 1, + expectedNames: []string{"test.net"}, + }, + { + name: "filter with mixed case", + filterText: "YaHoO", + expectedCount: 1, + expectedNames: []string{"yahoo.com"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FilterMetrics(metrics, tt.filterText) + + // Check count + if len(result) != tt.expectedCount { + t.Errorf("FilterMetrics() returned %d metrics, want %d", len(result), tt.expectedCount) + } + + // Check that all expected names are present + resultNames := make(map[string]bool) + for _, metric := range result { + resultNames[metric.Name] = true + } + + for _, expectedName := range tt.expectedNames { + if !resultNames[expectedName] { + t.Errorf("FilterMetrics() missing expected metric: %s", expectedName) + } + } + + // Check that no unexpected names are present + if len(resultNames) != len(tt.expectedNames) { + actualNames := make([]string, 0, len(result)) + for _, metric := range result { + actualNames = append(actualNames, metric.Name) + } + t.Errorf("FilterMetrics() returned unexpected metrics. Got: %v, Want: %v", actualNames, tt.expectedNames) + } + }) + } +} + +func TestFilterMetricsPreservesOrder(t *testing.T) { + metrics := []stats.Metrics{ + {Name: "alpha.com"}, + {Name: "beta.com"}, + {Name: "gamma.com"}, + } + + result := FilterMetrics(metrics, ".com") + + expectedOrder := []string{"alpha.com", "beta.com", "gamma.com"} + for i, metric := range result { + if metric.Name != expectedOrder[i] { + t.Errorf("FilterMetrics() changed order. Got %s at position %d, want %s", metric.Name, i, expectedOrder[i]) + } + } +} + +func TestFilterMetricsWithEmptyMetrics(t *testing.T) { + var metrics []stats.Metrics + + result := FilterMetrics(metrics, "test") + + if len(result) != 0 { + t.Errorf("FilterMetrics() with empty metrics returned %d items, want 0", len(result)) + } +} + +func TestFilterMetricsDoesNotModifyOriginal(t *testing.T) { + original := []stats.Metrics{ + {Name: "test1.com"}, + {Name: "test2.com"}, + {Name: "example.org"}, + } + + // Create a copy to compare later + originalCopy := make([]stats.Metrics, len(original)) + copy(originalCopy, original) + + // Filter metrics + FilterMetrics(original, "test") + + // Verify original slice is unchanged + if len(original) != len(originalCopy) { + t.Error("FilterMetrics() modified the original slice length") + } + + for i, metric := range original { + if metric.Name != originalCopy[i].Name { + t.Errorf("FilterMetrics() modified original slice at index %d: got %s, want %s", i, metric.Name, originalCopy[i].Name) + } + } +} \ No newline at end of file diff --git a/internal/ui/shared/formatters.go b/internal/ui/shared/formatters.go new file mode 100644 index 0000000..9384140 --- /dev/null +++ b/internal/ui/shared/formatters.go @@ -0,0 +1,57 @@ +package shared + +import ( + "fmt" + "time" + + "github.com/servak/mping/internal/stats" +) + +func DurationFormater(duration time.Duration) string { + if duration == 0 { + return "-" + } else if duration.Microseconds() < 1000 { + return fmt.Sprintf("%3dµs", duration.Microseconds()) + } else if duration.Milliseconds() < 1000 { + return fmt.Sprintf("%3dms", duration.Milliseconds()) + } else { + return fmt.Sprintf("%3.0fs", duration.Seconds()) + } +} + +func TimeFormater(t time.Time) string { + if t.IsZero() { + return "-" + } + return t.Format("15:04:05") +} + +// FormatHostDetail generates detailed information for a host +func FormatHostDetail(metric stats.Metrics) string { + return fmt.Sprintf(`Host Details: %s + +Total Probes: %d +Successful: %d +Failed: %d +Loss Rate: %.1f%% +Last RTT: %s +Average RTT: %s +Minimum RTT: %s +Maximum RTT: %s +Last Success: %s +Last Failure: %s +Last Error: %s`, + metric.Name, + metric.Total, + metric.Successful, + metric.Failed, + metric.Loss, + DurationFormater(metric.LastRTT), + DurationFormater(metric.AverageRTT), + DurationFormater(metric.MinimumRTT), + DurationFormater(metric.MaximumRTT), + TimeFormater(metric.LastSuccTime), + TimeFormater(metric.LastFailTime), + metric.LastFailDetail, + ) +} \ No newline at end of file diff --git a/internal/ui/shared/formatters_test.go b/internal/ui/shared/formatters_test.go new file mode 100644 index 0000000..4745ce7 --- /dev/null +++ b/internal/ui/shared/formatters_test.go @@ -0,0 +1,190 @@ +package shared + +import ( + "testing" + "time" + + "github.com/servak/mping/internal/stats" +) + +func TestDurationFormater(t *testing.T) { + tests := []struct { + name string + duration time.Duration + expected string + }{ + { + name: "zero duration", + duration: 0, + expected: "-", + }, + { + name: "microseconds", + duration: 500 * time.Microsecond, + expected: "500µs", + }, + { + name: "milliseconds", + duration: 50 * time.Millisecond, + expected: " 50ms", + }, + { + name: "seconds", + duration: 2 * time.Second, + expected: " 2s", + }, + { + name: "edge case - exactly 1000µs", + duration: 1000 * time.Microsecond, + expected: " 1ms", + }, + { + name: "edge case - exactly 1000ms", + duration: 1000 * time.Millisecond, + expected: " 1s", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DurationFormater(tt.duration) + if result != tt.expected { + t.Errorf("DurationFormater(%v) = %s, want %s", tt.duration, result, tt.expected) + } + }) + } +} + +func TestTimeFormater(t *testing.T) { + tests := []struct { + name string + time time.Time + expected string + }{ + { + name: "zero time", + time: time.Time{}, + expected: "-", + }, + { + name: "valid time", + time: time.Date(2024, 1, 1, 15, 30, 45, 0, time.UTC), + expected: "15:30:45", + }, + { + name: "midnight", + time: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), + expected: "00:00:00", + }, + { + name: "noon", + time: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + expected: "12:00:00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := TimeFormater(tt.time) + if result != tt.expected { + t.Errorf("TimeFormater(%v) = %s, want %s", tt.time, result, tt.expected) + } + }) + } +} + +func TestFormatHostDetail(t *testing.T) { + testTime := time.Date(2024, 1, 1, 15, 30, 45, 0, time.UTC) + + metric := stats.Metrics{ + Name: "example.com", + Total: 100, + Successful: 95, + Failed: 5, + Loss: 5.0, + LastRTT: 25 * time.Millisecond, + AverageRTT: 30 * time.Millisecond, + MinimumRTT: 20 * time.Millisecond, + MaximumRTT: 40 * time.Millisecond, + LastSuccTime: testTime, + LastFailTime: testTime.Add(time.Second), + LastFailDetail: "timeout", + } + + result := FormatHostDetail(metric) + + expectedContents := []string{ + "Host Details: example.com", + "Total Probes: 100", + "Successful: 95", + "Failed: 5", + "Loss Rate: 5.0%", + "Last RTT: 25ms", + "Average RTT: 30ms", + "Minimum RTT: 20ms", + "Maximum RTT: 40ms", + "Last Success: 15:30:45", + "Last Failure: 15:30:46", + "Last Error: timeout", + } + + for _, expected := range expectedContents { + if !contains(result, expected) { + t.Errorf("FormatHostDetail result missing expected content: %s\nActual result:\n%s", expected, result) + } + } +} + +func TestFormatHostDetailWithZeroValues(t *testing.T) { + metric := stats.Metrics{ + Name: "test.com", + Total: 0, + Successful: 0, + Failed: 0, + Loss: 0.0, + LastRTT: 0, + AverageRTT: 0, + MinimumRTT: 0, + MaximumRTT: 0, + LastSuccTime: time.Time{}, + LastFailTime: time.Time{}, + LastFailDetail: "", + } + + result := FormatHostDetail(metric) + + expectedContents := []string{ + "Host Details: test.com", + "Total Probes: 0", + "Successful: 0", + "Failed: 0", + "Loss Rate: 0.0%", + "Last RTT: -", + "Average RTT: -", + "Minimum RTT: -", + "Maximum RTT: -", + "Last Success: -", + "Last Failure: -", + "Last Error: ", + } + + for _, expected := range expectedContents { + if !contains(result, expected) { + t.Errorf("FormatHostDetail result missing expected content: %s\nActual result:\n%s", expected, result) + } + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(substr) == 0 || findSubstring(s, substr)) +} + +func findSubstring(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} \ No newline at end of file diff --git a/internal/ui/shared/table_data.go b/internal/ui/shared/table_data.go new file mode 100644 index 0000000..971c9b2 --- /dev/null +++ b/internal/ui/shared/table_data.go @@ -0,0 +1,169 @@ +package shared + +import ( + "fmt" + + "github.com/gdamore/tcell/v2" + "github.com/jedib0t/go-pretty/v6/table" + "github.com/rivo/tview" + + "github.com/servak/mping/internal/stats" +) + +// TableData represents table data optimized for tview.Table with go-pretty fallback +type TableData struct { + Headers []string + Rows [][]string + Metrics []stats.Metrics // Keep reference for interactive row selection +} + +// NewTableData creates TableData from metrics +func NewTableData(metrics []stats.Metrics, sortKey stats.Key, ascending bool) *TableData { + // Generate headers with sort arrows + headers := []string{ + headerWithArrow("Host", stats.Host, sortKey, ascending), + headerWithArrow("Sent", stats.Sent, sortKey, ascending), + headerWithArrow("Succ", stats.Success, sortKey, ascending), + headerWithArrow("Fail", stats.Fail, sortKey, ascending), + headerWithArrow("Loss", stats.Loss, sortKey, ascending), + headerWithArrow("Last", stats.Last, sortKey, ascending), + headerWithArrow("Avg", stats.Avg, sortKey, ascending), + headerWithArrow("Best", stats.Best, sortKey, ascending), + headerWithArrow("Worst", stats.Worst, sortKey, ascending), + headerWithArrow("LastSuccTime", stats.LastSuccTime, sortKey, ascending), + headerWithArrow("LastFailTime", stats.LastFailTime, sortKey, ascending), + "FAIL Reason", + } + + // Generate rows + rows := make([][]string, len(metrics)) + df := DurationFormater + tf := TimeFormater + + for i, m := range metrics { + rows[i] = []string{ + m.Name, + fmt.Sprintf("%d", m.Total), + fmt.Sprintf("%d", m.Successful), + fmt.Sprintf("%d", m.Failed), + fmt.Sprintf("%5.1f%%", m.Loss), + df(m.LastRTT), + df(m.AverageRTT), + df(m.MinimumRTT), + df(m.MaximumRTT), + tf(m.LastSuccTime), + tf(m.LastFailTime), + m.LastFailDetail, + } + } + + return &TableData{ + Headers: headers, + Rows: rows, + Metrics: metrics, + } +} + +// ToGoPrettyTable converts to go-pretty table format for final output only +func (td *TableData) ToGoPrettyTable() table.Writer { + t := table.NewWriter() + + // Convert headers to interface{} slice + headerRow := make(table.Row, len(td.Headers)) + for i, h := range td.Headers { + headerRow[i] = h + } + t.AppendHeader(headerRow) + + // Add rows + for _, row := range td.Rows { + rowData := make(table.Row, len(row)) + for i, cell := range row { + rowData[i] = cell + } + t.AppendRow(rowData) + } + + return t +} + +// ToTviewTable converts to interactive tview.Table format (primary UI) +func (td *TableData) ToTviewTable() *tview.Table { + t := tview.NewTable(). + SetFixed(1, 0). + SetSelectable(true, false). + SetBorders(false). // Disable all borders + SetSeparator(' '). // Use space separator instead of lines + SetSelectedStyle(tcell.StyleDefault. + Background(tcell.ColorDarkBlue). + Foreground(tcell.ColorWhite)) // Pattern 1: DarkBlue + White - k9s style + + // Define alignment for each column + alignments := []int{ + tview.AlignLeft, // Host + tview.AlignRight, // Sent + tview.AlignRight, // Succ + tview.AlignRight, // Fail + tview.AlignRight, // Loss + tview.AlignRight, // Last + tview.AlignRight, // Avg + tview.AlignRight, // Best + tview.AlignRight, // Worst + tview.AlignCenter, // LastSuccTime + tview.AlignCenter, // LastFailTime + tview.AlignLeft, // FAIL Reason + } + + // Set headers with direct TableCell struct + for col, header := range td.Headers { + alignment := tview.AlignLeft + if col < len(alignments) { + alignment = alignments[col] + } + + t.SetCell(0, col, &tview.TableCell{ + Text: " " + header + " ", + Color: tcell.ColorYellow, + Align: alignment, + NotSelectable: true, + }) + } + + // Set rows with direct TableCell struct + for row, rowData := range td.Rows { + for col, cellData := range rowData { + alignment := tview.AlignLeft + if col < len(alignments) { + alignment = alignments[col] + } + + t.SetCell(row+1, col, &tview.TableCell{ + Text: " " + cellData + " ", + Color: tcell.ColorWhite, + Align: alignment, + }) + } + } + + return t +} + +// GetMetricAtRow returns the metric for a given row index +func (td *TableData) GetMetricAtRow(row int) (stats.Metrics, bool) { + if row < 0 || row >= len(td.Metrics) { + return stats.Metrics{}, false + } + return td.Metrics[row], true +} + +// headerWithArrow generates header with sort direction arrow +func headerWithArrow(header string, key stats.Key, sortKey stats.Key, ascending bool) string { + if key == sortKey { + if ascending { + return header + " ↑" + } else { + return header + " ↓" + } + } + return header +} \ No newline at end of file diff --git a/internal/ui/table.go b/internal/ui/table.go deleted file mode 100644 index 792e85a..0000000 --- a/internal/ui/table.go +++ /dev/null @@ -1,32 +0,0 @@ -package ui - -import ( - "fmt" - - "github.com/jedib0t/go-pretty/v6/table" - "github.com/servak/mping/internal/stats" -) - -func TableRender(mm *stats.MetricsManager, key stats.Key) table.Writer { - t := table.NewWriter() - t.AppendHeader(table.Row{stats.Host, stats.Sent, stats.Success, stats.Fail, stats.Loss, stats.Last, stats.Avg, stats.Best, stats.Worst, stats.LastSuccTime, stats.LastFailTime, "FAIL Reason"}) - df := DurationFormater - tf := TimeFormater - for _, m := range mm.GetSortedMetricsByKey(key) { - t.AppendRow(table.Row{ - m.Name, - m.Total, - m.Successful, - m.Failed, - fmt.Sprintf("%5.1f%%", m.Loss), - df(m.LastRTT), - df(m.AverageRTT), - df(m.MinimumRTT), - df(m.MaximumRTT), - tf(m.LastSuccTime), - tf(m.LastFailTime), - m.LastFailDetail, - }) - } - return t -} diff --git a/internal/ui/tui/app.go b/internal/ui/tui/app.go new file mode 100644 index 0000000..54ad2a1 --- /dev/null +++ b/internal/ui/tui/app.go @@ -0,0 +1,306 @@ +package tui + +import ( + "context" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/state" +) + +// TUIApp is the main controller for tview application +type TUIApp struct { + app *tview.Application + layout *LayoutManager + state *state.UIState + mm *stats.MetricsManager + config *shared.Config + interval time.Duration + timeout time.Duration + ctx context.Context + cancel context.CancelFunc +} + +// NewTUIApp creates a new TUIApp instance +func NewTUIApp(mm *stats.MetricsManager, cfg *shared.Config, interval, timeout time.Duration) *TUIApp { + if cfg == nil { + cfg = shared.DefaultConfig() + } + + ctx, cancel := context.WithCancel(context.Background()) + + app := tview.NewApplication() + uiState := state.NewUIState() + layout := NewLayoutManager(uiState, mm, cfg, interval, timeout) + + tuiApp := &TUIApp{ + app: app, + layout: layout, + state: uiState, + mm: mm, + config: cfg, + interval: interval, + timeout: timeout, + ctx: ctx, + cancel: cancel, + } + + tuiApp.setupCallbacks() + tuiApp.setupKeyBindings() + tuiApp.setupHelpModal() + + return tuiApp +} + +// Run starts the application +func (a *TUIApp) Run() error { + a.app.SetRoot(a.layout.GetRoot(), true).SetFocus(a.layout.GetRoot()) + return a.app.Run() +} + +// Update refreshes the display content +func (a *TUIApp) Update() { + a.app.QueueUpdateDraw(func() { + // Update all panels - they will read state internally + a.layout.UpdateAll() + }) +} + +// Close terminates the application +func (a *TUIApp) Close() { + a.cancel() + a.app.Stop() +} + +// setupCallbacks configures callbacks between components +func (a *TUIApp) setupCallbacks() { + // Set focus callback for filter input + a.layout.SetFocusCallback(func() { + a.app.SetFocus(a.layout.GetRoot()) + }) + + // Set filter input done function + a.layout.SetFilterDoneFunc(a.handleFilterDone) + + // Set row selection callback + a.layout.GetHostListPanel().SetSelectedFunc(a.handleRowSelection) +} + +// setupKeyBindings configures key bindings +func (a *TUIApp) setupKeyBindings() { + a.app.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + // When help modal is visible + if a.isHelpVisible() { + switch event.Rune() { + case 'h': + a.hideHelp() + return nil + } + switch event.Key() { + case tcell.KeyEscape: + a.hideHelp() + return nil + } + return event + } + + // When filter input is visible, let it handle its own keys + if a.layout.IsFilterShown() { + return event + } + + // Main screen key bindings + switch event.Key() { + case tcell.KeyEscape: + // Clear filter if one is active (k9s-like behavior) + if a.state.GetFilter() != "" { + a.clearFilter() + return nil + } + } + + switch event.Rune() { + case 'q': + a.Close() + return nil + case 'h': + a.showHelp() + return nil + case 's': + a.nextSort() + return nil + case 'S': + a.prevSort() + return nil + case 'r': + a.reverseSort() + return nil + case 'R': + a.resetMetrics() + return nil + case '/': + a.showFilter() + return nil + } + + // Delegate navigation to layout + return a.layout.HandleKeyEvent(event) + }) +} + +// setupHelpModal creates and adds help modal +func (a *TUIApp) setupHelpModal() { + helpModal := a.createHelpModal() + a.layout.AddModal("help", helpModal) +} + +// createHelpModal creates help modal content +func (a *TUIApp) createHelpModal() *tview.Modal { + helpText := `mping - Multi-target Ping Tool + +NAVIGATION: + j, ↓ Move down + k, ↑ Move up + g Go to top + G Go to bottom + u, Page Up Page up + d, Page Down Page down + s Next sort key + S Previous sort key + r Reverse sort order + R Reset all metrics + / Filter hosts + h Show/hide this help + q, Ctrl+C Quit application + +FILTER: + / Start filter input + Enter Apply filter + Esc Cancel/Clear filter + +Press 'h' or Esc to close ` + + return tview.NewModal(). + SetText(helpText). + AddButtons([]string{"Close"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + // Button press handling is done by parent + }) +} + +// Sort-related methods +func (a *TUIApp) nextSort() { + keys := stats.Keys() + currentKey := a.state.GetSortKey() + if int(currentKey+1) < len(keys) { + a.state.SetSortKey(currentKey + 1) + } else { + a.state.SetSortKey(0) + } +} + +func (a *TUIApp) prevSort() { + keys := stats.Keys() + currentKey := a.state.GetSortKey() + if int(currentKey) == 0 { + a.state.SetSortKey(stats.Key(len(keys) - 1)) + } else { + a.state.SetSortKey(currentKey - 1) + } +} + +func (a *TUIApp) reverseSort() { + a.state.ReverseSort() +} + +func (a *TUIApp) resetMetrics() { + a.mm.ResetAllMetrics() +} + +// Filter-related methods +func (a *TUIApp) showFilter() { + a.layout.SetFilterText(a.state.GetFilter()) + a.layout.showFilterInput() + a.app.SetFocus(a.layout.GetFilterInput()) +} + +func (a *TUIApp) clearFilter() { + a.state.ClearFilter() +} + +func (a *TUIApp) handleFilterDone(key tcell.Key) { + switch key { + case tcell.KeyEnter: + // Apply filter + filterText := a.layout.GetFilterText() + a.state.SetFilter(filterText) + a.layout.HideFilterInput() + a.Update() + a.layout.RestoreFocus() + case tcell.KeyEscape: + // Cancel filter input + a.layout.HideFilterInput() + a.layout.RestoreFocus() + } +} + +// Help modal related methods +func (a *TUIApp) showHelp() { + a.layout.ShowPage("help") + a.app.SetFocus(a.layout.GetRoot()) +} + +func (a *TUIApp) hideHelp() { + a.layout.HidePage("help") + a.app.SetFocus(a.layout.GetRoot()) +} + +func (a *TUIApp) isHelpVisible() bool { + frontPageName, _ := a.layout.GetFrontPage() + return a.layout.HasPage("help") && frontPageName == "help" +} + +// Row selection handling +func (a *TUIApp) handleRowSelection(row, col int) { + if row == 0 { + return // Skip header row + } + + // Get filtered metrics directly + metrics := a.getFilteredMetrics() + tableData := shared.NewTableData(metrics, a.state.GetSortKey(), a.state.IsAscending()) + + // Convert table row to data row (subtract 1 for header) + dataRow := row - 1 + if metric, ok := tableData.GetMetricAtRow(dataRow); ok { + a.showHostDetails(metric) + } +} + +// showHostDetails displays detailed information for a selected host +func (a *TUIApp) showHostDetails(metric stats.Metrics) { + detailText := shared.FormatHostDetail(metric) + + // Create and show modal + modal := tview.NewModal(). + SetText(detailText). + AddButtons([]string{"Close"}). + SetDoneFunc(func(buttonIndex int, buttonLabel string) { + // Remove modal and restore focus + a.layout.RemoveModal("details") + }) + + a.layout.AddModal("details", modal) + a.layout.ShowPage("details") +} + +// getFilteredMetrics returns filtered metrics based on current state +func (a *TUIApp) getFilteredMetrics() []stats.Metrics { + metrics := a.mm.SortBy(a.state.GetSortKey(), a.state.IsAscending()) + return shared.FilterMetrics(metrics, a.state.GetFilter()) +} + diff --git a/internal/ui/tui/app_test.go b/internal/ui/tui/app_test.go new file mode 100644 index 0000000..00cd2b2 --- /dev/null +++ b/internal/ui/tui/app_test.go @@ -0,0 +1,182 @@ +package tui + +import ( + "testing" + "time" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" +) + +func TestNewTUIApp(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + interval := time.Second + timeout := time.Second + + app := NewTUIApp(mm, cfg, interval, timeout) + + if app == nil { + t.Fatal("NewTUIApp() returned nil") + } + + if app.app == nil { + t.Error("Expected tview application to be initialized") + } + + if app.layout == nil { + t.Error("Expected layout to be initialized") + } + + if app.state == nil { + t.Error("Expected state to be initialized") + } + + if app.mm != mm { + t.Error("Expected metrics manager to be set correctly") + } + + if app.config != cfg { + t.Error("Expected config to be set correctly") + } + + if app.interval != interval { + t.Error("Expected interval to be set correctly") + } + + if app.timeout != timeout { + t.Error("Expected timeout to be set correctly") + } +} + +func TestNewTUIAppWithNilConfig(t *testing.T) { + mm := stats.NewMetricsManager() + interval := time.Second + timeout := time.Second + + app := NewTUIApp(mm, nil, interval, timeout) + + if app == nil { + t.Fatal("NewTUIApp() with nil config returned nil") + } + + if app.config == nil { + t.Error("Expected default config to be used when nil is passed") + } + + // Verify it's using default config values + if app.config.Title != "mping" { + t.Error("Expected default config to be applied") + } +} + +func TestTUIAppClose(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + // Test that Close doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("Close() panicked: %v", r) + } + }() + + app.Close() +} + +func TestTUIAppSortMethods(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + initialKey := app.state.GetSortKey() + initialAscending := app.state.IsAscending() + + // Test nextSort + app.nextSort() + if app.state.GetSortKey() == initialKey { + // Should change unless we're at the last key and wrap around + keys := stats.Keys() + if initialKey != stats.Key(len(keys)-1) { + t.Error("nextSort() should change sort key") + } + } + + // Test prevSort + app.prevSort() + // Should return to initial or be different + + // Test reverseSort + app.reverseSort() + if app.state.IsAscending() == initialAscending { + t.Error("reverseSort() should change sort order") + } +} + +func TestTUIAppResetMetrics(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + // Register test data + mm.Register("test.com", "test.com") + + // Test resetMetrics doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("resetMetrics() panicked: %v", r) + } + }() + + app.resetMetrics() +} + +func TestTUIAppFilterMethods(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + // Test clearFilter + app.state.SetFilter("test") + app.clearFilter() + if app.state.GetFilter() != "" { + t.Error("clearFilter() should clear the filter") + } +} + +func TestTUIAppGetFilteredMetrics(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + // Register test metrics + mm.Register("google.com", "google.com") + mm.Register("yahoo.com", "yahoo.com") + mm.Register("example.org", "example.org") + + // Test without filter + metrics := app.getFilteredMetrics() + if len(metrics) != 3 { + t.Errorf("Expected 3 metrics without filter, got %d", len(metrics)) + } + + // Test with filter + app.state.SetFilter("google") + metrics = app.getFilteredMetrics() + if len(metrics) != 1 { + t.Errorf("Expected 1 metric with 'google' filter, got %d", len(metrics)) + } +} + +func TestTUIAppCreateHelpModal(t *testing.T) { + mm := stats.NewMetricsManager() + cfg := shared.DefaultConfig() + app := NewTUIApp(mm, cfg, time.Second, time.Second) + + modal := app.createHelpModal() + + if modal == nil { + t.Error("createHelpModal() returned nil") + } +} \ No newline at end of file diff --git a/internal/ui/tui/layout.go b/internal/ui/tui/layout.go new file mode 100644 index 0000000..99cb3bb --- /dev/null +++ b/internal/ui/tui/layout.go @@ -0,0 +1,235 @@ +package tui + +import ( + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/panels" + "github.com/servak/mping/internal/ui/tui/state" +) + +// DisplayMode represents the current layout mode +type DisplayMode int + +const ( + ListOnly DisplayMode = iota + ListWithDetail +) + +// LayoutManager manages screen layout and panel arrangement +type LayoutManager struct { + // Core layout components + root *tview.Flex + pages *tview.Pages + + // Panels + header *panels.HeaderPanel + hostList *panels.HostListPanel + footer *panels.FooterPanel + _ *panels.HostDetailPanel + + // Filter input + filterInput *tview.InputField + showFilter bool + + // Current layout state + mode DisplayMode + + // Callbacks + focusCallback func() +} + +// NewLayoutManager creates a new LayoutManager +func NewLayoutManager(uiState *state.UIState, mm *stats.MetricsManager, config *shared.Config, interval, timeout time.Duration) *LayoutManager { + layout := &LayoutManager{ + mode: ListOnly, + } + + layout.setupPanels(uiState, mm, config, interval, timeout) + layout.setupLayout() + layout.setupPages() + + return layout +} + +// setupPanels initializes all panels +func (l *LayoutManager) setupPanels(uiState *state.UIState, mm *stats.MetricsManager, config *shared.Config, interval, timeout time.Duration) { + l.header = panels.NewHeaderPanel(uiState, config, interval, timeout) + l.hostList = panels.NewHostListPanel(uiState, mm) + l.footer = panels.NewFooterPanel(config) + // hostDetail will be created on demand + + // Setup filter input + l.filterInput = tview.NewInputField(). + SetLabel("Filter: "). + SetLabelColor(tcell.ColorWhite). + SetFieldBackgroundColor(tcell.ColorBlack) +} + +// setupLayout configures the main layout structure +func (l *LayoutManager) setupLayout() { + l.root = tview.NewFlex(). + SetDirection(tview.FlexRow). + AddItem(l.header.GetView(), 1, 0, false). + AddItem(l.hostList.GetView(), 0, 1, true). + AddItem(l.footer.GetView(), 1, 0, false) +} + +// setupPages configures pages for modal support +func (l *LayoutManager) setupPages() { + l.pages = tview.NewPages() + l.pages.AddPage("main", l.root, true, true) +} + +// GetRoot returns the root primitive for the application +func (l *LayoutManager) GetRoot() tview.Primitive { + return l.pages +} + +// GetHostListPanel returns the host list panel +func (l *LayoutManager) GetHostListPanel() *panels.HostListPanel { + return l.hostList +} + +// UpdateAll refreshes all panels +func (l *LayoutManager) UpdateAll() { + l.header.Update() + l.footer.Update() + l.hostList.Update() +} + +// HandleKeyEvent handles key events for navigation +func (l *LayoutManager) HandleKeyEvent(event *tcell.EventKey) *tcell.EventKey { + switch event.Rune() { + case 'j': + l.hostList.ScrollDown() + return nil + case 'k': + l.hostList.ScrollUp() + return nil + case 'g': + l.hostList.ScrollToTop() + return nil + case 'G': + l.hostList.ScrollToBottom() + return nil + case 'u': + l.hostList.PageUp() + return nil + case 'd': + l.hostList.PageDown() + return nil + } + return event +} + +// Filter input handling methods +func (l *LayoutManager) showFilterInput() { + if l.showFilter { + return + } + + l.showFilter = true + + // Rebuild layout with filter input + l.root.Clear() + l.root.AddItem(l.header.GetView(), 1, 0, false). + AddItem(l.hostList.GetView(), 0, 1, false). + AddItem(l.filterInput, 1, 0, true). + AddItem(l.footer.GetView(), 1, 0, false) +} + +func (l *LayoutManager) hideFilterInput() { + if !l.showFilter { + return + } + + l.showFilter = false + + // Rebuild layout without filter input + l.root.Clear() + l.root.AddItem(l.header.GetView(), 1, 0, false). + AddItem(l.hostList.GetView(), 0, 1, true). + AddItem(l.footer.GetView(), 1, 0, false) +} + +// SetFilterDoneFunc sets the function to call when filter input is done +func (l *LayoutManager) SetFilterDoneFunc(fn func(key tcell.Key)) { + l.filterInput.SetDoneFunc(fn) +} + +// GetFilterInput returns the filter input field +func (l *LayoutManager) GetFilterInput() *tview.InputField { + return l.filterInput +} + +// IsFilterShown returns whether filter input is currently shown +func (l *LayoutManager) IsFilterShown() bool { + return l.showFilter +} + +// HideFilterInput hides the filter input +func (l *LayoutManager) HideFilterInput() { + l.hideFilterInput() +} + +// SetFilterText sets the filter input text +func (l *LayoutManager) SetFilterText(text string) { + l.filterInput.SetText(text) +} + +// GetFilterText returns the current filter input text +func (l *LayoutManager) GetFilterText() string { + return l.filterInput.GetText() +} + +// SetFocusCallback sets callback function to restore focus to main view +func (l *LayoutManager) SetFocusCallback(callback func()) { + l.focusCallback = callback +} + +// RestoreFocus calls the focus callback if set +func (l *LayoutManager) RestoreFocus() { + if l.focusCallback != nil { + l.focusCallback() + } +} + +// Modal support methods +func (l *LayoutManager) AddModal(name string, modal tview.Primitive) { + l.pages.AddPage(name, modal, false, false) +} + +func (l *LayoutManager) RemoveModal(name string) { + l.pages.RemovePage(name) +} + +func (l *LayoutManager) ShowPage(name string) { + l.pages.ShowPage(name) +} + +func (l *LayoutManager) HidePage(name string) { + l.pages.HidePage(name) +} + +func (l *LayoutManager) HasPage(name string) bool { + return l.pages.HasPage(name) +} + +func (l *LayoutManager) GetFrontPage() (string, tview.Primitive) { + return l.pages.GetFrontPage() +} + +// Future layout mode switching methods +func (l *LayoutManager) SetDisplayMode(mode DisplayMode) { + l.mode = mode + // Implementation for future layout switching +} + +func (l *LayoutManager) GetDisplayMode() DisplayMode { + return l.mode +} diff --git a/internal/ui/tui/panels/header_footer.go b/internal/ui/tui/panels/header_footer.go new file mode 100644 index 0000000..12c257f --- /dev/null +++ b/internal/ui/tui/panels/header_footer.go @@ -0,0 +1,123 @@ +package panels + +import ( + "fmt" + "strings" + "time" + + "github.com/rivo/tview" + + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/state" +) + +// HeaderPanel manages header display +type HeaderPanel struct { + view *tview.TextView + renderState state.RenderState + config *shared.Config + interval time.Duration + timeout time.Duration +} + +// NewHeaderPanel creates a new HeaderPanel +func NewHeaderPanel(renderState state.RenderState, config *shared.Config, interval, timeout time.Duration) *HeaderPanel { + view := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + + return &HeaderPanel{ + view: view, + renderState: renderState, + config: config, + interval: interval, + timeout: timeout, + } +} + +// Update refreshes header display based on current state +func (h *HeaderPanel) Update() { + content := h.generateHeaderContent() + h.view.SetText(content) +} + +// generateHeaderContent generates header text from current state +func (h *HeaderPanel) generateHeaderContent() string { + sortDisplay := h.renderState.GetSortKey().String() + filterText := h.renderState.GetFilter() + + var parts []string + if h.config.EnableColors && h.config.Colors.Header != "" { + parts = append(parts, fmt.Sprintf("[%s]Sort: %s[-]", h.config.Colors.Header, sortDisplay)) + parts = append(parts, fmt.Sprintf("[%s]Interval: %dms[-]", h.config.Colors.Header, h.interval.Milliseconds())) + parts = append(parts, fmt.Sprintf("[%s]Timeout: %dms[-]", h.config.Colors.Header, h.timeout.Milliseconds())) + + if filterText != "" { + parts = append(parts, fmt.Sprintf("[%s]Filter: %s[-]", h.config.Colors.Warning, filterText)) + } + + parts = append(parts, fmt.Sprintf("[%s]%s[-]", h.config.Colors.Header, h.config.Title)) + } else { + parts = append(parts, fmt.Sprintf("Sort: %s", sortDisplay)) + parts = append(parts, fmt.Sprintf("Interval: %dms", h.interval.Milliseconds())) + parts = append(parts, fmt.Sprintf("Timeout: %dms", h.timeout.Milliseconds())) + + if filterText != "" { + parts = append(parts, fmt.Sprintf("Filter: %s", filterText)) + } + + parts = append(parts, h.config.Title) + } + + return strings.Join(parts, " ") +} + +// GetView returns the underlying tview component +func (h *HeaderPanel) GetView() *tview.TextView { + return h.view +} + +// FooterPanel manages footer display +type FooterPanel struct { + view *tview.TextView + config *shared.Config +} + +// NewFooterPanel creates a new FooterPanel +func NewFooterPanel(config *shared.Config) *FooterPanel { + view := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignCenter) + + return &FooterPanel{ + view: view, + config: config, + } +} + +// Update refreshes footer display based on current state +func (f *FooterPanel) Update() { + content := f.generateFooterContent() + f.view.SetText(content) +} + +// generateFooterContent generates footer text +func (f *FooterPanel) generateFooterContent() string { + if f.config.EnableColors && f.config.Colors.Footer != "" { + helpText := fmt.Sprintf("[%s]h:help[-]", f.config.Colors.Footer) + quitText := fmt.Sprintf("[%s]q:quit[-]", f.config.Colors.Footer) + sortText := fmt.Sprintf("[%s]s:sort[-]", f.config.Colors.Footer) + reverseText := fmt.Sprintf("[%s]r:reverse[-]", f.config.Colors.Footer) + resetText := fmt.Sprintf("[%s]R:reset[-]", f.config.Colors.Footer) + filterText := fmt.Sprintf("[%s]/:filter[-]", f.config.Colors.Footer) + moveText := fmt.Sprintf("[%s]j/k/g/G/u/d:move[-]", f.config.Colors.Footer) + return fmt.Sprintf("%s %s %s %s %s %s %s", helpText, quitText, sortText, reverseText, resetText, filterText, moveText) + } else { + return "h:help q:quit s:sort r:reverse R:reset /:filter j/k/g/G/u/d:move" + } +} + +// GetView returns the underlying tview component +func (f *FooterPanel) GetView() *tview.TextView { + return f.view +} \ No newline at end of file diff --git a/internal/ui/tui/panels/host_detail.go b/internal/ui/tui/panels/host_detail.go new file mode 100644 index 0000000..338b60d --- /dev/null +++ b/internal/ui/tui/panels/host_detail.go @@ -0,0 +1,43 @@ +package panels + +import ( + "github.com/rivo/tview" +) + +// HostDetailPanel manages host detail display (future feature) +type HostDetailPanel struct { + view *tview.TextView +} + +// NewHostDetailPanel creates a new HostDetailPanel +func NewHostDetailPanel() *HostDetailPanel { + view := tview.NewTextView() + view.SetDynamicColors(true). + SetScrollable(true). + SetBorder(true). + SetTitle("Host Details") + + return &HostDetailPanel{ + view: view, + } +} + +// Update refreshes host detail display (future feature) +func (h *HostDetailPanel) Update() { + // This will be implemented when we add the side panel feature + // For now, it's just a placeholder + content := `Host Details Panel +(Future feature - will show detailed metrics for selected host)` + + h.view.SetText(content) +} + +// GetView returns the underlying tview component +func (h *HostDetailPanel) GetView() *tview.TextView { + return h.view +} + +// SetVisible controls whether the detail panel is visible +func (h *HostDetailPanel) SetVisible(visible bool) { + // Implementation for future layout mode switching +} \ No newline at end of file diff --git a/internal/ui/tui/panels/host_list.go b/internal/ui/tui/panels/host_list.go new file mode 100644 index 0000000..3ffd54f --- /dev/null +++ b/internal/ui/tui/panels/host_list.go @@ -0,0 +1,238 @@ +package panels + +import ( + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" + "github.com/servak/mping/internal/ui/tui/state" +) + +// HostListPanel manages host list table display +type HostListPanel struct { + table *tview.Table + renderState state.RenderState + selectionState state.SelectionState + mm *stats.MetricsManager +} + +type HostListParams interface { + state.RenderState + state.SelectionState +} + +// NewHostListPanel creates a new HostListPanel +func NewHostListPanel(state HostListParams, mm *stats.MetricsManager) *HostListPanel { + table := tview.NewTable(). + SetSelectable(true, false) + + panel := &HostListPanel{ + table: table, + renderState: state, + selectionState: state, + mm: mm, + } + + return panel +} + +// Update refreshes host list display based on current state +func (h *HostListPanel) Update() { + // Get filtered metrics based on current state + metrics := h.getFilteredMetrics() + tableData := shared.NewTableData(metrics, h.renderState.GetSortKey(), h.renderState.IsAscending()) + + // Clear existing content and repopulate + h.table.Clear() + + // Configure table settings + h.table. + SetBorders(false). + SetSeparator(' '). + SetFixed(1, 0). + SetSelectable(true, false). + SetSelectedStyle(tcell.StyleDefault. + Background(tcell.ColorDarkGreen). + Foreground(tcell.ColorWhite)) + + // Use TableData's logic but populate our existing table + h.populateTableFromData(tableData) + + // Restore selection if specified + selectedHost := h.renderState.GetSelectedHost() + if selectedHost != "" { + h.restoreSelection(tableData, selectedHost) + } else { + // Default to first data row + if h.table.GetRowCount() > 1 { + h.table.Select(1, 0) + } + } +} + +// getFilteredMetrics returns filtered metrics based on current state +func (h *HostListPanel) getFilteredMetrics() []stats.Metrics { + metrics := h.mm.SortBy(h.renderState.GetSortKey(), h.renderState.IsAscending()) + return shared.FilterMetrics(metrics, h.renderState.GetFilter()) +} + +// updateSelectedHost updates the selection state based on current table selection +func (h *HostListPanel) updateSelectedHost() { + metrics := h.getFilteredMetrics() + tableData := shared.NewTableData(metrics, h.renderState.GetSortKey(), h.renderState.IsAscending()) + selectedHost := h.GetSelectedHost(tableData) + h.selectionState.SetSelectedHost(selectedHost) +} + +// GetView returns the underlying tview component +func (h *HostListPanel) GetView() *tview.Table { + return h.table +} + +// GetSelectedMetric returns the currently selected metric +func (h *HostListPanel) GetSelectedMetric(tableData *shared.TableData) (stats.Metrics, bool) { + row, _ := h.table.GetSelection() + if row <= 0 { + return stats.Metrics{}, false + } + return tableData.GetMetricAtRow(row - 1) // Subtract 1 for header +} + +// GetSelectedHost returns the name of the currently selected host +func (h *HostListPanel) GetSelectedHost(tableData *shared.TableData) string { + if metric, ok := h.GetSelectedMetric(tableData); ok { + return metric.Name + } + return "" +} + +// SetSelectedFunc sets the function to call when a row is selected +func (h *HostListPanel) SetSelectedFunc(fn func(row, col int)) { + h.table.SetSelectedFunc(fn) +} + +// Navigation methods +func (h *HostListPanel) ScrollDown() { + row, _ := h.table.GetSelection() + h.table.Select(row+1, 0) + // Update selection state directly + h.updateSelectedHost() +} + +func (h *HostListPanel) ScrollUp() { + row, _ := h.table.GetSelection() + if row > 1 { // Don't go above first data row (row 0 is header) + h.table.Select(row-1, 0) + // Update selection state directly + h.updateSelectedHost() + } +} + +func (h *HostListPanel) ScrollToTop() { + h.table.Select(1, 0) // Select first data row (row 0 is header) + // Update selection state directly + h.updateSelectedHost() +} + +func (h *HostListPanel) ScrollToBottom() { + rowCount := h.table.GetRowCount() + if rowCount > 1 { + h.table.Select(rowCount-1, 0) + // Update selection state directly + h.updateSelectedHost() + } +} + +func (h *HostListPanel) PageDown() { + row, _ := h.table.GetSelection() + _, _, _, height := h.table.GetRect() + pageSize := height / 2 // Reasonable page size + newRow := row + pageSize + rowCount := h.table.GetRowCount() + if newRow >= rowCount { + newRow = rowCount - 1 + } + h.table.Select(newRow, 0) + // Update selection state directly + h.updateSelectedHost() +} + +func (h *HostListPanel) PageUp() { + row, _ := h.table.GetSelection() + _, _, _, height := h.table.GetRect() + pageSize := height / 2 // Reasonable page size + newRow := row - pageSize + if newRow < 1 { // Don't go above first data row + newRow = 1 + } + h.table.Select(newRow, 0) + // Update selection state directly + h.updateSelectedHost() +} + + +// populateTableFromData populates our table using TableData content +func (h *HostListPanel) populateTableFromData(tableData *shared.TableData) { + // Define alignment for each column (same as in shared/table_data.go) + alignments := []int{ + tview.AlignLeft, // Host + tview.AlignRight, // Sent + tview.AlignRight, // Succ + tview.AlignRight, // Fail + tview.AlignRight, // Loss + tview.AlignRight, // Last + tview.AlignRight, // Avg + tview.AlignRight, // Best + tview.AlignRight, // Worst + tview.AlignCenter, // LastSuccTime + tview.AlignCenter, // LastFailTime + tview.AlignLeft, // FAIL Reason + } + + // Set headers + for col, header := range tableData.Headers { + alignment := tview.AlignLeft + if col < len(alignments) { + alignment = alignments[col] + } + + h.table.SetCell(0, col, &tview.TableCell{ + Text: " " + header + " ", + Color: tcell.ColorYellow, + Align: alignment, + NotSelectable: true, + }) + } + + // Set data rows + for row, rowData := range tableData.Rows { + for col, cellData := range rowData { + alignment := tview.AlignLeft + if col < len(alignments) { + alignment = alignments[col] + } + + h.table.SetCell(row+1, col, &tview.TableCell{ + Text: " " + cellData + " ", + Color: tcell.ColorWhite, + Align: alignment, + }) + } + } +} + +// restoreSelection finds and selects the row containing the specified host +func (h *HostListPanel) restoreSelection(tableData *shared.TableData, selectedHost string) { + for i, metric := range tableData.Metrics { + if metric.Name == selectedHost { + h.table.Select(i+1, 0) // +1 because row 0 is header + return + } + } + + // If host not found, select first row + if h.table.GetRowCount() > 1 { + h.table.Select(1, 0) + } +} diff --git a/internal/ui/tui/panels/host_list_test.go b/internal/ui/tui/panels/host_list_test.go new file mode 100644 index 0000000..8672d47 --- /dev/null +++ b/internal/ui/tui/panels/host_list_test.go @@ -0,0 +1,295 @@ +package panels + +import ( + "fmt" + "testing" + + "github.com/servak/mping/internal/stats" + "github.com/servak/mping/internal/ui/shared" +) + +// mockState implements the required interfaces for testing +type mockState struct { + sortKey stats.Key + ascending bool + filter string + selectedHost string +} + +func (m *mockState) GetSortKey() stats.Key { return m.sortKey } +func (m *mockState) SetSortKey(key stats.Key) { m.sortKey = key } +func (m *mockState) IsAscending() bool { return m.ascending } +func (m *mockState) ReverseSort() { m.ascending = !m.ascending } +func (m *mockState) GetFilter() string { return m.filter } +func (m *mockState) SetFilter(filter string) { m.filter = filter } +func (m *mockState) ClearFilter() { m.filter = "" } +func (m *mockState) GetSelectedHost() string { return m.selectedHost } +func (m *mockState) SetSelectedHost(host string) { m.selectedHost = host } + +func newMockState() *mockState { + return &mockState{ + sortKey: stats.Success, + ascending: false, + filter: "", + selectedHost: "", + } +} + +func TestNewHostListPanel(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + + panel := NewHostListPanel(state, mm) + + if panel == nil { + t.Fatal("NewHostListPanel() returned nil") + } + + if panel.table == nil { + t.Error("Expected table to be initialized") + } + + if panel.renderState == nil { + t.Error("Expected renderState to be set") + } + + if panel.selectionState == nil { + t.Error("Expected selectionState to be set") + } + + if panel.mm == nil { + t.Error("Expected metrics manager to be set") + } +} + +func TestHostListPanelGetView(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + table := panel.GetView() + if table == nil { + t.Error("GetView() returned nil") + } + + if table != panel.table { + t.Error("GetView() returned different table instance") + } +} + +func TestHostListPanelUpdate(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register some test metrics + mm.Register("google.com", "google.com") + mm.Register("example.com", "example.com") + + // Note: We just register the metrics for testing + // The actual metrics data would be populated by the probe manager + + // Test that Update() doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("Update() panicked: %v", r) + } + }() + + panel.Update() + + // Basic validation that table has content + if panel.table.GetRowCount() < 1 { + t.Error("Expected table to have at least header row after Update()") + } +} + +func TestHostListPanelUpdateWithFilter(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register test metrics + mm.Register("google.com", "google.com") + mm.Register("yahoo.com", "yahoo.com") + mm.Register("example.org", "example.org") + + // Set filter + state.SetFilter("google") + + // Update should not panic + defer func() { + if r := recover(); r != nil { + t.Errorf("Update() with filter panicked: %v", r) + } + }() + + panel.Update() +} + +func TestHostListPanelUpdateSelectedHost(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register test metrics + mm.Register("test.com", "test.com") + + // Test updateSelectedHost doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("updateSelectedHost() panicked: %v", r) + } + }() + + panel.updateSelectedHost() +} + +func TestHostListPanelScrollDown(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register multiple metrics to enable scrolling + for i := 0; i < 10; i++ { + target := fmt.Sprintf("host%d.com", i) + mm.Register(target, target) + } + + panel.Update() + + // Test that ScrollDown doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ScrollDown() panicked: %v", r) + } + }() + + panel.ScrollDown() +} + +func TestHostListPanelScrollUp(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register multiple metrics + for i := 0; i < 10; i++ { + target := fmt.Sprintf("host%d.com", i) + mm.Register(target, target) + } + + panel.Update() + + // Test that ScrollUp doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ScrollUp() panicked: %v", r) + } + }() + + panel.ScrollUp() +} + +func TestHostListPanelScrollToTop(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register metrics + mm.Register("test.com", "test.com") + panel.Update() + + // Test that ScrollToTop doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ScrollToTop() panicked: %v", r) + } + }() + + panel.ScrollToTop() +} + +func TestHostListPanelScrollToBottom(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register metrics + mm.Register("test.com", "test.com") + panel.Update() + + // Test that ScrollToBottom doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("ScrollToBottom() panicked: %v", r) + } + }() + + panel.ScrollToBottom() +} + +func TestHostListPanelPageDown(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register metrics + mm.Register("test.com", "test.com") + panel.Update() + + // Test that PageDown doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("PageDown() panicked: %v", r) + } + }() + + panel.PageDown() +} + +func TestHostListPanelPageUp(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register metrics + mm.Register("test.com", "test.com") + panel.Update() + + // Test that PageUp doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("PageUp() panicked: %v", r) + } + }() + + panel.PageUp() +} + +func TestHostListPanelRestoreSelection(t *testing.T) { + mm := stats.NewMetricsManager() + state := newMockState() + panel := NewHostListPanel(state, mm) + + // Register test metrics + mm.Register("google.com", "google.com") + mm.Register("example.com", "example.com") + + metrics := []stats.Metrics{ + {Name: "google.com"}, + {Name: "example.com"}, + } + tableData := shared.NewTableData(metrics, stats.Success, false) + + // Test restoreSelection doesn't panic + defer func() { + if r := recover(); r != nil { + t.Errorf("restoreSelection() panicked: %v", r) + } + }() + + panel.restoreSelection(tableData, "google.com") + panel.restoreSelection(tableData, "nonexistent.com") +} + diff --git a/internal/ui/tui/state/interfaces.go b/internal/ui/tui/state/interfaces.go new file mode 100644 index 0000000..2502766 --- /dev/null +++ b/internal/ui/tui/state/interfaces.go @@ -0,0 +1,41 @@ +package state + +import "github.com/servak/mping/internal/stats" + +// SelectionState manages selected host state +type SelectionState interface { + GetSelectedHost() string + SetSelectedHost(host string) +} + +// SortState manages sort-related state +type SortState interface { + GetSortKey() stats.Key + SetSortKey(key stats.Key) + IsAscending() bool + ReverseSort() +} + +// FilterState manages filter-related state +type FilterState interface { + GetFilter() string + SetFilter(filter string) + ClearFilter() +} + +// RenderState provides read-only access for rendering +type RenderState interface { + GetSortKey() stats.Key + IsAscending() bool + GetFilter() string + GetSelectedHost() string +} + +// FullUIState provides complete access to all UI state +// Only TUIApp should use this interface +type FullUIState interface { + SelectionState + SortState + FilterState + RenderState +} \ No newline at end of file diff --git a/internal/ui/tui/state/state.go b/internal/ui/tui/state/state.go new file mode 100644 index 0000000..141779b --- /dev/null +++ b/internal/ui/tui/state/state.go @@ -0,0 +1,83 @@ +package state + +import ( + "sync" + + "github.com/servak/mping/internal/stats" +) + +// UIState manages all UI state and implements all state interfaces +type UIState struct { + mu sync.RWMutex // Protects concurrent access + sortKey stats.Key + ascending bool + filterText string + selectedHost string +} + +// NewUIState creates a new UIState with defaults +func NewUIState() *UIState { + return &UIState{ + sortKey: stats.Success, + ascending: false, // Default to descending + filterText: "", + selectedHost: "", + } +} + +// SelectionState implementation +func (s *UIState) GetSelectedHost() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.selectedHost +} + +func (s *UIState) SetSelectedHost(host string) { + s.mu.Lock() + defer s.mu.Unlock() + s.selectedHost = host +} + +// SortState implementation +func (s *UIState) GetSortKey() stats.Key { + s.mu.RLock() + defer s.mu.RUnlock() + return s.sortKey +} + +func (s *UIState) SetSortKey(key stats.Key) { + s.mu.Lock() + defer s.mu.Unlock() + s.sortKey = key +} + +func (s *UIState) IsAscending() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.ascending +} + +func (s *UIState) ReverseSort() { + s.mu.Lock() + defer s.mu.Unlock() + s.ascending = !s.ascending +} + +// FilterState implementation +func (s *UIState) GetFilter() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.filterText +} + +func (s *UIState) SetFilter(filter string) { + s.mu.Lock() + defer s.mu.Unlock() + s.filterText = filter +} + +func (s *UIState) ClearFilter() { + s.mu.Lock() + defer s.mu.Unlock() + s.filterText = "" +} \ No newline at end of file diff --git a/internal/ui/tui/state/state_test.go b/internal/ui/tui/state/state_test.go new file mode 100644 index 0000000..50757d2 --- /dev/null +++ b/internal/ui/tui/state/state_test.go @@ -0,0 +1,198 @@ +package state + +import ( + "fmt" + "sync" + "testing" + + "github.com/servak/mping/internal/stats" +) + +func TestNewUIState(t *testing.T) { + state := NewUIState() + + if state == nil { + t.Fatal("NewUIState() returned nil") + } + + // Test default values + if state.GetSortKey() != stats.Success { + t.Errorf("Expected default sort key to be %v, got %v", stats.Success, state.GetSortKey()) + } + + if state.IsAscending() { + t.Error("Expected default sort order to be descending") + } + + if state.GetFilter() != "" { + t.Errorf("Expected default filter to be empty, got '%s'", state.GetFilter()) + } + + if state.GetSelectedHost() != "" { + t.Errorf("Expected default selected host to be empty, got '%s'", state.GetSelectedHost()) + } +} + +func TestUIStateSortKey(t *testing.T) { + state := NewUIState() + + testCases := []stats.Key{ + stats.Host, + stats.Sent, + stats.Success, + stats.Fail, + stats.Loss, + stats.Last, + stats.Avg, + stats.Best, + stats.Worst, + } + + for _, key := range testCases { + state.SetSortKey(key) + if state.GetSortKey() != key { + t.Errorf("SetSortKey(%v) failed, got %v", key, state.GetSortKey()) + } + } +} + +func TestUIStateSortOrder(t *testing.T) { + state := NewUIState() + + // Test initial state (should be descending) + if state.IsAscending() { + t.Error("Expected initial sort order to be descending") + } + + // Test reverse + state.ReverseSort() + if !state.IsAscending() { + t.Error("Expected sort order to be ascending after ReverseSort()") + } + + // Test reverse again + state.ReverseSort() + if state.IsAscending() { + t.Error("Expected sort order to be descending after second ReverseSort()") + } +} + +func TestUIStateFilter(t *testing.T) { + state := NewUIState() + + testFilters := []string{ + "google", + "example.com", + "test123", + "", + "case-SENSITIVE-Test", + } + + for _, filter := range testFilters { + state.SetFilter(filter) + if state.GetFilter() != filter { + t.Errorf("SetFilter('%s') failed, got '%s'", filter, state.GetFilter()) + } + } + + // Test clear filter + state.SetFilter("some-filter") + state.ClearFilter() + if state.GetFilter() != "" { + t.Errorf("ClearFilter() failed, got '%s'", state.GetFilter()) + } +} + +func TestUIStateSelectedHost(t *testing.T) { + state := NewUIState() + + testHosts := []string{ + "google.com", + "example.org", + "localhost", + "192.168.1.1", + "", + } + + for _, host := range testHosts { + state.SetSelectedHost(host) + if state.GetSelectedHost() != host { + t.Errorf("SetSelectedHost('%s') failed, got '%s'", host, state.GetSelectedHost()) + } + } +} + +func TestUIStateConcurrency(t *testing.T) { + state := NewUIState() + const numGoroutines = 100 + const numOperations = 100 + + var wg sync.WaitGroup + wg.Add(numGoroutines * 4) // 4 types of operations + + // Test concurrent access to sort key + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + key := stats.Key(j % 9) // There are 9 different sort keys + state.SetSortKey(key) + _ = state.GetSortKey() + } + }(i) + } + + // Test concurrent access to sort order + for i := 0; i < numGoroutines; i++ { + go func() { + defer wg.Done() + for j := 0; j < numOperations; j++ { + state.ReverseSort() + _ = state.IsAscending() + } + }() + } + + // Test concurrent access to filter + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + filter := fmt.Sprintf("filter-%d-%d", id, j) + state.SetFilter(filter) + _ = state.GetFilter() + if j%10 == 0 { + state.ClearFilter() + } + } + }(i) + } + + // Test concurrent access to selected host + for i := 0; i < numGoroutines; i++ { + go func(id int) { + defer wg.Done() + for j := 0; j < numOperations; j++ { + host := fmt.Sprintf("host-%d-%d.com", id, j) + state.SetSelectedHost(host) + _ = state.GetSelectedHost() + } + }(i) + } + + wg.Wait() + + // If we reach here without panics or data races, the test passes +} + +func TestUIStateInterfaces(t *testing.T) { + state := NewUIState() + + // Test that UIState implements all expected interfaces + var _ SelectionState = state + var _ SortState = state + var _ FilterState = state + var _ RenderState = state + var _ FullUIState = state +} + diff --git a/internal/ui/ui.go b/internal/ui/ui.go deleted file mode 100644 index 3d54928..0000000 --- a/internal/ui/ui.go +++ /dev/null @@ -1,7 +0,0 @@ -package ui - -type UI interface { - Run() - Update() - Close() -} diff --git a/internal/ui/util.go b/internal/ui/util.go deleted file mode 100644 index f8201ee..0000000 --- a/internal/ui/util.go +++ /dev/null @@ -1,25 +0,0 @@ -package ui - -import ( - "fmt" - "time" -) - -func DurationFormater(duration time.Duration) string { - if duration == 0 { - return "-" - } else if duration.Microseconds() < 1000 { - return fmt.Sprintf("%3dµs", duration.Microseconds()) - } else if duration.Milliseconds() < 1000 { - return fmt.Sprintf("%3dms", duration.Milliseconds()) - } else { - return fmt.Sprintf("%3.0fs", duration.Seconds()) - } -} - -func TimeFormater(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format("15:04:05") -}