├── MANIFEST.in ├── tests ├── test_runnable.py └── test_input.py ├── .bumpversion.cfg ├── tox.ini ├── .circleci └── config.yml ├── setup.py ├── LICENSE ├── .gitignore ├── README.rst └── pingtop ├── ping.py └── __init__.py /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include readme.md -------------------------------------------------------------------------------- /tests/test_runnable.py: -------------------------------------------------------------------------------- 1 | def test_hello(): 2 | import pingtop 3 | return True 4 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 0.4.2 3 | commit = True 4 | tag = True 5 | 6 | [bumpversion:file:setup.py] 7 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist=py{37} 3 | 4 | [testenv] 5 | deps= 6 | pytest 7 | pytest-cov 8 | 9 | commands= 10 | py.test tests --cov=pingtop --cov=ping --junitxml={env:TEST_RESULTS_DIR:.tox/}tox-{envname}.xml {posargs} 11 | -------------------------------------------------------------------------------- /tests/test_input.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | import urwid 3 | from pingtop import global_input 4 | 5 | 6 | def test_global_input(): 7 | global current_sort_column 8 | with pytest.raises(urwid.main_loop.ExitMainLoop): 9 | global_input("q") 10 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | python: 4 | docker: 5 | - image: circleci/python:3.7 6 | 7 | steps: 8 | - checkout 9 | - run: 10 | name: Install Tox 11 | command: | 12 | pip install --user tox 13 | echo 'export PATH="$PATH":"$HOME"/.local/bin' >> $BASH_ENV 14 | source $BASH_ENV 15 | - run: 16 | name: Run Tests 17 | environment: 18 | TEST_RESULTS_DIR: /tmp/tox/ 19 | command: | 20 | mkdir /tmp/tox 21 | tox 22 | - store_test_results: 23 | path: /tmp/tox 24 | 25 | workflows: 26 | version: 2 27 | UnitTests: 28 | jobs: 29 | - python 30 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from setuptools import setup, find_packages 4 | from os import path 5 | 6 | this_directory = path.abspath(path.dirname(__file__)) 7 | with open(path.join(this_directory, "README.rst")) as f: 8 | long_description = f.read() 9 | 10 | setup( 11 | name="pingtop", 12 | version="0.4.2", 13 | packages=find_packages(), 14 | description="Ping multiple servers and show the result in a top like terminal UI.", 15 | author="laixintao", 16 | author_email="laixintaoo@gmail.com", 17 | url="https://github.com/laixintao/pingtop", 18 | entry_points={"console_scripts": ["pingtop=pingtop:multi_ping"]}, 19 | install_requires=["panwid==0.2.5", "click"], 20 | classifiers=[ 21 | "Programming Language :: Python", 22 | "Intended Audience :: Developers", 23 | "License :: OSI Approved :: MIT License", 24 | "Operating System :: OS Independent", 25 | ], 26 | keywords=["IP", "ping", "icmp"], 27 | long_description=long_description, 28 | long_description_content_type="text/x-rst", 29 | ) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 赖信涛 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | 62 | # Flask stuff: 63 | instance/ 64 | .webassets-cache 65 | 66 | # Scrapy stuff: 67 | .scrapy 68 | 69 | # Sphinx documentation 70 | docs/_build/ 71 | 72 | # PyBuilder 73 | target/ 74 | 75 | # Jupyter Notebook 76 | .ipynb_checkpoints 77 | 78 | # IPython 79 | profile_default/ 80 | ipython_config.py 81 | 82 | # pyenv 83 | .python-version 84 | 85 | # pipenv 86 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 87 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 88 | # having no cross-platform support, pipenv may install dependencies that don’t work, or not 89 | # install all needed dependencies. 90 | #Pipfile.lock 91 | 92 | # celery beat schedule file 93 | celerybeat-schedule 94 | 95 | # SageMath parsed files 96 | *.sage.py 97 | 98 | # Environments 99 | .env 100 | .venv 101 | env/ 102 | venv/ 103 | ENV/ 104 | env.bak/ 105 | venv.bak/ 106 | 107 | # Spyder project settings 108 | .spyderproject 109 | .spyproject 110 | 111 | # Rope project settings 112 | .ropeproject 113 | 114 | # mkdocs documentation 115 | /site 116 | 117 | # mypy 118 | .mypy_cache/ 119 | .dmypy.json 120 | dmypy.json 121 | 122 | # Pyre type checker 123 | .pyre/ 124 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pingtop 2 | ======= 3 | 4 | 5 | Ping multiple servers and show the result in a top like terminal UI. 6 | 7 | |asciicast| 8 | 9 | Install 10 | ------- 11 | 12 | :: 13 | 14 | pip install pingtop 15 | 16 | 17 | ** pingtop support Python3.8 Python3.8. ** 18 | 19 | There is a dependency (`blist `) not supporting Python3.9, so please pingtop can't support 3.9. 20 | 21 | Usage 22 | ----- 23 | 24 | Then ping multiple server: 25 | 26 | :: 27 | 28 | pingtop baidu.com google.com twitter.com 29 | 30 | This project is using 31 | `click `__. Check help info 32 | with ``pingtop -h``. 33 | 34 | :: 35 | 36 | ~ pingtop --help 37 | Usage: pingtop [OPTIONS] [HOST]... 38 | 39 | Options: 40 | -s, --packetsize INTEGER specify the number of data bytes to be sent. 41 | The default is 56, which translates into 64 42 | ICMP data bytes when combined with the 8 43 | bytes of ICMP header data. This option 44 | cannot be used with ping sweeps. [default: 45 | 56] 46 | -l, --logto PATH 47 | -v, --log-level [DEBUG|INFO|WARNING|ERROR|CRITICAL] 48 | --help Show this message and exit. 49 | 50 | Why do I get ``Permission denied`` ? 51 | ------------------------------------ 52 | 53 | We use ICMP socket to send ping packet without ``sudo`` (See `this 54 | post `__ 55 | by lilydjwg(in Chinese)), however, who(which group) can use this feature 56 | is controlled by a kernel parameter: ``net.ipv4.ping_group_range``. 57 | 58 | :: 59 | 60 | cat /proc/sys/net/ipv4/ping_group_range 61 | 62 | 1 0 63 | 64 | The default value is ``1 0``, this means the whose group number from 1 65 | to 0 can use this feature(which means nobody can use this), so you get a 66 | Permission denied . 67 | 68 | To fix this, change this variable to a proper range include your group 69 | id, like this: 70 | 71 | :: 72 | 73 | [vagrant@centos7 pingtop]$ id 74 | uid=1000(vagrant) gid=1000(vagrant) groups=1000(vagrant) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 75 | 76 | [vagrant@centos7 pingtop]$ sudo sysctl -w net.ipv4.ping_group_range='0 1001' 77 | net.ipv4.ping_group_range = 0 1001 78 | 79 | Credits 80 | ------- 81 | 82 | - For the credits of ping.py’s implementation please refer 83 | `ping.py <./pingtop/ping.py>`__. 84 | - The UI was built on `panwid `__ 85 | thanks to @tonycpsu. 86 | - @\ `gzxultra `__ helped to solve the 87 | permission issues. 88 | 89 | .. |CircleCI| image:: https://circleci.com/gh/laixintao/pingtop.svg?style=svg 90 | :target: https://circleci.com/gh/laixintao/pingtop 91 | .. |asciicast| image:: https://asciinema.org/a/onbBCmHzhltau7iqButUGx6yu.svg 92 | :target: https://asciinema.org/a/onbBCmHzhltau7iqButUGx6yu 93 | -------------------------------------------------------------------------------- /pingtop/ping.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | """ 4 | A pure python ping implementation using raw socket. 5 | 6 | 7 | Note that ICMP messages can only be sent from processes running as root. 8 | 9 | 10 | Derived from ping.c distributed in Linux's netkit. That code is 11 | copyright (c) 1989 by The Regents of the University of California. 12 | That code is in turn derived from code written by Mike Muuss of the 13 | US Army Ballistic Research Laboratory in December, 1983 and 14 | placed in the public domain. They have my thanks. 15 | 16 | Bugs are naturally mine. I'd be glad to hear about them. There are 17 | certainly word - size dependenceies here. 18 | 19 | Copyright (c) Matthew Dixon Cowles, . 20 | Distributable under the terms of the GNU General Public License 21 | version 2. Provided with no warranties of any sort. 22 | 23 | Original Version from Matthew Dixon Cowles: 24 | -> ftp://ftp.visi.com/users/mdc/ping.py 25 | 26 | Rewrite by Jens Diemer: 27 | -> http://www.python-forum.de/post-69122.html#69122 28 | 29 | Rewrite by George Notaras: 30 | -> http://www.g-loaded.eu/2009/10/30/python-ping/ 31 | 32 | Fork by Pierre Bourdon: 33 | -> http://bitbucket.org/delroth/python-ping/ 34 | 35 | Revision history 36 | ~~~~~~~~~~~~~~~~ 37 | 38 | November 22, 1997 39 | ----------------- 40 | Initial hack. Doesn't do much, but rather than try to guess 41 | what features I (or others) will want in the future, I've only 42 | put in what I need now. 43 | 44 | December 16, 1997 45 | ----------------- 46 | For some reason, the checksum bytes are in the wrong order when 47 | this is run under Solaris 2.X for SPARC but it works right under 48 | Linux x86. Since I don't know just what's wrong, I'll swap the 49 | bytes always and then do an htons(). 50 | 51 | December 4, 2000 52 | ---------------- 53 | Changed the struct.pack() calls to pack the checksum and ID as 54 | unsigned. My thanks to Jerome Poincheval for the fix. 55 | 56 | May 30, 2007 57 | ------------ 58 | little rewrite by Jens Diemer: 59 | - change socket asterisk import to a normal import 60 | - replace time.time() with time.clock() 61 | - delete "return None" (or change to "return" only) 62 | - in checksum() rename "str" to "source_string" 63 | 64 | November 8, 2009 65 | ---------------- 66 | Improved compatibility with GNU/Linux systems. 67 | 68 | Fixes by: 69 | * George Notaras -- http://www.g-loaded.eu 70 | Reported by: 71 | * Chris Hallman -- http://cdhallman.blogspot.com 72 | 73 | Changes in this release: 74 | - Reuse time.time() instead of time.clock(). The 2007 implementation 75 | worked only under Microsoft Windows. Failed on GNU/Linux. 76 | time.clock() behaves differently under the two OSes[1]. 77 | 78 | [1] http://docs.python.org/library/time.html#time.clock 79 | 80 | September 25, 2010 81 | ------------------ 82 | Little modifications by Georgi Kolev: 83 | - Added quiet_ping function. 84 | - returns percent lost packages, max round trip time, avrg round trip 85 | time 86 | - Added packet size to verbose_ping & quiet_ping functions. 87 | - Bump up version to 0.2 88 | 89 | April, 2019 90 | ----------- 91 | Forked by laixintao: 92 | - Migrate to Python3 93 | - Make it thread safe by setting a flag in packet 94 | - do not need sudo (by @gzxultra (Zhixiang) ) 95 | """ 96 | 97 | __version__ = "0.2" 98 | 99 | import os 100 | import select 101 | import socket 102 | import struct 103 | import sys 104 | import time 105 | 106 | # From /usr/include/linux/icmp.h; your mileage may vary. 107 | ICMP_ECHO_REQUEST = 8 # Seems to be the same on Solaris. 108 | 109 | 110 | def checksum(source_string): 111 | """ 112 | I'm not too confident that this is right but testing seems 113 | to suggest that it gives the same answers as in_cksum in ping.c 114 | """ 115 | sum = 0 116 | count_to = int((len(source_string) / 2) * 2) 117 | for count in range(0, count_to, 2): 118 | this = source_string[count + 1] * 256 + source_string[count] 119 | sum = sum + this 120 | sum = sum & 0xffffffff # Necessary? 121 | 122 | if count_to < len(source_string): 123 | sum = sum + ord(source_string[len(source_string) - 1]) 124 | sum = sum & 0xffffffff # Necessary? 125 | 126 | sum = (sum >> 16) + (sum & 0xffff) 127 | sum = sum + (sum >> 16) 128 | answer = ~sum 129 | answer = answer & 0xffff 130 | 131 | # Swap bytes. Bugger me if I know why. 132 | answer = answer >> 8 | (answer << 8 & 0xff00) 133 | 134 | return answer 135 | 136 | 137 | def receive_one_ping(my_socket, id, timeout): 138 | """ 139 | Receive the ping from the socket. 140 | """ 141 | time_left = timeout 142 | while True: 143 | started_select = time.time() 144 | what_ready = select.select([my_socket], [], [], time_left) 145 | how_long_in_select = time.time() - started_select 146 | if what_ready[0] == []: # Timeout 147 | return 148 | 149 | time_received = time.time() 150 | received_packet, addr = my_socket.recvfrom(1024) 151 | icmpHeader = received_packet[20:28] 152 | type, code, checksum, packet_id, sequence = struct.unpack("bbHHh", icmpHeader) 153 | if packet_id == id: 154 | bytes = struct.calcsize("d") 155 | time_sent = struct.unpack("d", received_packet[28 : 28 + bytes])[0] 156 | return time_received - time_sent 157 | 158 | time_left = time_left - how_long_in_select 159 | if time_left <= 0: 160 | return 161 | 162 | 163 | def send_one_ping(my_socket, dest_addr, id, psize): 164 | """ 165 | Send one ping to the given >dest_addr<. 166 | """ 167 | dest_addr = socket.gethostbyname(dest_addr) 168 | 169 | # Remove header size from packet size 170 | # psize = psize - 8 171 | # laixintao edit: 172 | # Do not need to remove header here. From BSD ping man: 173 | # The default is 56, which translates into 64 ICMP data 174 | # bytes when combined with the 8 bytes of ICMP header data. 175 | 176 | # Header is type (8), code (8), checksum (16), id (16), sequence (16) 177 | my_checksum = 0 178 | 179 | # Make a dummy header with a 0 checksum. 180 | header = struct.pack("bbHHh", ICMP_ECHO_REQUEST, 0, my_checksum, id, 1) 181 | bytes = struct.calcsize("d") 182 | data = (psize - bytes) * b"Q" 183 | data = struct.pack("d", time.time()) + data 184 | 185 | # Calculate the checksum on the data and the dummy header. 186 | my_checksum = checksum(header + data) 187 | 188 | # Now that we have the right checksum, we put that in. It's just easier 189 | # to make up a new header than to stuff it into the dummy. 190 | header = struct.pack( 191 | "bbHHh", ICMP_ECHO_REQUEST, 0, socket.htons(my_checksum), id, 1 192 | ) 193 | packet = header + data 194 | my_socket.sendto(packet, (dest_addr, 1)) # Don't know about the 1 195 | 196 | 197 | def do_one(dest_addr, timeout, psize, flag=0): 198 | """ 199 | Returns either the delay (in seconds) or none on timeout. 200 | """ 201 | icmp = socket.getprotobyname("icmp") 202 | try: 203 | if os.getuid() != 0: 204 | my_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, icmp) 205 | else: 206 | my_socket = socket.socket(socket.AF_INET, socket.SOCK_RAW, icmp) 207 | except socket.error as e: 208 | if e.errno == 1: 209 | # Operation not permitted 210 | msg = str(e) 211 | raise socket.error(msg) 212 | raise # raise the original error 213 | 214 | process_pre = os.getpid() & 0xFF00 215 | flag = flag & 0x00FF 216 | my_id = process_pre | flag 217 | 218 | send_one_ping(my_socket, dest_addr, my_id, psize) 219 | delay = receive_one_ping(my_socket, my_id, timeout) 220 | 221 | my_socket.close() 222 | return delay 223 | 224 | 225 | def verbose_ping(dest_addr, timeout=2, count=4, psize=64): 226 | """ 227 | Send `count' ping with `psize' size to `dest_addr' with 228 | the given `timeout' and display the result. 229 | """ 230 | for i in range(count): 231 | print("ping %s with ..." % dest_addr, end="") 232 | try: 233 | delay = do_one(dest_addr, timeout, psize) 234 | except socket.gaierror as e: 235 | print("failed. (socket error: '%s')" % e[1]) 236 | break 237 | 238 | if delay == None: 239 | print("failed. (timeout within %ssec.)" % timeout) 240 | else: 241 | delay = delay * 1000 242 | print("get ping in %0.4fms" % delay) 243 | print() 244 | 245 | 246 | def quiet_ping(dest_addr, timeout=2, count=4, psize=64): 247 | """ 248 | Send `count' ping with `psize' size to `dest_addr' with 249 | the given `timeout' and display the result. 250 | Returns `percent' lost packages, `max' round trip time 251 | and `avrg' round trip time. 252 | """ 253 | mrtt = None 254 | artt = None 255 | lost = 0 256 | plist = [] 257 | 258 | for i in range(count): 259 | try: 260 | delay = do_one(dest_addr, timeout, psize) 261 | except socket.gaierror as e: 262 | print("failed. (socket error: '%s')" % e[1]) 263 | break 264 | 265 | if delay != None: 266 | delay = delay * 1000 267 | plist.append(delay) 268 | 269 | # Find lost package percent 270 | percent_lost = 100 - (len(plist) * 100 / count) 271 | 272 | # Find max and avg round trip time 273 | if plist: 274 | mrtt = max(plist) 275 | artt = sum(plist) / len(plist) 276 | 277 | return percent_lost, mrtt, artt 278 | 279 | 280 | if __name__ == "__main__": 281 | print(do_one("google.com", 1, 64)) 282 | print(do_one("baidu.com", 1, 64)) 283 | verbose_ping("heise.de") 284 | -------------------------------------------------------------------------------- /pingtop/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import logging 4 | import click 5 | import urwid 6 | import threading 7 | import socket 8 | from concurrent.futures import ThreadPoolExecutor 9 | from .ping import do_one 10 | import time 11 | import statistics 12 | 13 | from panwid.datatable import DataTableColumn, DataTable 14 | from urwid_utils.palette import PaletteEntry, Palette 15 | 16 | logger = logging.getLogger(__name__) 17 | 18 | WAIT_TIME = 1 # seconds 19 | SOCKET_TIMEOUT = 1 20 | hosts = {} 21 | event = threading.Event() 22 | screen_lock = threading.Lock() 23 | sort_keys = { 24 | "H": "host", 25 | "S": "seq", 26 | "R": "real_rtt", 27 | "I": "min_rtt", 28 | "A": "avg_rtt", 29 | "M": "max_rtt", 30 | "T": "std", 31 | "L": "lost", 32 | } 33 | current_sort_column = "real_rtt" 34 | sort_reverse = False 35 | UNICODE_BLOCKS = "▁▂▃▄▅▆▇█" 36 | 37 | 38 | screen = urwid.raw_display.Screen() 39 | screen.set_terminal_properties(256) 40 | 41 | NORMAL_FG_MONO = "white" 42 | NORMAL_FG_16 = "light gray" 43 | NORMAL_BG_16 = "black" 44 | NORMAL_FG_256 = "light gray" 45 | NORMAL_BG_256 = "g0" 46 | 47 | COLUMNS = [ 48 | DataTableColumn( 49 | "host", 50 | label="Host(IP)", 51 | width=16, 52 | align="left", 53 | sort_key=lambda v: (v is None, v), 54 | attr="color", 55 | padding=1, 56 | ), 57 | DataTableColumn( 58 | "ip", 59 | label="IP", 60 | width=3 * 4 + 3 + 2, 61 | align="left", 62 | sort_reverse=True, 63 | sort_icon=False, 64 | padding=1, 65 | ), 66 | DataTableColumn( 67 | "seq", 68 | label="Seq", 69 | width=4, 70 | align="right", 71 | sort_reverse=True, 72 | sort_icon=False, 73 | padding=0, 74 | ), 75 | DataTableColumn( 76 | "real_rtt", 77 | label="RTT", 78 | width=6, 79 | align="right", 80 | sort_reverse=True, 81 | sort_icon=False, 82 | padding=0, 83 | ), 84 | DataTableColumn( 85 | "min_rtt", 86 | label="Min", 87 | width=6, 88 | align="right", 89 | sort_reverse=True, 90 | sort_icon=False, 91 | padding=0, 92 | ), 93 | DataTableColumn( 94 | "avg_rtt", 95 | label="Avg", 96 | width=8, 97 | align="right", 98 | sort_reverse=True, 99 | sort_icon=False, 100 | padding=0, 101 | ), 102 | DataTableColumn( 103 | "max_rtt", 104 | label="Max", 105 | width=6, 106 | align="right", 107 | sort_reverse=True, 108 | sort_icon=False, 109 | padding=0, 110 | ), 111 | DataTableColumn( 112 | "std", 113 | label="Std", 114 | width=8, 115 | align="right", 116 | sort_reverse=True, 117 | sort_icon=False, 118 | padding=0, 119 | ), 120 | DataTableColumn( 121 | "lost", 122 | label="LOSS", 123 | width=5, 124 | align="right", 125 | sort_reverse=True, 126 | sort_icon=False, 127 | padding=0, 128 | ), 129 | DataTableColumn( 130 | "lostp", label="LOSS%", width=6, align="right", sort_icon=False, padding=0 131 | ), 132 | DataTableColumn("stat", label="Stat", align="left", sort_icon=False, padding=0), 133 | ] 134 | 135 | 136 | def get_last_column_width(): 137 | screen_width = screen.get_cols_rows()[0] 138 | previous_all_column_width = sum(col.width_with_padding() for col in COLUMNS) 139 | last_column_width = screen_width - previous_all_column_width - 10 140 | logger.info(f"Get last_column_width = {last_column_width}.") 141 | return last_column_width 142 | 143 | 144 | def get_palette(): 145 | attr_entries = {} 146 | for attr in ["dark red", "dark green", "dark blue"]: 147 | attr_entries[attr.split()[1]] = PaletteEntry( 148 | mono="white", foreground=attr, background="black" 149 | ) 150 | entries = DataTable.get_palette_entries(user_entries=attr_entries) 151 | palette = Palette("default", **entries) 152 | return palette 153 | 154 | 155 | def rerender_table(loop, table): 156 | """ 157 | Rerender table box from its data, and make loop redraw screen. 158 | Not thread safe. 159 | """ 160 | # save focused host 161 | position = table.focus_position 162 | focus_host = "" 163 | try: 164 | row = table.get_row_by_position(position) 165 | except IndexError: 166 | pass 167 | else: 168 | focus_host = row.values["host"] 169 | 170 | # restore sort column 171 | table.reset(reset_sort=True) 172 | table.sort_by_column(current_sort_column, sort_reverse) 173 | 174 | # restore focused host 175 | for r in table.filtered_rows: 176 | row = table.get_row_by_position(r) 177 | if row.values["host"] == focus_host: 178 | table.set_focus(r) 179 | break 180 | loop.draw_screen() 181 | 182 | 183 | class PingDataTable(DataTable): 184 | 185 | columns = COLUMNS[:] 186 | 187 | index = "index" 188 | 189 | def __init__(self, num_rows=10, *args, **kwargs): 190 | self.num_rows = num_rows 191 | self.query_data = self.query() 192 | self.last_rec = len(self.query_data) 193 | super().__init__(*args, **kwargs) 194 | 195 | def query(self, sort=(None, None), offset=None, limit=None, load_all=False): 196 | global hosts 197 | rows = [] 198 | for host, properties in hosts.items(): 199 | temp = {"host": host} 200 | temp.update(properties) 201 | rows.append(temp) 202 | return rows 203 | 204 | def query_result_count(self): 205 | return len(self.query_data) 206 | 207 | 208 | class MainBox(urwid.WidgetWrap): 209 | def __init__(self, packetsize, *args, **kwargs): 210 | self.table = PingDataTable(*args, **kwargs) 211 | urwid.connect_signal( 212 | self.table, 213 | "select", 214 | lambda source, selection: logger.info("selection: %s" % (selection)), 215 | ) 216 | banner = urwid.Text("Pingtop", align="center") 217 | key_label = "[Sort Key] {}".format( 218 | " ".join("{}: {}".format(key, col) for key, col in sort_keys.items()) 219 | ) 220 | quit_key_label = "[Quit key] Q" 221 | packet_size_line = f"Sending ICMP packet with {packetsize} data bytes." 222 | self.pile = urwid.Pile( 223 | [ 224 | ("pack", banner), 225 | ("pack", urwid.Text(packet_size_line)), 226 | ("pack", urwid.Text(key_label)), 227 | ("pack", urwid.Text(quit_key_label)), 228 | ("pack", urwid.Divider("\N{HORIZONTAL BAR}")), 229 | ("weight", 1, self.table), 230 | ] 231 | ) 232 | super().__init__(self.pile) 233 | 234 | 235 | def global_input(key): 236 | global current_sort_column 237 | global sort_reverse 238 | 239 | # keyboard input only 240 | logger.info(f"[KEY]: {key}") 241 | if not isinstance(key, str): 242 | return 243 | 244 | if key in ("q", "Q", "^C"): 245 | event.clear() 246 | raise urwid.ExitMainLoop() 247 | elif key.upper() in sort_keys: 248 | upper_key = key.upper() 249 | sort_column = sort_keys[upper_key] 250 | if current_sort_column == sort_column: 251 | sort_reverse = not sort_reverse 252 | else: 253 | sort_reverse = False 254 | current_sort_column = sort_column 255 | else: 256 | return False 257 | 258 | 259 | def forever_ping(dest, index_flag, packetsize, tablebox, mainloop): 260 | global hosts 261 | global event 262 | last_column_width = get_last_column_width() 263 | try: 264 | dest_ip = socket.gethostbyname(dest) 265 | except socket.gaierror as e: 266 | hosts[dest]["error"] = e 267 | hosts[dest]["ip"] = "Unknown" 268 | with event.is_set() and screen_lock: 269 | rerender_table(mainloop, tablebox.table) 270 | return 271 | 272 | dest_attr = hosts[dest] 273 | 274 | dest_attr["ip"] = dest_ip 275 | dest_attr.setdefault("lost", 0) 276 | dest_attr.setdefault("lostp", "0%") 277 | dest_attr.setdefault("seq", 0) 278 | dest_attr.setdefault("real_rtt", SOCKET_TIMEOUT * 1000) 279 | dest_attr.setdefault("min_rtt", SOCKET_TIMEOUT * 1000) 280 | dest_attr.setdefault("max_rtt", SOCKET_TIMEOUT * 1000) 281 | dest_attr.setdefault("avg_rtt", SOCKET_TIMEOUT * 1000) 282 | dest_attr.setdefault("std", 0) 283 | dest_attr.setdefault("stat", "") 284 | rtts = dest_attr.setdefault("rtts", []) 285 | 286 | while event.is_set(): 287 | logging.info(f"ping {dest}, {index_flag}") 288 | delay = do_one(dest, SOCKET_TIMEOUT, packetsize, index_flag) 289 | logging.info(f"[Done]ping {dest}, {index_flag} rtt={delay}") 290 | with screen_lock: 291 | dest_attr["seq"] += 1 292 | if delay is None: 293 | dest_attr["lost"] += 1 294 | dest_attr["lostp"] = "{0:.0%}".format( 295 | dest_attr["lost"] / dest_attr["seq"] 296 | ) 297 | block_mark = " " 298 | sleep_before_next_ping = WAIT_TIME 299 | else: 300 | delay_ms = int(delay * 1000) 301 | rtts.append(delay_ms) 302 | dest_attr["real_rtt"] = delay_ms 303 | dest_attr["min_rtt"] = min(dest_attr["rtts"]) 304 | dest_attr["max_rtt"] = max(dest_attr["rtts"]) 305 | dest_attr["avg_rtt"] = sum(dest_attr["rtts"]) / dest_attr["seq"] 306 | if len(rtts) >= 2: 307 | dest_attr["std"] = float("%2.1f" % (statistics.stdev(rtts))) 308 | 309 | block_mark = UNICODE_BLOCKS[min(delay_ms // 30, 7)] 310 | sleep_before_next_ping = WAIT_TIME - delay 311 | dest_attr["stat"] = (dest_attr["stat"] + block_mark)[-last_column_width:] 312 | 313 | try: 314 | rerender_table(mainloop, tablebox.table) 315 | except AssertionError: 316 | break 317 | logger.info(f"{dest}({dest_ip})Sleep for seconds {sleep_before_next_ping}") 318 | time.sleep(max(0, sleep_before_next_ping)) 319 | 320 | 321 | def _raise_error(future): 322 | exp = future.exception() 323 | if exp: 324 | logging.exception(exp) 325 | 326 | 327 | PACKETSIZE_HELP = "specify the number of data bytes to be sent. The default is 56, which translates into 64 ICMP data bytes when combined with the 8 bytes of ICMP header data. This option cannot be used with ping sweeps." 328 | 329 | 330 | def config_logger(level, logfile): 331 | global logger 332 | _level = { 333 | "DEBUG": logging.DEBUG, 334 | "INFO": logging.INFO, 335 | "WARNING": logging.WARNING, 336 | "ERROR": logging.ERROR, 337 | "CRITICAL": logging.CRITICAL, 338 | }[level] 339 | logging.basicConfig( 340 | filename=logfile, 341 | filemode="a", 342 | format="%(asctime)s,%(msecs)d %(name)s %(levelname)s %(message)s", 343 | datefmt="%H:%M:%S", 344 | level=_level, 345 | ) 346 | logger = logging.getLogger(__name__) 347 | return logger 348 | 349 | 350 | def ping_statistics(data): 351 | """ 352 | Render result statistics 353 | :return: str result string 354 | """ 355 | TEMPLATE = """--- {hostname} ping statistics --- 356 | {packet} packets transmitted, {packet_received} packets received, {packet_lost:.1f}% packet loss""" 357 | RTT_TEMPLATE = """\nround-trip min/avg/max/stddev = {min:3.2f}/{avg:3.2f}/{max:3.2f}/{stddev:3.2f} ms""" 358 | ERROR_TEMPLATE = """--- {hostname} ping statistics --- 359 | ping: cannot resolve {hostname}: Unknown host""" 360 | results = [] 361 | for hostname, value in data.items(): 362 | if value.get("error"): 363 | # I could use PEP572 here 364 | results.append(ERROR_TEMPLATE.format(hostname=hostname)) 365 | continue 366 | rtts = value["rtts"] 367 | if value["seq"] == 0: 368 | packet, packet_received, packet_lost = 0, 0, 0 369 | else: 370 | packet = value["seq"] 371 | packet_received = int(value["seq"]) - int(value["lost"]) 372 | packet_lost = value["lost"] / value["seq"] * 100 373 | 374 | packets_info = TEMPLATE.format( 375 | hostname=hostname, 376 | packet=packet, 377 | packet_received=packet_received, 378 | packet_lost=packet_lost, 379 | ) 380 | rtt_info = "" 381 | if rtts: 382 | stdev = 0 383 | if len(rtts) > 2: 384 | stdev = statistics.stdev(value["rtts"]) 385 | rtt_info = RTT_TEMPLATE.format( 386 | min=min(value["rtts"]), 387 | avg=sum(value["rtts"]) / value["seq"], 388 | max=max(value["rtts"]), 389 | stddev=stdev, 390 | ) 391 | results.append(packets_info + rtt_info) 392 | return "\n".join(results) 393 | 394 | 395 | @click.command() 396 | @click.argument("host", nargs=-1) 397 | @click.option( 398 | "--packetsize", "-s", type=int, default=56, show_default=True, help=PACKETSIZE_HELP 399 | ) 400 | @click.option("--logto", "-l", type=click.Path(), default=None) 401 | @click.option( 402 | "--log-level", 403 | "-v", 404 | type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]), 405 | default="DEBUG", 406 | ) 407 | @click.option( 408 | "--summary/--no-summary", 409 | default=True, 410 | help="Weather to print BSD compatible summary.", 411 | ) 412 | def multi_ping(host, packetsize, logto, log_level, summary): 413 | global hosts 414 | if logto: 415 | config_logger(log_level, logto) 416 | hosts = {h: {} for h in host} 417 | logger.info(f"Hosts: {hosts}") 418 | hosts_num = len(hosts) 419 | 420 | if (hosts_num) == 0: 421 | raise click.BadParameter("Hosts were not specified.") 422 | 423 | # update the HOST column width to fit max length host 424 | max_host_length = max([len(host) for host in hosts] + [9]) + 2 425 | COLUMNS[0].width = max_host_length if max_host_length < 40 else 40 426 | # start the UI loop 427 | tablebox = MainBox( 428 | packetsize, 429 | 1000, 430 | index="uniqueid", 431 | sort_refocus=True, 432 | sort_icons=True, 433 | with_scrollbar=True, 434 | border=(1, "\N{VERTICAL LINE}", "blue"), 435 | padding=3, 436 | with_footer=False, 437 | ui_sort=False, 438 | ) 439 | mainloop = urwid.MainLoop( 440 | tablebox, palette=get_palette(), screen=screen, unhandled_input=global_input 441 | ) 442 | 443 | # open threadpool to ping 444 | logger.info(f"Open ThreadPoolExecutor with max_workers={hosts_num}.") 445 | pool = ThreadPoolExecutor(max_workers=hosts_num) 446 | event.set() 447 | for index, host in zip(range(len(hosts)), hosts): 448 | future = pool.submit(forever_ping, host, index, packetsize, tablebox, mainloop) 449 | future.add_done_callback(_raise_error) 450 | 451 | # Go! 452 | mainloop.run() 453 | 454 | if summary: 455 | click.echo(ping_statistics(hosts)) 456 | 457 | 458 | if __name__ == "__main__": 459 | multi_ping() 460 | --------------------------------------------------------------------------------