├── .gitignore ├── LICENSE ├── README.md ├── assets ├── Proxmon_Demo.mp4 └── image.png ├── proxmon.py ├── pyproject.toml ├── requirements.txt ├── styles.tcss └── utils.py /.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 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | uv.lock 176 | .python-version 177 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Sourav De 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Proxmon 2 | Proxmox monitoring cli tool 3 | ![Proxmon](assets/image.png) 4 | 5 | ## Features 6 | 7 | - Monitor Proxmox nodes and VMs from the command line. 8 | - Display resource usage such as CPU, memory, and storage. 9 | - Fetch and display VM statuses in real-time. 10 | - Lightweight and easy to use. 11 | 12 | ## Installation 13 | 14 | 1. Clone the repository: 15 | ```bash 16 | git clone https://github.com/yourusername/proxmon.git 17 | ``` 18 | 2. Navigate to the project directory: 19 | ```bash 20 | cd proxmon 21 | ``` 22 | 3. Install dependencies: 23 | ```bash 24 | pip install -r requirements.txt 25 | # or 26 | uv sync 27 | ``` 28 | 29 | ## Usage 30 | 31 | Run the tool with: 32 | ```bash 33 | python proxmon.py 34 | ``` 35 | ## Demo 36 | 37 | Here is a quick demo of Proxmon in action: 38 | [https://github.com/Sr-vZ/Proxmon/blob/main/assets/Proxmon_Demo.mp4](https://github.com/Sr-vZ/Proxmon/raw/refs/heads/main/assets/Proxmon_Demo.mp4) 39 | 40 | https://github.com/Sr-vZ/Proxmon/raw/refs/heads/main/assets/Proxmon_Demo.mp4 41 | 42 | ## Contributing 43 | 44 | Contributions are welcome! Please fork the repository and submit a pull request. 45 | 46 | ## License 47 | 48 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 49 | -------------------------------------------------------------------------------- /assets/Proxmon_Demo.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sr-vZ/Proxmon/628b37cf7d4f128fc804de34ffe144831b1325d7/assets/Proxmon_Demo.mp4 -------------------------------------------------------------------------------- /assets/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Sr-vZ/Proxmon/628b37cf7d4f128fc804de34ffe144831b1325d7/assets/image.png -------------------------------------------------------------------------------- /proxmon.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import time 4 | import re 5 | import logging 6 | import requests 7 | import paramiko 8 | import psutil 9 | from dotenv import load_dotenv 10 | from textual.app import App, ComposeResult 11 | from textual.containers import Horizontal, VerticalScroll, Container 12 | from textual.widgets import Header, Footer, DataTable, Static, Log, RichLog 13 | from textual.widgets import Pretty 14 | from textual.timer import Timer 15 | from rich.layout import Layout 16 | from rich.panel import Panel 17 | from rich.table import Table 18 | from rich import box 19 | from rich.text import Text 20 | 21 | from utils import get_cpu_temperature, get_data_from_proxapi, get_vmids, get_vm_data, find_vm_ip_address, get_rrd_data, get_vm_config, draw_vertical_bar_chart 22 | 23 | # Configure logging 24 | logging.basicConfig(level=logging.ERROR) 25 | log_file_path = "/home/srvz/projects/proxmon/Proxmon/proxmon.log" 26 | file_handler = logging.FileHandler(log_file_path) 27 | file_handler.setLevel(logging.INFO) 28 | formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') 29 | file_handler.setFormatter(formatter) 30 | logging.getLogger().addHandler(file_handler) 31 | 32 | # Load environment variables 33 | load_dotenv() 34 | 35 | # Proxmox API configuration 36 | PROXMOX_API_URL = os.getenv("PROXMOX_HOST") 37 | API_TOKEN_ID = os.getenv("TOKEN_ID") 38 | API_TOKEN_SECRET = os.getenv("TOKEN_SECRET") 39 | NODE = os.getenv("NODE") 40 | SSH_HOST = os.getenv("SSH_HOST") 41 | SSH_PORT = int(os.getenv("SSH_PORT", 22)) 42 | SSH_USER = os.getenv("SSH_USER") 43 | SSH_PASSWORD = os.getenv("SSH_PASSWORD") 44 | 45 | from utils import ssh_execute_command 46 | 47 | # Disable warnings for unverified HTTPS requests 48 | requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) 49 | 50 | HEADERS = {"Authorization": f"PVEAPIToken={API_TOKEN_ID}={API_TOKEN_SECRET}"} 51 | 52 | # Global variables 53 | TABLE_CURSOR = dict() 54 | selected_vm = { 55 | "vmid": None, 56 | "type": None, 57 | "name": None, 58 | "status": None, 59 | } 60 | 61 | def toggle_vm(self): 62 | """Start or stop the selected VM or LXC.""" 63 | global selected_vm 64 | if selected_vm["vmid"] is None: 65 | self.notify("Select a VM/LXC first!", severity="warning") 66 | return 67 | 68 | vmid = selected_vm["vmid"] 69 | vm_type = selected_vm["type"] 70 | vm_status = selected_vm["status"].split(" ")[1].lower() 71 | data = get_vm_data(selected_vm["vmid"], selected_vm["type"]) 72 | vm_status = data['status'] 73 | vm_name = selected_vm["name"] 74 | guesttype = "qemu" if vm_type == "vm" else "lxc" 75 | if vm_status == 'running': 76 | self.notify(f"{vm_name} {vm_type} Shutting down!") 77 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/status/shutdown" 78 | else: 79 | self.notify(f"{vm_name} {vm_type} Starting!") 80 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/status/start" 81 | response = requests.post(url, headers=HEADERS, verify=False) 82 | response.raise_for_status() 83 | return response.json() 84 | 85 | class ProxmonApp(App): 86 | CSS_PATH = "styles.tcss" 87 | BINDINGS = [ 88 | ("", "prev_entry", "Up"), 89 | ("", "next_entry", "Down"), 90 | ("󰌑", "", "Select"), 91 | ("S", "toggle_vm", "Start/Stop"), 92 | ("Ctrl+Q", "quit", "Quit") 93 | ] 94 | 95 | timer: Timer 96 | timer_rrd: Timer 97 | layout = Layout() 98 | vm_stats = {} 99 | 100 | def compose(self) -> ComposeResult: 101 | yield Header("Proxmox Monitor") 102 | yield Container(Static("Proxmox Node Monitor", id="topbar"), id="topbar_container", classes="topbar_container") 103 | yield VerticalScroll(DataTable(id="vm_table"), id="vm_table_vs") 104 | yield VerticalScroll(Static(id="stats", expand=True)) 105 | yield Footer() 106 | 107 | 108 | 109 | def update_node_stats(self): 110 | """Update and display node statistics.""" 111 | temp = get_cpu_temperature() 112 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/status" 113 | node_data = get_data_from_proxapi(url) 114 | free_memory = node_data.get("memory").get("free", 0) / (1024 * 1024 * 1024) 115 | total_memory = node_data.get("memory").get("total", 0) / (1024 * 1024 * 1024) 116 | used_memory = round(total_memory - free_memory, 0) 117 | cpu_load = float(node_data.get("loadavg")[0]) / int(node_data.get("cpuinfo").get("cores")) * 100 118 | uptime = int(node_data.get("uptime", 0)) 119 | formatted_uptime = f"{uptime // 86400:02}:{(uptime % 86400) // 3600:02}:{(uptime % 3600) // 60:02}:{uptime % 60:02}" 120 | disk_free = node_data.get("rootfs").get("free") / (1024 * 1024 * 1024) 121 | disk_total = node_data.get("rootfs").get("total") / (1024 * 1024 * 1024) 122 | disk_used = node_data.get("rootfs").get("used") / (1024 * 1024 * 1024) 123 | 124 | text = ( 125 | f"  {node_data.get('pveversion')} " 126 | f"|  CPU: {node_data.get('cpuinfo').get('model')} " 127 | f"| 󰽘 Cores: {node_data.get('cpuinfo').get('cores')} " 128 | f"| 󰄪 Load: {cpu_load:.0f}% " 129 | f"| 🌡️Temp:{str(temp.get('CPU', 'N/A'))} °C " 130 | f"|  RAM: {used_memory:.0f}/{total_memory:.0f} GB " 131 | f"|  Disk: {disk_used:.0f}/{disk_total:.0f} GB " 132 | f"| 󰔚 Uptime: {formatted_uptime} " 133 | ) 134 | self.query_one("#topbar", Static).update(Text(text, justify="full", style="")) 135 | 136 | def on_mount(self): 137 | """Initialize the app and set up timers.""" 138 | global selected_vm 139 | table = self.query_one("#vm_table", DataTable) 140 | table.add_columns("ID", "Status", "Type", "Name", "Core", "CPU (%)", "RAM (MB)", "Disk (GB)", "NetIN (MB)", "NetOUT (MB)", "MAC", "IP") 141 | table.cursor_type = "row" 142 | table.zebra_stripes = False 143 | table.border = True 144 | self.update_table() 145 | self.update_node_stats() 146 | 147 | self.timer = self.set_interval(10, self.update_table) 148 | self.timer_node_stats = self.set_interval(10, self.update_node_stats) 149 | self.timer_rrd = self.set_interval(2, self.update_rrd_data) 150 | 151 | def on_key(self, event) -> None: 152 | """Handle key events.""" 153 | if event.key == "s": 154 | toggle_vm(self) 155 | self.update_table() 156 | 157 | def update_table(self): 158 | """Update the VM and LXC table.""" 159 | global TABLE_CURSOR 160 | table = self.query_one("#vm_table", DataTable) 161 | 162 | # Store the current cursor position 163 | selected_row = table.cursor_row 164 | 165 | table.clear() 166 | 167 | vm_data = [] 168 | for vmid in get_vmids()["vm"]: 169 | json_data = get_vm_data(vmid, type="vm") 170 | json_data['type'] = "VM" 171 | vm_data.append(json_data) 172 | 173 | for vmid in get_vmids()["lxc"]: 174 | json_data = get_vm_data(vmid, type="lxc") 175 | json_data['type'] = "LXC" 176 | vm_data.append(json_data) 177 | 178 | vm_data = sorted(vm_data, key=lambda d: d['vmid']) 179 | ip_data = find_vm_ip_address() 180 | 181 | for idx, data in enumerate(vm_data): 182 | mem_usage = (data.get("maxmem", 0)) / (1024 * 1024) if data.get("mem", 0) != 0 else 0 183 | table.add_row( 184 | str(data['vmid']), 185 | "🟢 Running" if data['status'] == 'running' else "🔴 Stopped", 186 | data['type'], 187 | data['name'], 188 | " " + str(data['cpus']), 189 | str(round(data.get("cpu", 0) * 100, 2)), 190 | str(round(mem_usage, 0)), 191 | str(round(data.get("maxdisk", 0) / (1024 * 1024 * 1024), 0)), 192 | str(round(data['netin'] / (1024 * 1024), 2)), 193 | str(round(data['netout'] / (1024 * 1024), 2)), 194 | ip_data.get(data['vmid']).get('mac', 'N/A'), 195 | ip_data.get(data['vmid']).get('ip', 'N/A'), 196 | ) 197 | 198 | # Restore the cursor position 199 | if selected_row is not None and selected_row < len(table.rows): 200 | table.move_cursor(row=selected_row) 201 | 202 | table.focus() 203 | 204 | 205 | def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: 206 | """Handle row selection in the data table.""" 207 | global selected_vm 208 | global TABLE_CURSOR 209 | table = self.query_one("#vm_table", DataTable) 210 | row_key = event.row_key 211 | row_data = table.get_row(row_key) 212 | 213 | # selected_vm.update({ 214 | # "name": row_data[3], 215 | # "vmid": row_data[0], 216 | # "type": row_data[2].lower(), 217 | # "status": row_data[1], 218 | # }) 219 | selected_vm["name"] = row_data[3] 220 | selected_vm["vmid"] = row_data[0] 221 | selected_vm["type"] = row_data[2].lower() 222 | selected_vm["status"] = row_data[1] 223 | 224 | TABLE_CURSOR = {"cursor_row": event.cursor_row, "row_key": event.row_key} 225 | self.update_rrd_data() 226 | 227 | def update_rrd_data(self): 228 | """Update RRD data for the selected VM or LXC.""" 229 | global selected_vm 230 | if selected_vm["vmid"] is None: 231 | return 232 | 233 | vmid = selected_vm["vmid"] 234 | vm_type = selected_vm["type"] 235 | vm_name = selected_vm["name"] 236 | # rrd_data = get_rrd_data(vmid=vmid, vm_type=vm_type) 237 | # rrd_data = sorted(rrd_data, key=lambda d: d['time']) 238 | 239 | data = get_vm_data(vmid, vm_type) 240 | ts = time.time() 241 | cpu = data.get('cpu', data.get('maxcpu', 0)) * 100 242 | mem = data.get('mem', data.get('maxmem', 0)) / (1024 * 1024) 243 | netin = data.get('netin', 0) / (1024 * 1024) 244 | netout = data.get('netout', 0) / (1024 * 1024) 245 | 246 | if vmid not in self.vm_stats: 247 | self.vm_stats[vmid] = {"ts": [], "cpu": [], "mem": [], "netin": [], "netout": []} 248 | 249 | # Update stats and keep only the last 100 values 250 | self.vm_stats[vmid]["ts"].append(ts) 251 | self.vm_stats[vmid]["ts"] = self.vm_stats[vmid]["ts"][-100:] 252 | 253 | self.vm_stats[vmid]["cpu"].append(cpu) 254 | self.vm_stats[vmid]["cpu"] = self.vm_stats[vmid]["cpu"][-100:] 255 | 256 | self.vm_stats[vmid]["mem"].append(mem) 257 | self.vm_stats[vmid]["mem"] = self.vm_stats[vmid]["mem"][-100:] 258 | 259 | self.vm_stats[vmid]["netin"].append(netin) 260 | self.vm_stats[vmid]["netin"] = self.vm_stats[vmid]["netin"][-100:] 261 | 262 | self.vm_stats[vmid]["netout"].append(netout) 263 | self.vm_stats[vmid]["netout"] = self.vm_stats[vmid]["netout"][-100:] 264 | 265 | layout = self.stats_layout() 266 | vm_type_icon = "" if vm_type =="lxc" else "" 267 | layout["stat_header"].update(Panel(f"VMID: {vmid} Type: {vm_type_icon} {vm_type.upper()}"+ \ 268 | f" Name: {vm_name} CPU: 󰓅 {cpu:.1f} % Mem:  {mem:.1f} MB NetIn: 󰅢 MB {netin:.1f} Netout: 󰅧 {netout:.1f} MB" , 269 | title="Stats", border_style="blue")) 270 | 271 | if data.get('status') == "running": 272 | layout["cpu"].update(Panel(draw_vertical_bar_chart(self.vm_stats[vmid]['cpu'], height=8, chart_width=90, color="green"), title="CPU Usage", border_style="blue")) 273 | layout["mem"].update(Panel(draw_vertical_bar_chart(self.vm_stats[vmid]['mem'], height=8, chart_width=90, color="cyan", decimal_places=0), title="Memory Usage", border_style="blue")) 274 | layout["netin"].update(Panel(draw_vertical_bar_chart(self.vm_stats[vmid]['netin'], height=8, chart_width=90, color="yellow", decimal_places=1, char="."), title="Network In", border_style="blue")) 275 | layout["netout"].update(Panel(draw_vertical_bar_chart(self.vm_stats[vmid]['netout'], height=8, chart_width=90, color="dodger_blue2", decimal_places=1, char="."), title="Network Out", border_style="blue")) 276 | else: 277 | layout["cpu"].update(Panel("VM/LXC Not Running!", title="CPU Usage", border_style="blue")) 278 | layout["mem"].update(Panel("VM/LXC Not Running!", title="Memory Usage", border_style="blue")) 279 | layout["netin"].update(Panel("VM/LXC Not Running!", title="Network In", border_style="blue")) 280 | layout["netout"].update(Panel("VM/LXC Not Running!", title="Network Out", border_style="blue")) 281 | 282 | self.query_one('#stats', Static).update(layout) 283 | # layout["misc"].update(Panel(str(get_vm_config(vmid, vm_type)), border_style="blue")) 284 | layout["misc"].update(Panel(json.dumps(get_vm_config(vmid, vm_type),indent=4, sort_keys=True), border_style="magenta")) 285 | 286 | def stats_layout(self): 287 | """Define the layout for statistics display.""" 288 | layout = self.layout 289 | layout.split( 290 | Layout(name="stat_header", size=3), 291 | Layout(name="main"), 292 | Layout(name="misc"), 293 | ) 294 | layout["main"].split_column( 295 | Layout(name="process"), 296 | Layout(name="network"), 297 | ) 298 | layout["process"].split_row( 299 | Layout(name="cpu"), 300 | Layout(name="mem"), 301 | ) 302 | layout["network"].split_row( 303 | Layout(name="netin"), 304 | Layout(name="netout"), 305 | ) 306 | return layout 307 | 308 | 309 | 310 | if __name__ == "__main__": 311 | app = ProxmonApp() 312 | app.run() -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "proxmon" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.12" 7 | dependencies = [ 8 | "paramiko>=3.5.1", 9 | "pip>=25.0.1", 10 | "psutil>=7.0.0", 11 | "python-dotenv>=1.0.1", 12 | "readchar>=4.2.1", 13 | "requests>=2.32.3", 14 | "rich>=13.9.4", 15 | "textual>=2.1.2", 16 | ] 17 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | "paramiko>=3.5.1" 2 | "psutil>=7.0.0" 3 | "python-dotenv>=1.0.1" 4 | "readchar>=4.2.1" 5 | "requests>=2.32.3" 6 | "rich>=13.9.4" 7 | "textual>=2.1.2" 8 | -------------------------------------------------------------------------------- /styles.tcss: -------------------------------------------------------------------------------- 1 | /* styles.css */ 2 | DataTable { 3 | border: round magenta; 4 | max-height: 25vh; 5 | background: black 100%; 6 | 7 | } 8 | 9 | #topbar_container{ 10 | height:3; 11 | text-align: justify; 12 | } 13 | 14 | #vm_table_vs{ 15 | max-height: 25vh; 16 | } 17 | 18 | #topbar{ 19 | height:3; 20 | max-height: 3; 21 | border: round magenta; 22 | } 23 | 24 | ToastRack { 25 | align: right top; 26 | } 27 | 28 | Toast { 29 | padding: 2; 30 | } -------------------------------------------------------------------------------- /utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import os 3 | import re 4 | import paramiko 5 | import psutil 6 | from dotenv import load_dotenv 7 | 8 | load_dotenv() 9 | 10 | # Proxmox API configuration 11 | PROXMOX_API_URL = os.getenv("PROXMOX_HOST") 12 | API_TOKEN_ID = os.getenv("TOKEN_ID") 13 | API_TOKEN_SECRET = os.getenv("TOKEN_SECRET") 14 | NODE = os.getenv("NODE") 15 | SSH_HOST = os.getenv("SSH_HOST") 16 | SSH_PORT = int(os.getenv("SSH_PORT", 22)) 17 | SSH_USER = os.getenv("SSH_USER") 18 | SSH_PASSWORD = os.getenv("SSH_PASSWORD") 19 | 20 | HEADERS = {"Authorization": f"PVEAPIToken={API_TOKEN_ID}={API_TOKEN_SECRET}"} 21 | 22 | # Global variables 23 | TABLE_CURSOR = dict() 24 | selected_vm = { 25 | "vmid": None, 26 | "type": None, 27 | "name": None, 28 | "status": None, 29 | } 30 | 31 | def ssh_execute_command(host, username, password, command, port=22): 32 | """SSH into a Proxmox node and execute a command.""" 33 | try: 34 | client = paramiko.SSHClient() 35 | client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) 36 | client.connect(host, port=port, username=username, password=password, timeout=10) 37 | stdin, stdout, stderr = client.exec_command(command) 38 | output = stdout.read().decode().strip() 39 | error = stderr.read().decode().strip() 40 | client.close() 41 | return output if output else error 42 | except Exception as e: 43 | return f"SSH Connection failed: {str(e)}" 44 | 45 | # Fetch data from Proxmox API 46 | def get_data_from_proxapi(url): 47 | """Fetch data from Proxmox API""" 48 | response = requests.get(url, headers=HEADERS, verify=False) 49 | response.raise_for_status() 50 | return response.json()['data'] 51 | 52 | def get_vm_config(vmid,vm_type): 53 | """Fetch vm/lxc config""" 54 | if vm_type == "vm": 55 | guesttype = "qemu" 56 | else: 57 | guesttype = "lxc" 58 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/config" 59 | return get_data_from_proxapi(url) 60 | 61 | def get_vmids(): 62 | vmids = {"vm": [], "lxc": []} 63 | vm_data = get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/qemu") 64 | vmids["vm"].extend([vm["vmid"] for vm in vm_data]) 65 | lxc_data = get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/lxc") 66 | vmids["lxc"].extend([lxc["vmid"] for lxc in lxc_data]) 67 | return vmids 68 | 69 | def get_vm_data(vmid, type="vm"): 70 | if type == "vm": 71 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/qemu/{vmid}/status/current" 72 | elif type == "lxc": 73 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/lxc/{vmid}/status/current" 74 | else: 75 | return None 76 | # url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/qemu/{vmid}/status/current" 77 | return get_data_from_proxapi(url) 78 | 79 | def get_rrd_data(vmid, type="vm"): 80 | if type == "vm": 81 | guesttype = "qemu" 82 | else: 83 | guesttype = "lxc" 84 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/rrddata?timeframe=hour" 85 | return get_data_from_proxapi(url) 86 | 87 | def draw_vertical_bar_chart(data, height=10, value_width=5, 88 | decimal_places=1, chart_width=None, 89 | color = "white", char="░", 90 | max_output_width=200): 91 | """ 92 | Draws a vertical bar chart with consistent padding and adjustable width. 93 | 94 | Args: 95 | data: A list of numerical values to chart. 96 | height: The height of the chart. 97 | value_width: The total width of the formatted numerical value. 98 | decimal_places: The number of decimal places to display. 99 | chart_width: The width of the bars in the chart. If None, it defaults to the length of the data. 100 | max_output_width: Maximum width of the output string. 101 | Returns: 102 | A string representation of the chart. 103 | """ 104 | if chart_width is None: 105 | chart_width = len(data) 106 | 107 | max_value = max(data) 108 | min_value = min(data) 109 | range_value = max_value - min_value 110 | 111 | chart = [[' ' for _ in range(chart_width)] for _ in range(height)] 112 | 113 | for i, value in enumerate(data): 114 | normalized_value = int((value - min_value) / range_value * (height - 1)) if range_value > 0 else 0 115 | if i < chart_width: 116 | for h in range(normalized_value + 1): 117 | if h < height: 118 | chart[height - 1 - h][i] = f'{char}' 119 | 120 | chart_str = "" 121 | format_string = f">{value_width}.{decimal_places}f" 122 | for h in range(height): 123 | value_at_height = min_value + (range_value * (height - 1 - h) / (height - 1)) if range_value > 0 else min_value 124 | formatted_value = f"{value_at_height:{format_string}}" 125 | chart_str += f"{formatted_value} ┤ " + ''.join(chart[h]) + "\n" 126 | 127 | chart_str += f"{' ' * value_width} ╰" + '─' * chart_width + "\n" 128 | # chart_str += " " + ' '.join(str(i) for i in range(len(data))) + "\n" 129 | 130 | # Limit the output width 131 | lines = chart_str.splitlines() 132 | truncated_lines = [] 133 | for line in lines: 134 | if len(line) > max_output_width: 135 | truncated_lines.append(line[:max_output_width] + "...") 136 | else: 137 | truncated_lines.append(line) 138 | chart_str = "\n".join(truncated_lines) 139 | 140 | return f"[{color}]{chart_str}[/{color}]" 141 | 142 | def get_data_from_proxapi(url): 143 | """Fetch data from Proxmox API.""" 144 | response = requests.get(url, headers=HEADERS, verify=False) 145 | response.raise_for_status() 146 | return response.json()['data'] 147 | 148 | def get_vmids(): 149 | """Retrieve VM and LXC IDs from Proxmox.""" 150 | vmids = {"vm": [], "lxc": []} 151 | vm_data = get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/qemu") 152 | vmids["vm"].extend([vm["vmid"] for vm in vm_data]) 153 | lxc_data = get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/lxc") 154 | vmids["lxc"].extend([lxc["vmid"] for lxc in lxc_data]) 155 | return vmids 156 | 157 | def get_vmids_dict(): 158 | """Retrieve VM and LXC IDs as a dictionary.""" 159 | vmids = {} 160 | for vm in get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/qemu"): 161 | vmids[vm["vmid"]] = {"type": "qemu"} 162 | for lxc in get_data_from_proxapi(f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/lxc"): 163 | vmids[lxc["vmid"]] = {"type": "lxc"} 164 | return vmids 165 | 166 | def get_vm_data(vmid, type="vm"): 167 | """Retrieve data for a specific VM or LXC.""" 168 | guesttype = "qemu" if type == "vm" else "lxc" 169 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/status/current" 170 | return get_data_from_proxapi(url) 171 | 172 | def get_rrd_data(vmid, vm_type="vm"): 173 | """Retrieve RRD data for a specific VM or LXC.""" 174 | guesttype = "qemu" if vm_type == "vm" else "lxc" 175 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{guesttype}/{vmid}/rrddata?timeframe=hour" 176 | return get_data_from_proxapi(url) 177 | 178 | def get_pve_subnets(): 179 | """Retrieve Proxmox subnets.""" 180 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/network/" 181 | data = get_data_from_proxapi(url) 182 | return [item['cidr'] for item in data if 'cidr' in item] 183 | 184 | def find_vm_ip_address(): 185 | """Find IP addresses of VMs based on their MAC addresses.""" 186 | ip_table = {} 187 | neigh_data = ssh_execute_command(SSH_HOST, SSH_USER, SSH_PASSWORD, "arp -a", SSH_PORT) 188 | pattern = re.compile(r"\(\s*(\d+\.\d+\.\d+\.\d+)\s*\) at (\S+)") 189 | matches = pattern.findall(neigh_data) 190 | mac_to_ip = {mac.upper(): ip for ip, mac in matches} 191 | 192 | vmid_data = get_vmids_dict() 193 | for vmid, info in vmid_data.items(): 194 | url = f"{PROXMOX_API_URL}/api2/json/nodes/{NODE}/{info['type']}/{vmid}/config" 195 | config_data = get_data_from_proxapi(url) 196 | net_config = config_data.get("net0", "") 197 | mac_match = re.search(r"(?:hwaddr=)?([0-9A-Fa-f]{2}(:[0-9A-Fa-f]{2}){5})", net_config) 198 | mac_address = mac_match.group(1) if mac_match else None 199 | if mac_address: 200 | ip_table[vmid] = {"mac": mac_address, "ip": mac_to_ip.get(mac_address, "N/A")} 201 | return ip_table 202 | 203 | def get_cpu_temperature(): 204 | """Get the CPU temperature using psutil or SSH command.""" 205 | # if hasattr(psutil, "sensors_temperatures"): 206 | # temps = psutil.sensors_temperatures() 207 | # if "coretemp" in temps: 208 | # return {entry.label or "CPU": entry.current for entry in temps["coretemp"]} 209 | # elif "cpu_thermal" in temps: 210 | # return {"CPU": temps["cpu_thermal"][0].current} 211 | temp = ssh_execute_command(SSH_HOST, SSH_USER, SSH_PASSWORD, "cat /sys/class/thermal/thermal_zone0/temp", SSH_PORT) 212 | return {"CPU": "%.1f" % (float(temp) / 1000)} if temp else None --------------------------------------------------------------------------------