├── webdisplay ├── __init__.py ├── config.py ├── templates │ └── index.html └── run.py ├── rpimonitorapi ├── __init__.py └── run.py ├── requirements.txt ├── rpimonitor ├── __init__.py ├── constants.py ├── config.py ├── __main__.py └── display.py ├── display.sh ├── .gitignore ├── requirements-webdisplay.txt ├── setup.py ├── rpi-info-api.service ├── install.sh ├── rpi-info-webapi.service ├── install-web-api.sh └── README.md /webdisplay/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rpimonitorapi/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | flask 2 | psutil 3 | requests 4 | -------------------------------------------------------------------------------- /rpimonitor/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | __version__ = '0.1.1' 3 | -------------------------------------------------------------------------------- /display.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | python3 -m rpimonitor $@ 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | build/ 3 | dist/ 4 | *.egg-info 5 | config_test.py 6 | -------------------------------------------------------------------------------- /requirements-webdisplay.txt: -------------------------------------------------------------------------------- 1 | flask 2 | flask_mobility 3 | markupsafe 4 | psutil 5 | requests 6 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from setuptools import setup, find_packages 4 | 5 | setup( 6 | name='rpimonitor', 7 | version='0.1.2', 8 | packages=find_packages(include=['rpimonitor']), 9 | install_requires=['requests'], 10 | python_requires='>=3.6', 11 | ) 12 | -------------------------------------------------------------------------------- /rpi-info-api.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Raspberry Pi Info API 3 | After=multi-user.target 4 | Conflicts=getty@tty1.service 5 | 6 | [Service] 7 | Type=simple 8 | ExecStart=/usr/bin/python3 /usr/local/sbin/rpimonitorapi/run.py 9 | StandardInput=tty-force 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! command -v python3 &> /dev/null 4 | then 5 | apt-get install python3 python3-pip 6 | fi 7 | 8 | $(which python3) -m pip install -r requirements.txt 9 | 10 | cp -r ./rpimonitorapi /usr/local/sbin/ 11 | cp rpi-info-api.service /lib/systemd/system/ 12 | systemctl enable rpi-info-api.service 13 | systemctl start rpi-info-api.service 14 | -------------------------------------------------------------------------------- /rpi-info-webapi.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Raspberry Pi Info Web UI 3 | After=multi-user.target 4 | Conflicts=getty@tty1.service 5 | 6 | [Service] 7 | Type=simple 8 | WorkingDirectory=/usr/local/sbin/rpi-info-monitor 9 | ExecStart=/usr/bin/python3 /usr/local/sbin/rpi-info-monitor/webdisplay/run.py 10 | StandardInput=tty-force 11 | Environment="PYTHONPATH=/usr/local/sbin/rpi-info-monitor" 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /rpimonitor/constants.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | class Styles: 4 | HEADER = '\033[95m' 5 | OKBLUE = '\033[94m' 6 | OKCYAN = '\033[96m' 7 | GREEN = OKGREEN = '\033[92m' 8 | YELLOW = WARNING = '\033[93m' 9 | RED = FAIL = '\033[91m' 10 | ENDC = '\033[0m' 11 | BOLD = '\033[1m' 12 | UNDERLINE = '\033[4m' 13 | 14 | 15 | class TempUnits: 16 | C = CELSIUS = 'celsius' 17 | F = FAHRENHEIT = 'fahrenheit' 18 | 19 | 20 | __all__ = [ 21 | 'Styles', 22 | 'TempUnits', 23 | ] 24 | -------------------------------------------------------------------------------- /install-web-api.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if ! command -v python3 &> /dev/null 4 | then 5 | apt-get install python3 python3-pip 6 | fi 7 | 8 | $(which python3) -m pip install -r requirements-webdisplay.txt 9 | 10 | mkdir /usr/local/sbin/rpi-info-monitor 11 | cp -r ./webdisplay /usr/local/sbin/rpi-info-monitor/ 12 | cp rpi-info-webapi.service /lib/systemd/system/ 13 | 14 | LIB_INSTALLED=$(python3 -c "import rpimonitor" &> /dev/null ; echo $? ) 15 | 16 | if [ $LIB_INSTALLED -ne 0 ] 17 | then 18 | cp -r ./rpimonitor /usr/local/sbin/rpi-info-monitor/ 19 | fi 20 | 21 | systemctl enable rpi-info-webapi.service 22 | systemctl start rpi-info-webapi.service 23 | -------------------------------------------------------------------------------- /webdisplay/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | TEMPLATE_PATH = os.path.join( 4 | os.path.abspath(os.path.dirname(__file__)), 5 | 'templates', 6 | ) 7 | PORT = 5100 8 | DEFAULT_FONT = 18 9 | VERT_FONT = 13.5 10 | MOBILE_HOR_FONT = 7 11 | MOBILE_VERT_FONT = 22 12 | MOBILE_WEIGHT_FONT = 15 13 | TEMP_UNITS = 'celsius' 14 | BASE_DISPLAY = 3 15 | FONT_UNIT = 3 16 | MIN_FONT = 8 17 | 18 | __all__ = [ 19 | 'TEMPLATE_PATH', 20 | 'PORT', 21 | 'DEFAULT_FONT', 22 | 'VERT_FONT', 23 | 'MOBILE_HOR_FONT', 24 | 'MOBILE_VERT_FONT', 25 | 'MOBILE_WEIGHT_FONT', 26 | 'TEMP_UNITS', 27 | 'BASE_DISPLAY', 28 | 'FONT_UNIT', 29 | 'MIN_FONT', 30 | ] 31 | -------------------------------------------------------------------------------- /rpimonitor/config.py: -------------------------------------------------------------------------------- 1 | import shutil as _shutil 2 | 3 | # Add all host/ip info for each pi 4 | HOSTS = [ 5 | { 6 | 'hostname': 'localhost', 7 | 'local_ip': '127.0.0.1', 8 | }, 9 | ] 10 | 11 | # Services you want to monitor 12 | SERVICES = [ 13 | 'pihole-FTL', 14 | 'openvpn', 15 | 'qbittorrent-nox', 16 | 'dockerd', 17 | 'minidlnad', 18 | 'jupyter-lab', 19 | 'sshfs', 20 | ] 21 | 22 | # These will show the number or services 23 | # running instead or 'Running'/'Stopped' 24 | COUNT_DISPLAY_SERVICES = [ 25 | 'qbittorrent-nox', 26 | 'sshfs', 27 | ] 28 | 29 | VALID_STYLES = [ 30 | '\x1b[1m', 31 | '\x1b[0m', 32 | '\x1b[91m', 33 | '\x1b[95m', 34 | '\x1b[94m', 35 | '\x1b[96m', 36 | '\x1b[92m', 37 | '\x1b[4m', 38 | '\x1b[93m', 39 | ] 40 | 41 | # Width of display 42 | WIDTH = _shutil.get_terminal_size()\ 43 | .columns // len(HOSTS) - 1 44 | VALUE_SPACING = 20 45 | API_PORT = 5000 46 | SLEEP_TIME = 5 47 | PCT_RED_THRESH = 80.0 48 | TEMP_UNITS = 'celsius' 49 | MAX_TEMP = 185.0 50 | 51 | # Timeouts for host check and request 52 | SOCK_TIMEOUT = 1 53 | HOST_TIMEOUT = 10 54 | 55 | __all__ = [ 56 | 'HOSTS', 57 | 'SERVICES', 58 | 'COUNT_DISPLAY_SERVICES', 59 | 'WIDTH', 60 | 'API_PORT', 61 | 'SLEEP_TIME', 62 | 'VALUE_SPACING', 63 | 'PCT_RED_THRESH', 64 | 'VALID_STYLES', 65 | 'TEMP_UNITS', 66 | 'MAX_TEMP', 67 | 'HOST_TIMEOUT', 68 | 'SOCK_TIMEOUT', 69 | ] 70 | -------------------------------------------------------------------------------- /rpimonitor/__main__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from rpimonitor import display 4 | from rpimonitor.config import SLEEP_TIME 5 | import argparse 6 | import time 7 | import os 8 | try: 9 | import cursor 10 | except ImportError: 11 | cursor = None 12 | 13 | _clear = lambda: os.system('clear') 14 | 15 | def run(vertical=False, colored=True, temp_units=None): 16 | content = display.get_content( 17 | temp_units=temp_units, 18 | vertical=vertical, 19 | colored=colored, 20 | ) 21 | if not vertical: 22 | content = display.rotate( 23 | content, 24 | as_string=True, 25 | ) 26 | else: 27 | content = '\n'.join(content) 28 | _clear() 29 | print(content) 30 | 31 | 32 | if __name__ == '__main__': 33 | argparser = argparse.ArgumentParser(prog='display') 34 | argparser.add_argument( 35 | '--run-forever', 36 | '--run', '-r', 37 | action='store_true', 38 | ) 39 | argparser.add_argument( 40 | '--vertical', 41 | '-v', 42 | action='store_true', 43 | ) 44 | argparser.add_argument( 45 | '--no-color', 46 | '-c', 47 | action='store_false', 48 | dest='colored', 49 | ) 50 | argparser.add_argument( 51 | '--temp-units', 52 | '--units', 53 | '-t', 54 | type=lambda x: str(x).lower(), 55 | ) 56 | argparser.add_argument( 57 | '--refresh', 58 | type=int, 59 | default=SLEEP_TIME, 60 | ) 61 | args = argparser.parse_args() 62 | 63 | if cursor is not None: 64 | cursor.hide() 65 | # If run_forever is True, run an 66 | # infinite loop (e.g. `while True:`) 67 | while args.run_forever: 68 | try: 69 | run(vertical=args.vertical, 70 | colored=args.colored, 71 | temp_units=args.temp_units) 72 | time.sleep(args.refresh) 73 | except KeyboardInterrupt: 74 | break 75 | else: 76 | content = display.get_content( 77 | temp_units=args.temp_units, 78 | vertical=args.vertical, 79 | colored=args.colored, 80 | ) 81 | if not args.vertical: 82 | content = display.rotate( 83 | content, 84 | as_string=True, 85 | ) 86 | else: 87 | content = '\n'.join(content) 88 | print(content) 89 | -------------------------------------------------------------------------------- /webdisplay/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 23 | 39 | 40 | 41 |
77 | {{content}}
78 |
79 | 50 | ----------------------------------------------------------------------------------------------------------------- 51 | 2021-08-13 04:13 PM 52 | ----------------------------------------------------------------------------------------------------------------- 53 | rpi - 192.168.1.1 |rpi2 - 192.168.1.2 |rpi3 - 192.168.1.3 54 | -------------------------------------|-------------------------------------|------------------------------------- 55 | Status: Up (4d 6h 29m) |Status: Up (0d 17h 15m) |Status: Up (7d 1h 55m) 56 | -------------------------------------|-------------------------------------|------------------------------------- 57 | IP: x.x.x.x |IP: x.x.x.x |IP: x.x.x.x 58 | IP State: Pennsylvania |IP State: New York |IP State: Pennsylvania 59 | IP City: Philadelphia |IP City: New York |IP City: Philadelphia 60 | -------------------------------------|-------------------------------------|------------------------------------- 61 | pihole-FTL: Running |pihole-FTL: Stopped |pihole-FTL: Running 62 | openvpn: Stopped |openvpn: Running |openvpn: Stopped 63 | qbittorrent-nox: 0 Services |qbittorrent-nox: 2 Services |qbittorrent-nox: 0 Services 64 | dockerd: Running |dockerd: Stopped |dockerd: Stopped 65 | minidlnad: Running |minidlnad: Stopped |minidlnad: Stopped 66 | sshfs: 2 Services |sshfs: 2 Services |sshfs: 0 Services 67 | -------------------------------------|-------------------------------------|------------------------------------- 68 | Memory Usage: 0.3/4.1GB (13.4%) |Memory Usage: 0.1/1.9GB (13.5%) |Memory Usage: 0.2/1.0GB (26.3%) 69 | CPU Usage: 2.6% |CPU Usage: 0.2% |CPU Usage: 0.1% 70 | CPU Temp: 45.8C |CPU Temp: 37.5C |CPU Temp: 39.7C 71 | ----------------------------------------------------------------------------------------------------------------- 72 |73 | 74 | #### Command: 75 | 76 | `$ ./display.sh --vertical` or`$ python3 -m rpimonitor --vertical` (or replace `--vertical` with `-v`) 77 | 78 | #### Output: 79 | 80 |  81 | 82 | #### Web Output: 83 | 84 |
85 | 2021-08-13 04:14 PM 86 | ------------------------------------- 87 | rpi - 192.168.1.1 88 | ------------------------------------- 89 | Status: Up (4d 6h 29m) 90 | ------------------------------------- 91 | IP: x.x.x.x 92 | IP State: Pennsylvania 93 | IP City: Philadelphia 94 | ------------------------------------- 95 | pihole-FTL: Running 96 | openvpn: Stopped 97 | qbittorrent-nox: 0 Services 98 | dockerd: Running 99 | minidlnad: Running 100 | sshfs: 2 Services 101 | ------------------------------------- 102 | Memory Usage: 0.3/4.1GB (13.4%) 103 | CPU Usage: 1.1% 104 | CPU Temp: 44.8C 105 | 106 | 107 | rpi2 - 192.168.1.2 108 | ------------------------------------- 109 | Status: Up (0d 17h 15m) 110 | ------------------------------------- 111 | IP: x.x.x.x 112 | IP State: New York 113 | IP City: New York 114 | ------------------------------------- 115 | pihole-FTL: Stopped 116 | openvpn: Running 117 | qbittorrent-nox: 2 Services 118 | dockerd: Stopped 119 | minidlnad: Stopped 120 | sshfs: 2 Services 121 | ------------------------------------- 122 | Memory Usage: 0.1/1.9GB (13.5%) 123 | CPU Usage: 0.4% 124 | CPU Temp: 38.5C 125 | 126 | 127 | rpi3 - 192.168.1.3 128 | ------------------------------------- 129 | Status: Up (7d 1h 56m) 130 | ------------------------------------- 131 | IP: x.x.x.x 132 | IP State: Pennsylvania 133 | IP City: Philadelphia 134 | ------------------------------------- 135 | pihole-FTL: Running 136 | openvpn: Stopped 137 | qbittorrent-nox: 0 Services 138 | dockerd: Stopped 139 | minidlnad: Stopped 140 | sshfs: 0 Services 141 | ------------------------------------- 142 | Memory Usage: 0.2/1.0GB (26.3%) 143 | CPU Usage: 0.0% 144 | CPU Temp: 39.7C 145 |146 | -------------------------------------------------------------------------------- /rpimonitorapi/run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | 3 | from functools import lru_cache 4 | import datetime 5 | import requests 6 | import psutil 7 | from flask import Flask, Response, request 8 | from typing import Dict, List, Union 9 | import json 10 | import time 11 | 12 | app = Flask(__name__) 13 | timestamp = None 14 | 15 | 16 | PORT = 5000 17 | CACHE_TIME = 2 18 | PROC_CACHE = 30 19 | 20 | 21 | class _Process: 22 | def __init__(self, 23 | name: str, 24 | status_only: bool = True): 25 | self.name = name 26 | self.status_only = status_only 27 | self._procs = [] 28 | self.count = 0 29 | self._timestamp = datetime.datetime.now() 30 | 31 | def initialize(self): 32 | self._timestamp = datetime.datetime.now() 33 | self._procs = [ 34 | p for p in psutil.process_iter() 35 | if p.name().lower() == self.name.lower() 36 | ] 37 | 38 | self.count = len(self._procs) 39 | 40 | def _update(self): 41 | if self.count == 0: 42 | last_refresh = ( 43 | datetime.datetime.now() - 44 | self._timestamp 45 | ) 46 | if last_refresh.seconds / 60 >= CACHE_TIME: 47 | self.initialize() 48 | return 49 | 50 | refresh = any( 51 | (not p.is_running()) 52 | for p in self._procs 53 | ) 54 | # Only refresh when 55 | # process status has changed 56 | if refresh: 57 | self.initialize() 58 | 59 | @property 60 | def running(self) -> Union[bool, int]: 61 | self._update() 62 | if self.status_only: 63 | return self.count > 0 64 | else: 65 | return self.count 66 | 67 | def get_info(self) -> Dict[str, object]: 68 | # Call `running` first to make 69 | # sure count is updated 70 | _running = self.running 71 | return { 72 | 'name': self.name, 73 | 'running': _running, 74 | 'count': self.count, 75 | } 76 | 77 | def __str__(self): 78 | if self.status_only: 79 | return ( 80 | 'Running' if self.running 81 | else 'Stopped' 82 | ) 83 | else: 84 | return f'{self.running} Services' 85 | 86 | class ProcessCache: 87 | def __init__(self): 88 | self.processes = {} 89 | self._timestamp = datetime.datetime.now() 90 | 91 | def add(self, name): 92 | if name.lower() in self.processes: 93 | return 94 | proc = _Process(name) 95 | proc.initialize() 96 | self.processes[name.lower()] = proc 97 | 98 | def _check_timestamp(self): 99 | # Periodic refresh for each process 100 | last_refresh = ( 101 | datetime.datetime.now() - 102 | self._timestamp 103 | ) 104 | if last_refresh.seconds / 60 >= PROC_CACHE: 105 | self.reset() 106 | 107 | def reset(self): 108 | self._timestamp = datetime.datetime.now() 109 | for proc in self.processes.values(): 110 | proc.initialize() 111 | 112 | def get(self, name, info_dict=True): 113 | self._check_timestamp() 114 | 115 | if name.lower() not in self.processes: 116 | self.add(name) 117 | 118 | curproc = self.processes[name.lower()] 119 | if info_dict: 120 | return curproc.get_info() 121 | else: 122 | return curproc 123 | 124 | proc_cache = ProcessCache() 125 | 126 | 127 | @lru_cache(None) 128 | def get_ip_info_cached(): 129 | res = requests.get("https://ipleak.net/json/", verify=False) 130 | return res.json() 131 | 132 | 133 | def get_uptime_string(): 134 | uptime = time.time() - psutil.boot_time() 135 | hr, mm = divmod(uptime / 60, 60) 136 | d, hr = divmod(hr, 24) 137 | return f'{d:,.0f}d {hr:.0f}h {mm:.0f}m' 138 | 139 | 140 | def get_usage_info(format_string=True, fahrenheit=True): 141 | mem = psutil.virtual_memory() 142 | used_gb = mem.used / 1e9 143 | total_gb = mem.total / 1e9 144 | 145 | cpu_pct = psutil.cpu_percent() 146 | 147 | temp_info_ = psutil.sensors_temperatures( 148 | fahrenheit=fahrenheit, 149 | ) 150 | temp_info = {} 151 | if len(temp_info_) == 0: 152 | temp_info['temp'] = 'N/A' 153 | temp_info['units'] = '' 154 | else: 155 | curtemp = list(temp_info_.values())[0] 156 | if len(curtemp) > 0: 157 | curtemp = curtemp[0].current 158 | else: 159 | curtemp = -1 160 | temp_info['temp'] = round(curtemp, 1) 161 | temp_info['units'] = ('F' if fahrenheit else 'C') 162 | 163 | if not format_string: 164 | return { 165 | 'memory': { 166 | 'used': used_gb, 167 | 'total': total_gb, 168 | 'percent': mem.percent 169 | }, 170 | 'cpu': { 171 | 'percent': cpu_pct 172 | }, 173 | 'temp': temp_info 174 | } 175 | else: 176 | return { 177 | 'memory':f'{used_gb:.1f}/{total_gb:.0f}GB '\ 178 | f'({mem.percent:.1f}%)', 179 | 'cpu': f'{cpu_pct:.1f}%', 180 | 'temp': f'{temp_info["temp"]}{temp_info["units"]}' 181 | } 182 | 183 | 184 | def _check_timestamp(refresh=CACHE_TIME): 185 | global timestamp 186 | if timestamp is None: 187 | timestamp = datetime.datetime.now() 188 | 189 | timediff = (datetime.datetime.now() - timestamp) 190 | if (timediff.seconds / 60) >= refresh: 191 | # Cache will clear after refresh period is exceeded 192 | get_ip_info_cached.cache_clear() 193 | timestamp = datetime.datetime.now() 194 | 195 | 196 | def get_ip_info() -> Dict[str, str]: 197 | _check_timestamp() 198 | 199 | ip_info = get_ip_info_cached() 200 | return ip_info 201 | 202 | 203 | def get_service_info( 204 | services=('pihole-FTL', 'qbittorrent-nox'), 205 | ) -> List[Dict[str, object]]: 206 | service_info = [] 207 | for service in services: 208 | proc = proc_cache.get(service, info_dict=True) 209 | service_info.append({ 210 | 'name': service, 211 | 'count': proc['count'], 212 | 'running': proc['running'], 213 | }) 214 | return service_info 215 | 216 | 217 | @app.route('/api/clear-cache') 218 | def clear_cache(): 219 | try: 220 | get_ip_info_cached.cache_clear() 221 | proc_cache.reset() 222 | return Response(status=200) 223 | except Exception as e: 224 | return Response(str(e), status=500) 225 | 226 | 227 | @app.route('/api/info') 228 | def server_info(): 229 | try: 230 | ip_info = get_ip_info() 231 | services = request.args\ 232 | .get('services') 233 | if services is not None: 234 | service_info = get_service_info( 235 | services=services.split(','), 236 | ) 237 | else: 238 | service_info = get_service_info() 239 | 240 | format_usage = request.args\ 241 | .get('format_usage', True) 242 | if isinstance(format_usage, str): 243 | format_usage = ( 244 | format_usage.lower() 245 | in ('true', '1') 246 | ) 247 | temp_units = request.args\ 248 | .get('temp_units', 'fahrenheit') 249 | fahrenheit = (temp_units.lower() == 'fahrenheit') 250 | 251 | res = { 252 | 'ip_info': { 253 | 'ip': ip_info.get('ip'), 254 | 'state': ip_info.get('region_name'), 255 | 'city': ip_info.get('city_name'), 256 | }, 257 | 'service_info': service_info, 258 | 'uptime': get_uptime_string(), 259 | 'usage': get_usage_info( 260 | format_usage, 261 | fahrenheit=fahrenheit, 262 | ), 263 | } 264 | 265 | return Response( 266 | json.dumps(res), 267 | status=200, 268 | content_type='application/json', 269 | ) 270 | except Exception as e: 271 | return Response(str(e), status=500) 272 | 273 | 274 | if __name__ == '__main__': 275 | app.run('0.0.0.0', PORT) 276 | -------------------------------------------------------------------------------- /rpimonitor/display.py: -------------------------------------------------------------------------------- 1 | from rpimonitor.constants import Styles 2 | from rpimonitor.config import ( 3 | HOSTS, 4 | SERVICES, 5 | COUNT_DISPLAY_SERVICES, 6 | WIDTH, 7 | API_PORT, 8 | VALUE_SPACING, 9 | PCT_RED_THRESH, 10 | VALID_STYLES, 11 | TEMP_UNITS, 12 | MAX_TEMP, 13 | HOST_TIMEOUT, 14 | SOCK_TIMEOUT, 15 | ) 16 | from typing import Union, List 17 | from numbers import Number 18 | import requests 19 | import datetime 20 | import socket 21 | 22 | 23 | ListOrStr = Union[List[str], str] 24 | 25 | def _check_host_status(host: str, 26 | port: int = API_PORT) -> bool: 27 | sock = socket.socket( 28 | socket.AF_INET, 29 | socket.SOCK_STREAM, 30 | ) 31 | sock.settimeout(SOCK_TIMEOUT) 32 | result = sock.connect_ex((host, port)) 33 | return (result == 0) 34 | 35 | 36 | def timestring(width: int = WIDTH) -> str: 37 | """Get AM/PM formatted time""" 38 | _now = datetime.datetime.now() 39 | hour = _now.time().hour 40 | if hour == 0: 41 | am_pm, hour = False, 12 42 | else: 43 | am_pm, hour = divmod(hour, 12) 44 | 45 | if hour == 0: 46 | hour = 12 47 | 48 | minute = str(_now.time()).split(':')[1] 49 | formatted_time = f"{' ' if hour < 10 else ''}{hour}:"\ 50 | f"{minute} {'PM' if am_pm else 'AM'}" 51 | curdate = str(_now).split(' ')[0] 52 | buffer_ = width - (len(curdate) + len(formatted_time)) 53 | return curdate + ' '*buffer_ + formatted_time 54 | 55 | 56 | def _get_len(row: str) -> int: 57 | """Get length excluding ANSI colors""" 58 | row = row[:] 59 | for s in VALID_STYLES: 60 | row = row.replace(s, '') 61 | return len(row) 62 | 63 | 64 | def rotate(content: list, 65 | as_string: bool = False) -> ListOrStr: 66 | """Rotate the content text or list""" 67 | new_width = ((WIDTH + 1) * len(content) - 1) 68 | curtime = timestring(width=new_width) 69 | content_horizontal = [ 70 | '-'*new_width, 71 | curtime, 72 | '-'*new_width, 73 | [ 74 | '|'.join([ 75 | r + ' '*(WIDTH - _get_len(r)) 76 | for r in row 77 | ]) 78 | for row in zip(*content) 79 | ], 80 | '-'*new_width, 81 | ] 82 | 83 | if as_string: 84 | content_horizontal = '\n'.join([ 85 | *content_horizontal[:3], 86 | '\n'.join(content_horizontal[3]), 87 | *content_horizontal[4:], 88 | ]) 89 | 90 | return content_horizontal 91 | 92 | 93 | def format_usage_str(resources: dict, 94 | colored: bool = True) -> List[str]: 95 | """Format information on resource usage""" 96 | mem_info = resources['memory'] 97 | cpu_info = resources['cpu'] 98 | temp_info = resources.get('temp') 99 | 100 | mem_str = 'Memory Usage:' 101 | membuff = VALUE_SPACING - len(mem_str) 102 | mem_str += ' ' * membuff 103 | 104 | cpu_str = 'CPU Usage:' 105 | cpubuff = VALUE_SPACING - len(cpu_str) 106 | cpu_str += ' ' * cpubuff 107 | 108 | temp_str = None 109 | 110 | if temp_info is not None: 111 | temp_str = 'CPU Temp:' 112 | tempbuff = VALUE_SPACING - len(temp_str) 113 | temp_str += ' ' * tempbuff 114 | 115 | if colored: 116 | mem_color = ( 117 | Styles.RED if mem_info['percent'] > PCT_RED_THRESH 118 | else Styles.GREEN 119 | ) 120 | mem_str += mem_color 121 | cpu_color = ( 122 | Styles.RED if cpu_info['percent'] > PCT_RED_THRESH 123 | else Styles.GREEN 124 | ) 125 | cpu_str += cpu_color 126 | if temp_info is not None: 127 | temp_ = temp_info['temp'] 128 | units = temp_info['units'] 129 | if isinstance(temp_, Number): 130 | max_temp = ( 131 | MAX_TEMP if units.upper() == 'F' 132 | else (MAX_TEMP - 32) * 5 / 9 133 | ) 134 | temp_color = ( 135 | Styles.RED if temp_ > max_temp 136 | else Styles.GREEN 137 | ) 138 | temp_str += temp_color 139 | else: 140 | temp_str += Styles.RED 141 | 142 | mem_str += f'{mem_info["used"]:,.1f}'\ 143 | f'/{mem_info["total"]:,.1f}GB '\ 144 | f'({mem_info["percent"]:.1f}%)' 145 | cpu_str += f'{cpu_info["percent"]:.1f}%' 146 | 147 | mem_str += (WIDTH - _get_len(mem_str)) * ' ' 148 | cpu_str += (WIDTH - _get_len(cpu_str)) * ' ' 149 | 150 | if temp_info is not None: 151 | temp_str += f'{temp_info["temp"]}'\ 152 | f'{temp_info["units"]}' 153 | temp_str += (WIDTH - _get_len(temp_str)) * ' ' 154 | if colored: 155 | temp_str += Styles.ENDC 156 | 157 | if colored: 158 | mem_str += Styles.ENDC 159 | cpu_str += Styles.ENDC 160 | 161 | res = [ 162 | '-'*WIDTH, 163 | mem_str, 164 | cpu_str, 165 | ] 166 | 167 | if temp_info is not None: 168 | res.append(temp_str) 169 | 170 | return res 171 | 172 | 173 | def format_service_str(service: dict, 174 | colored: bool = True) -> str: 175 | """Format string with service information""" 176 | s_title = f'{service["name"]}:' 177 | if service["name"] in COUNT_DISPLAY_SERVICES: 178 | status = (f'{service["count"]} Services') 179 | if colored: 180 | status = ( 181 | Styles.GREEN if service['count'] > 0 182 | else Styles.RED 183 | ) + status + Styles.ENDC 184 | else: 185 | status = ( 186 | 'Running' if service['running'] 187 | else 'Stopped' 188 | ) 189 | if colored: 190 | status = ( 191 | Styles.GREEN if status == 'Running' 192 | else Styles.RED 193 | ) + status + Styles.ENDC 194 | 195 | buffer_ = " "*(VALUE_SPACING - len(s_title)) 196 | 197 | return f'{s_title}{buffer_}{status}' 198 | 199 | 200 | def format_ip_info(ip_info: dict) -> List[str]: 201 | """Format string with IP information""" 202 | ip, state, city = 'IP:', 'IP State:', 'IP City:' 203 | ip += (VALUE_SPACING - len(ip)) * ' ' 204 | state += (VALUE_SPACING - len(state)) * ' ' 205 | city += (VALUE_SPACING - len(city)) * ' ' 206 | if ip_info is None: 207 | return [ 208 | f'{ip}N/A', 209 | f'{state}N/A', 210 | f'{city}N/A', 211 | ] 212 | 213 | ip += ip_info['ip'] 214 | state += ip_info['state'] 215 | city += ip_info['city'] 216 | 217 | return [ip, state, city] 218 | 219 | 220 | def get_content(vertical: bool = True, 221 | colored: bool = True, 222 | temp_units: str = None) -> ListOrStr: 223 | """Get content for display in a list or string""" 224 | 225 | curtime = timestring() 226 | display_content = [ 227 | curtime, 228 | '-'*WIDTH, 229 | ] 230 | 231 | # Used only for horizontal format 232 | _display_content = [] 233 | 234 | for pi_info in HOSTS: 235 | # Populate seperate lists for each 236 | # host to allow for rotation 237 | # from `_display_content` 238 | if not vertical: 239 | display_content = [] 240 | hostname = pi_info['hostname'] 241 | local_ip = pi_info['local_ip'] 242 | if colored: 243 | header = f'{Styles.OKBLUE}{Styles.BOLD}{hostname} - '\ 244 | f'{local_ip}{Styles.ENDC*2}' 245 | else: 246 | header = f'{hostname} - {local_ip}' 247 | 248 | # Status check uses a smaller timeout to 249 | # see if the host is reachable before calling 250 | host_status = _check_host_status( 251 | local_ip, 252 | API_PORT, 253 | ) 254 | if host_status: 255 | try: 256 | services = ','.join(SERVICES) 257 | temp_units = temp_units or TEMP_UNITS 258 | status_content = requests.get( 259 | f'http://{local_ip}:{API_PORT}/api/info' 260 | f'?services={services}' 261 | f'&format_usage=false' 262 | f'&temp_units={temp_units}', 263 | timeout=HOST_TIMEOUT, 264 | ) 265 | status_content = status_content.json() 266 | except requests.exceptions.RequestException as e: 267 | status_content = None 268 | else: 269 | status_content = None 270 | 271 | if status_content is None: 272 | display_content.extend([ 273 | header, 274 | '-'*WIDTH, 275 | 'Status: Down', 276 | '-'*WIDTH, 277 | *format_ip_info(None), 278 | '-'*WIDTH, 279 | ]) 280 | if not vertical: 281 | _display_content.append(display_content) 282 | else: 283 | display_content.append('\n') 284 | continue 285 | 286 | ip_info = status_content['ip_info'] 287 | service_info = status_content['service_info'] 288 | uptime = status_content.get('uptime', '') 289 | usage = status_content.get('usage') 290 | 291 | if uptime: 292 | uptime = f'({uptime})' 293 | 294 | display_content.extend([ 295 | header, 296 | '-'*WIDTH, 297 | f'Status: Up {uptime}', 298 | '-'*WIDTH, 299 | *format_ip_info(ip_info), 300 | '-'*WIDTH, 301 | ]) 302 | 303 | for service in service_info: 304 | display_content.append( 305 | format_service_str( 306 | service, 307 | colored=colored, 308 | ), 309 | ) 310 | 311 | if usage is not None: 312 | display_content.extend( 313 | format_usage_str(usage, colored=colored), 314 | ) 315 | else: 316 | if not vertical: 317 | display_content.append('-'*WIDTH) 318 | else: 319 | display_content.append('') 320 | display_content.extend(['']*2) 321 | 322 | if not vertical: 323 | _display_content.append(display_content) 324 | else: 325 | display_content.append('\n') 326 | 327 | if not vertical: 328 | max_len = max(map(len, _display_content)) 329 | if not all(len(dc) == max_len for dc in _display_content): 330 | for d in _display_content: 331 | if len(d) < max_len: 332 | d.extend(['']*(max_len - len(d))) 333 | display_content = _display_content[:] 334 | 335 | return display_content 336 | --------------------------------------------------------------------------------