├── .gitignore ├── LICENSE ├── README.md ├── dockerclustermon └── __init__.py ├── pyproject.toml └── ruff.toml /.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 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | .idea/ 163 | .vscode/ 164 | 165 | /uv.lock 166 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Michael Kennedy 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Docker Cluster Monitor - A TUI utility to monitor your docker containers 2 | 3 | A TUI tool for a live view of your docker containers running on a remote server. Here's a graphic of it running (refreshes every 5 seconds or so automatically): 4 | 5 | ![](https://mkennedy-shared.nyc3.digitaloceanspaces.com/docker-status.gif) 6 | 7 | Notice that it uses color to communicate outlier values. For example, low CPU is green, middle-of-the-road CPU is cyan, and heavy CPU usage is red. Similarly for memory. The memory limit column reflects the deploy>memory limit in Docker Compose for that container and the percentage reported is for the imposed memory limit rather than the machine physical memory limits. 8 | 9 | ## Usage 10 | 11 | To use the tool, it has one command, `dockerstatus` with a shorter alias `ds`. If you are running a set of Docker containers (say via Docker Compose) on server `my-docker-host`, then just run: 12 | 13 | ```bash 14 | dockerstatus my-docker-host 15 | ``` 16 | 17 | You can optionally pass the username if you're not logging in as `root`. Here is the full help text (thank you Typer): 18 | 19 | ```bash 20 | Usage: dockerstatus [OPTIONS] HOST [USERNAME] 21 | ╭─ Arguments ───────────────────────────────────────────────────────────╮ 22 | │ * host TEXT The server DNS name or IP address (e.g. 91.7.5.1│ 23 | │ or google.com). [default: None] [required] │ 24 | │ username [USERNAME] The username of the ssh user for interacting │ 25 | │ with the server. [default: root] │ 26 | ╰───────────────────────────────────────────────────────────────────────╯ 27 | ╭─ Options ─────────────────────────────────────────────────────────────╮ 28 | | --ssh-config Whether the host is a SSH config entry or not. | 29 | | --sudo Whether to run as the super user or not. | 30 | │ --help Show this message and exit. │ 31 | ╰───────────────────────────────────────────────────────────────────────╯ 32 | ``` 33 | 34 | ## Installation 35 | 36 | This package is available on PyPI as dockerclustermon. However, it is ideally used as a CLI tool 37 | and not imported into programs. As such, using **uv** or **pipx** will be most useful. Take your pick: 38 | 39 | ### uv 40 | 41 | ```bash 42 | uv tool install dockerclustermon 43 | ``` 44 | 45 | Of course this requires that you have 46 | [uv installed](https://docs.astral.sh/uv/getting-started/installation/) 47 | and in the path. 48 | 49 | ### pipx 50 | 51 | ```bash 52 | pipx install dockerclustermon 53 | ``` 54 | 55 | And this requires that you have [pipx installed](https://pipx.pypa.io/stable/installation/) 56 | and in the path. 57 | 58 | 59 | Compatibility 60 | ------------- 61 | 62 | Docker Cluster Monitor has been tested against Ubuntu Linux. It should work on any system that 63 | supports SSH and has the Docker CLI tools installed. Note that it does **not** work on the local 64 | machine at the moment. PRs are welcome. ;) 65 | -------------------------------------------------------------------------------- /dockerclustermon/__init__.py: -------------------------------------------------------------------------------- 1 | """dockerclustermon - A CLI tool for a live view of your docker containers running on a remote server.""" 2 | 3 | __version__ = '0.2.3' 4 | __author__ = 'Michael Kennedy ' 5 | __all__ = [] 6 | 7 | import datetime 8 | import re 9 | import subprocess 10 | import sys 11 | import time 12 | from subprocess import CalledProcessError 13 | from threading import Thread 14 | from typing import Annotated, Callable, TypedDict, Optional 15 | 16 | import rich.console 17 | 18 | import rich.live 19 | 20 | import rich.table 21 | import typer 22 | 23 | from rich.text import Text 24 | 25 | 26 | class ResultsType(TypedDict): 27 | ps: list[dict[str, str]] 28 | stat: list[dict[str, str]] 29 | free: tuple[float, float, float] 30 | error: Optional[Exception] 31 | 32 | 33 | results: ResultsType = { 34 | 'ps': [], 35 | 'stat': [], 36 | 'free': (0.0, 0.0, 0.0001), 37 | 'error': None, 38 | } 39 | workers = [] 40 | 41 | __host_type = Annotated[ 42 | str, 43 | typer.Argument(help='The server DNS name or IP address (e.g. 91.7.5.1 or google.com).'), 44 | ] 45 | __user_type = Annotated[ 46 | str, 47 | typer.Argument(help='The username of the ssh user for interacting with the server.'), 48 | ] 49 | __no_ssh = Annotated[ 50 | bool, 51 | typer.Option('--no-ssh', help='Pass this flag to run locally instead of through ssh.'), 52 | ] 53 | __ssh_config = Annotated[ 54 | bool, 55 | typer.Option( 56 | '--ssh-config', help='Pass this flag to treat the host as a ssh config entry (e.g. {username}@{host}).' 57 | ), 58 | ] 59 | __sudo = Annotated[ 60 | bool, 61 | typer.Option('--sudo', help='Pass this flag to run as super user.'), 62 | ] 63 | 64 | 65 | def get_user_host( 66 | username: str, 67 | host: str, 68 | ssh_config: bool, 69 | ) -> str: 70 | """ 71 | Get the user and host connection string. 72 | 73 | Args: 74 | username (str): The name of the user. 75 | host (str): The host. 76 | ssh_config (bool): Whether the host is an ssh config entry or not. 77 | """ 78 | return host if ssh_config else f'{username}@{host}' 79 | 80 | 81 | def get_command( 82 | args: list[str], 83 | user_host: str, 84 | no_ssh: bool, 85 | run_as_sudo: bool = False, 86 | ) -> list[str]: 87 | """ 88 | Build the command to execute. 89 | 90 | Args: 91 | args (List[str]): The list of arguments. 92 | user_host (str): The user and host connection string. 93 | no_ssh (bool): Whether the command should be executed locally or through SSH. 94 | run_as_sudo (bool, optional): Whether the command should be executed as the superuser or not. 95 | Defaults to False. 96 | """ 97 | cmd_args = (['sudo'] + args) if run_as_sudo else args 98 | 99 | return cmd_args if no_ssh else ['ssh', user_host, ' '.join(cmd_args)] 100 | 101 | 102 | def live_status( 103 | host: __host_type = 'localhost', 104 | username: __user_type = 'root', 105 | no_ssh: __no_ssh = False, 106 | ssh_config: __ssh_config = False, 107 | run_as_sudo: __sudo = False, 108 | ) -> None: 109 | try: 110 | print() 111 | if host == 'version': 112 | print(f'dockerclustermon monitoring utility version {__version__}.') 113 | return 114 | 115 | if host in {'localhost', '127.0.0.1', '::1'}: 116 | no_ssh = True 117 | 118 | table = build_table(username, host, no_ssh, ssh_config, run_as_sudo) 119 | if not table: 120 | return 121 | 122 | with rich.live.Live(table, auto_refresh=False) as live: 123 | while True: 124 | table = build_table(username, host, no_ssh, ssh_config, run_as_sudo) 125 | live.update(table) 126 | live.refresh() 127 | except KeyboardInterrupt: 128 | for w in workers: 129 | w.join() 130 | print('kthxbye!') 131 | 132 | 133 | def process_results(): 134 | ps_lines: list[dict[str, str]] = results['ps'] 135 | stat_lines: list[dict[str, str]] = results['stat'] 136 | total, used, avail = results['free'] 137 | joined = join_results(ps_lines, stat_lines) 138 | reduced = reduce_lines(joined) 139 | total_cpu = total_percent(reduced, 'CPU') 140 | total_mem = total_sizes(reduced, 'Mem') 141 | return reduced, total, total_cpu, total_mem, used 142 | 143 | 144 | def run_update(username: str, host: str, no_ssh: bool, ssh_config: bool, run_as_sudo: bool): 145 | global workers 146 | 147 | user_host = get_user_host(username, host, ssh_config) 148 | 149 | workers.clear() 150 | workers.append(Thread(target=lambda: run_stat_command(user_host, no_ssh, run_as_sudo), daemon=True)) 151 | workers.append(Thread(target=lambda: run_ps_command(user_host, no_ssh, run_as_sudo), daemon=True)) 152 | workers.append(Thread(target=lambda: run_free_command(user_host, no_ssh), daemon=True)) 153 | 154 | for w in workers: 155 | w.start() 156 | for w in workers: 157 | w.join() 158 | 159 | if results['error']: 160 | raise results['error'] 161 | 162 | 163 | def build_table(username: str, host: str, no_ssh: bool, ssh_config: bool, run_as_sudo: bool): 164 | # Keys: 'Name', 'Created', 'Status', 'CPU', 'Mem', 'Mem %', 'Limit' 165 | formatted_date = datetime.datetime.now().strftime('%b %d, %Y @ %I:%M %p') 166 | table = rich.table.Table(title=f'Docker cluster {host} status {formatted_date}') 167 | 168 | table.add_column('Name', style='white', no_wrap=True) 169 | # table.add_column("Created", style="white", no_wrap=True) 170 | table.add_column('Status', style='green', no_wrap=True) 171 | table.add_column('CPU %', justify='right', style='white') 172 | table.add_column('Mem %', justify='right', style='white') 173 | table.add_column('Mem', justify='right', style='white') 174 | table.add_column('Limit', justify='right', style='white') 175 | # noinspection PyBroadException 176 | try: 177 | run_update(username, host, no_ssh, ssh_config, run_as_sudo) 178 | reduced, total, total_cpu, total_mem, used = process_results() 179 | except CalledProcessError as cpe: 180 | print(f'Error: {cpe}') 181 | return None 182 | except Exception as x: 183 | table.add_row('Error', str(x), '', '', '', '') 184 | time.sleep(1) 185 | return table 186 | 187 | for container in reduced: 188 | table.add_row( 189 | Text(container['Name'], style='bold'), 190 | color_text( 191 | container['Status'], 192 | lambda t: not any(w in t for w in {'unhealthy', 'restart'}), 193 | ), 194 | color_number(container['CPU'], low=5, mid=25), 195 | color_number(container['Mem %'], low=25, mid=65), 196 | container['Mem'], 197 | container['Limit'], 198 | ) 199 | 200 | table.add_row() 201 | table.add_row('Totals', '', f'{total_cpu:,.0f} %', '', f'{total_mem:,.2f} GB', '') 202 | table.add_row() 203 | 204 | total_server_mem_pct = used / total * 100 205 | table.add_row( 206 | 'Server', 207 | '', 208 | '', 209 | f'{total_server_mem_pct:,.0f} %', 210 | f'{used:,.2f} GB', 211 | f'{total:,.2f} GB', 212 | ) 213 | return table 214 | 215 | 216 | def color_number(text: str, low: int, mid: int) -> Text: 217 | num_text = text.replace('%', '').replace('GB', '').replace('MB', '').replace('KB', '') 218 | num = float(num_text) 219 | 220 | if num <= low: 221 | return Text(text, style='green') 222 | 223 | if num <= mid: 224 | return Text(text, style='cyan') 225 | 226 | return Text(text, style='red') 227 | 228 | 229 | def color_text(text: str, good: Callable) -> Text: 230 | if good(text): 231 | return Text(text) 232 | 233 | return Text(text, style='bold red') 234 | 235 | 236 | def run_free_command(user_host: str, no_ssh: bool) -> tuple[float, float, float]: 237 | try: 238 | # print("Starting free") 239 | # Run the program and capture its output 240 | output = subprocess.check_output(get_command(['free', '-m'], user_host, no_ssh)) 241 | 242 | # Convert the output to a string 243 | output_string = bytes.decode(output, 'utf-8') 244 | 245 | # Convert the string to individual lines 246 | lines = [line.strip() for line in output_string.split('\n') if line and line.strip()] 247 | 248 | # total used free shared buff/cache available 249 | # Mem: 7937 4257 242 160 3436 3211 250 | mem_line = lines[1] 251 | while ' ' in mem_line: 252 | mem_line = mem_line.replace(' ', ' ') 253 | 254 | parts = mem_line.split(' ') 255 | used = int(parts[2]) / 1024 256 | avail = int(parts[5]) / 1024 257 | total = int(parts[1]) / 1024 258 | 259 | t = total, used, avail 260 | results['free'] = t 261 | 262 | # print("Free done") 263 | 264 | return t 265 | except Exception as x: 266 | msg = str(x) 267 | if "No such file or directory: 'free'" in msg: 268 | results['error'] = None 269 | t = 0.001, 0, 0 270 | results['free'] = t 271 | return t 272 | 273 | results['error'] = x 274 | 275 | 276 | def total_sizes(rows: list[dict[str, str]], key: str) -> float: 277 | # e.g. 1.5GB, 1.5MB, 1.5KB 278 | total = 0 279 | for row in rows: 280 | value = row[key] 281 | if 'GB' in value: 282 | value = float(value.replace('GB', '')) 283 | elif 'MB' in value: 284 | value = float(value.replace('MB', '')) 285 | value = value / 1024 286 | elif 'KB' in value: 287 | value = float(value.replace('KB', '')) 288 | value = value / 1024 / 1024 289 | total += value 290 | 291 | return total 292 | 293 | 294 | def total_percent(rows: list[dict[str, str]], key: str) -> float: 295 | # e.g. 50.88% 296 | total = 0 297 | for row in rows: 298 | value = float(row[key].replace('%', '')) 299 | total += value 300 | 301 | return total 302 | 303 | 304 | def reduce_lines(joined: list[dict[str, str]]) -> list[dict[str, str]]: 305 | new_lines = [] 306 | # keep_keys = { 'NAME', 'CREATED', 'STATUS', 'CPU %', 'MEM USAGE / LIMIT', 'MEM %'} 307 | 308 | for j in joined: 309 | j = split_mem(j) 310 | reduced = { 311 | 'Name': j['NAME'], 312 | 'Created': j['CREATED'], 313 | 'Status': j['STATUS'], 314 | 'CPU': str(int(float(j['CPU %'].replace('%', '')))) + ' %', 315 | 'Mem': j['MEM USAGE'].replace('KB', ' KB').replace('MB', ' MB').replace('GB', ' GB').replace(' ', ' '), 316 | 'Mem %': str(int(float(j['MEM %'].replace('%', '')))) + ' %', 317 | 'Limit': j['MEM LIMIT'].replace('KB', ' KB').replace('MB', ' MB').replace('GB', ' GB').replace(' ', ' '), 318 | } 319 | new_lines.append(reduced) 320 | 321 | # Sort by uptime (youngest first), then by name. 322 | new_lines.sort( 323 | key=lambda d: ( 324 | get_seconds_key_from_string(d.get('Status', '')), 325 | d.get('Name', '').lower().strip(), 326 | ) 327 | ) 328 | 329 | return new_lines 330 | 331 | 332 | def split_mem(j: dict) -> dict: 333 | key = 'MEM USAGE / LIMIT' 334 | # Example: 781.5MiB / 1.5GiB 335 | value = j[key] 336 | parts = [v.strip() for v in value.split('/')] 337 | 338 | j['MEM USAGE'] = parts[0].replace('iB', 'B') 339 | j['MEM LIMIT'] = parts[1].replace('iB', 'B') 340 | 341 | return j 342 | 343 | 344 | def join_results(ps_lines, stat_lines) -> list[dict[str, str]]: 345 | join_on = 'NAME' 346 | 347 | joined_lines = [] 348 | ps_dict: dict[str, str] 349 | stat_lines: dict[str, str] 350 | 351 | for ps_dict, stat_dict in zip(ps_lines, stat_lines): 352 | # noinspection PyTypeChecker 353 | if ps_dict[join_on] != stat_dict[join_on]: 354 | raise Exception('Lines do not match') 355 | 356 | joined = ps_dict.copy() 357 | # noinspection PyArgumentList 358 | joined.update(**stat_dict) 359 | 360 | joined_lines.append(joined) 361 | 362 | return joined_lines 363 | 364 | 365 | def run_stat_command(user_host: str, no_ssh: bool, run_as_sudo: bool) -> list[dict[str, str]]: 366 | # noinspection PyBroadException 367 | try: 368 | # print("Starring stat") 369 | # Run the program and capture its output 370 | output = subprocess.check_output( 371 | get_command( 372 | ['docker', 'stats', '--no-stream'], 373 | user_host, 374 | no_ssh, 375 | run_as_sudo, 376 | ) 377 | ) 378 | 379 | # Convert the output to a string 380 | output_string = bytes.decode(output, 'utf-8') 381 | 382 | # Convert the string to individual lines 383 | lines = [line.strip() for line in output_string.split('\n') if line and line.strip()] 384 | 385 | header = parse_stat_header(lines[0]) 386 | # print(header) 387 | 388 | entries = [] 389 | for line in lines[1:]: 390 | entries.append(parse_line(line, header)) 391 | 392 | results['stat'] = entries 393 | 394 | # print("Done with stat") 395 | return entries 396 | except CalledProcessError as e: 397 | results['error'] = e 398 | except Exception as x: 399 | results['error'] = x 400 | 401 | 402 | def parse_free_header(header_text: str) -> list[tuple[str, int]]: 403 | names = ['system', 'used', 'free', 'shared', 'buff/cache', 'available'] 404 | positions = [] 405 | for n in names: 406 | idx = header_text.index(n) 407 | item = (n, idx) 408 | positions.append(item) 409 | 410 | return positions 411 | 412 | 413 | def parse_stat_header(header_text: str) -> list[tuple[str, int]]: 414 | names = [ 415 | 'CONTAINER ID', 416 | 'NAME', 417 | 'CPU %', 418 | 'MEM USAGE / LIMIT', 419 | 'MEM %', 420 | 'NET I/O', 421 | 'BLOCK I/O', 422 | 'PIDS', 423 | ] 424 | positions = [] 425 | for n in names: 426 | idx = header_text.index(n) 427 | item = (n, idx) 428 | positions.append(item) 429 | 430 | return positions 431 | 432 | 433 | def run_ps_command(user_host: str, no_ssh: bool, run_as_sudo: bool) -> list[dict[str, str]]: 434 | try: 435 | # print("Starting ps ...") 436 | # Run the program and capture its output 437 | output = subprocess.check_output(get_command(['docker', 'ps'], user_host, no_ssh, run_as_sudo)) 438 | 439 | # Convert the output to a string 440 | output_string = bytes.decode(output, 'utf-8') 441 | 442 | # Convert the string to individual lines 443 | lines = [line.strip() for line in output_string.split('\n') if line and line.strip()] 444 | 445 | header = parse_ps_header(lines[0]) 446 | # print(header) 447 | 448 | entries = [] 449 | for line in lines[1:]: 450 | entries.append(parse_line(line, header)) 451 | 452 | results['ps'] = entries 453 | # print("Done with ps") 454 | return entries 455 | except Exception as x: 456 | results['error'] = x 457 | 458 | 459 | def parse_line(line: str, header: list[tuple[str, int]]) -> dict[str, str]: 460 | local_results = {} 461 | tmp_headers = header + [('END', 100000)] 462 | total_len = 0 463 | for (name, idx), (_, next_idx) in zip(tmp_headers[:-1], tmp_headers[1:]): 464 | total_len += idx 465 | 466 | # print("Going from {} to {}".format(idx, next_idx)) 467 | value = line[idx:next_idx].strip() 468 | # print(name + ' -> ' + value) 469 | if name == 'NAMES': 470 | name = 'NAME' 471 | local_results[name] = value 472 | 473 | return local_results 474 | 475 | 476 | def parse_ps_header(header_text: str) -> list[tuple[str, int]]: 477 | names = ['CONTAINER ID', 'IMAGE', 'COMMAND', 'CREATED', 'STATUS', 'PORTS', 'NAMES'] 478 | positions = [] 479 | for n in names: 480 | idx = header_text.index(n) 481 | item = (n, idx) 482 | positions.append(item) 483 | 484 | return positions 485 | 486 | 487 | def get_seconds_key_from_string(uptime_str: str) -> int: 488 | if match := re.search(r'(\d+) second', uptime_str): 489 | dt = int(match.group(1)) 490 | return dt 491 | 492 | if re.search(r'About a minute', uptime_str): 493 | return 60 494 | 495 | if match := re.search(r'(\d+) minute', uptime_str): 496 | dt = int(match.group(1)) 497 | return dt * 60 498 | 499 | if re.search(r'About an hour', uptime_str): 500 | return 60 * 60 501 | 502 | if match := re.search(r'(\d+) hour', uptime_str): 503 | dt = int(match.group(1)) 504 | return dt * 60 * 60 505 | 506 | if re.search(r'About a day', uptime_str): 507 | return 60 * 60 * 24 508 | 509 | if match := re.search(r'(\d+) day', uptime_str): 510 | dt = int(match.group(1)) 511 | return dt * 60 * 60 * 24 512 | 513 | return 1_000_000 514 | 515 | 516 | def run_live_status(): 517 | typer.run(live_status) 518 | 519 | 520 | def version_and_exit_if_requested(): 521 | if '--version' in sys.argv or '-v' in sys.argv: 522 | typer.echo(f'dockerclustermon version {__version__}') 523 | sys.exit(0) 524 | 525 | 526 | if __name__ == '__main__': 527 | version_and_exit_if_requested() 528 | run_live_status() 529 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "dockerclustermon" 3 | version = "0.2.3" 4 | description = "A CLI tool for a live view of your docker containers running on a remote server." 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | dependencies = [ 8 | "rich>=13.9.4", 9 | "typer>=0.13.1", 10 | ] 11 | license = { text = "MIT" } 12 | authors = [ 13 | { name = "Michael Kennedy", email = "michael@talkpython.fm" } 14 | ] 15 | keywords = ["Docker", "Docker Compose", "Monitoring"] 16 | classifiers = [ 17 | "Development Status :: 4 - Beta", 18 | "License :: OSI Approved :: MIT License", 19 | "Programming Language :: Python", 20 | "Programming Language :: Python :: 3.10", 21 | "Programming Language :: Python :: 3.11", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13" 24 | ] 25 | 26 | [project.urls] 27 | "Homepage" = "https://github.com/mikeckennedy/dockerclustermon" 28 | 29 | 30 | 31 | [project.scripts] 32 | ds = "dockerclustermon:run_live_status" 33 | dockerstatus = "dockerclustermon:run_live_status" 34 | 35 | 36 | [build-system] 37 | requires = ["hatchling>=1.21.0", "hatch-vcs>=0.3.0"] 38 | build-backend = "hatchling.build" 39 | 40 | 41 | [tool.hatch.build.targets.sdist] 42 | packages = ["dockerclustermon"] 43 | exclude = [ 44 | "/.github", 45 | "/dist", 46 | "/tests", 47 | "/example", 48 | "/readme_resources", 49 | "/venv", 50 | "/.venv", 51 | "tox.ini", 52 | "uv.lock", 53 | "ruff.toml", 54 | "requirements-dev.txt", 55 | "settings.json", 56 | ] 57 | 58 | [tool.hatch.build.targets.wheel] 59 | packages = ["dockerclustermon"] 60 | exclude = [ 61 | "/.github", 62 | "/dist", 63 | "/tests", 64 | "/example", 65 | "/venv", 66 | "/readme_resources", 67 | "/.venv", 68 | "tox.ini", 69 | "uv.lock", 70 | "ruff.toml", 71 | "requirements-dev.txt", 72 | "settings.json", 73 | ] 74 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # [ruff] 2 | line-length = 120 3 | format.quote-style = "single" 4 | 5 | # Enable Pyflakes `E` and `F` codes by default. 6 | lint.select = ["E", "F"] 7 | lint.ignore = [] 8 | 9 | # Exclude a variety of commonly ignored directories. 10 | exclude = [ 11 | ".bzr", 12 | ".direnv", 13 | ".eggs", 14 | ".git", 15 | ".hg", 16 | ".mypy_cache", 17 | ".nox", 18 | ".pants.d", 19 | ".ruff_cache", 20 | ".svn", 21 | ".tox", 22 | "__pypackages__", 23 | "_build", 24 | "buck-out", 25 | "build", 26 | "dist", 27 | "node_modules", 28 | ".env", 29 | ".venv", 30 | "venv", 31 | ] 32 | lint.per-file-ignores = { } 33 | 34 | # Allow unused variables when underscore-prefixed. 35 | # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 36 | 37 | # Assume Python 3.13. 38 | target-version = "py313" 39 | 40 | #[tool.ruff.mccabe] 41 | ## Unlike Flake8, default to a complexity level of 10. 42 | lint.mccabe.max-complexity = 10 43 | 44 | --------------------------------------------------------------------------------