├── 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 |
--------------------------------------------------------------------------------