├── LICENSE ├── README.md ├── llmtop ├── __init__.py └── llmtop.py ├── manifest.in ├── requirements.txt ├── screenshot.png └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Arinbjörn Kolbeinsson 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 | # llmtop 2 | 3 | `llmtop` is an intelligent system monitoring tool that combines real-time system metrics with LLM-powered insights. It provides a dynamic terminal interface showing system performance metrics enhanced with AI-driven analysis. 4 | 5 | > **Note**: This project is currently in beta testing. Features and interfaces may change. 6 | 7 | ![llmtop Screenshot](screenshot.png) 8 | 9 | ## Features 10 | 11 | - Real-time system metrics monitoring (CPU, Memory, Disk, Network) 12 | - Process monitoring with resource usage 13 | - AI-powered system analysis using either OpenAI or Ollama 14 | - Smart alerting system for resource thresholds 15 | - Dynamic terminal UI with auto-updating metrics 16 | 17 | ## Installation 18 | 19 | Install directly from PyPI: 20 | ```bash 21 | pip install llmtop 22 | ``` 23 | 24 | ## LLM Backend Options 25 | 26 | llmtop supports two LLM backends for system analysis: 27 | 28 | ### OpenAI (GPT-4o mini) 29 | 1. Set your OpenAI API key: 30 | ```bash 31 | export OPENAI_API_KEY='your-api-key-here' 32 | ``` 33 | 2. Run with OpenAI: 34 | ```bash 35 | llmtop --use-openai 36 | ``` 37 | 38 | ### Ollama with Llama 3.2 3B (Default) 39 | 1. Install Ollama from [ollama.ai](https://ollama.ai) 40 | 2. Start the Ollama service 41 | 3. Run llmtop: 42 | ```bash 43 | llmtop 44 | ``` 45 | 46 | ## Command Line Options 47 | 48 | ```bash 49 | llmtop [OPTIONS] 50 | 51 | Options: 52 | --update-frequency INTEGER Update frequency in seconds (default: 5) 53 | --use-openai Use OpenAI instead of local model 54 | --history-length INTEGER Number of historical data points to keep (default: 60) 55 | --help Show this message and exit 56 | ``` 57 | 58 | ## Usage Tips 59 | 60 | - The tool defaults to using Ollama for analysis, which is free but requires local installation 61 | - OpenAI mode provides more detailed analysis but requires an API key and has associated costs 62 | - Adjust update frequency based on your system's performance and monitoring needs 63 | 64 | ## Known Issues 65 | 66 | - Experimental (untested) support for Windows systems 67 | - Update frequency might need adjustment on slower systems 68 | - Some process names may be truncated in the display 69 | 70 | ## Contributing 71 | 72 | This project is in beta, and we welcome contributions! Please feel free to: 73 | 74 | - Report bugs 75 | - Suggest features 76 | - Submit pull requests 77 | 78 | ## License 79 | 80 | MIT License - see [LICENSE](LICENSE) file for details. 81 | 82 | ## Acknowledgements 83 | 84 | Built using: 85 | - Rich for terminal UI 86 | - OpenAI/Ollama for LLM integration 87 | - psutil for system metrics 88 | -------------------------------------------------------------------------------- /llmtop/__init__.py: -------------------------------------------------------------------------------- 1 | # llmtop/__init__.py 2 | """LLM-powered system monitoring""" 3 | __version__ = "0.1.0" -------------------------------------------------------------------------------- /llmtop/llmtop.py: -------------------------------------------------------------------------------- 1 | from rich.console import Console 2 | from rich.table import Table 3 | from rich.live import Live 4 | from rich.panel import Panel 5 | from rich.layout import Layout 6 | from rich.text import Text 7 | from rich.console import Group 8 | from rich import box 9 | import psutil 10 | import platform 11 | import GPUtil 12 | import os 13 | import time 14 | from datetime import datetime 15 | import argparse 16 | from collections import deque 17 | from typing import Dict, List, Any 18 | import logging 19 | import json 20 | 21 | 22 | # Set up logging configuration at the top of the file 23 | logging.basicConfig( 24 | level=logging.DEBUG, 25 | format='%(asctime)s - %(levelname)s - %(message)s', 26 | filename='system_monitor.log' 27 | ) 28 | console = Console() 29 | 30 | class SystemMetricsCollector: 31 | def __init__(self, history_length: int = 60): 32 | self.history_length = history_length 33 | self.history = { 34 | 'cpu': deque(maxlen=history_length), 35 | 'memory': deque(maxlen=history_length), 36 | 'network': deque(maxlen=history_length), 37 | 'disk': deque(maxlen=history_length), 38 | 'processes': deque(maxlen=history_length) 39 | } 40 | self.system_info = self._get_system_info() 41 | 42 | def _get_system_info(self) -> Dict: 43 | uname = platform.uname() 44 | return { 45 | "System": uname.system, 46 | "Node Name": uname.node, 47 | "Release": uname.release, 48 | "Version": uname.version, 49 | "Machine": uname.machine, 50 | "Processor": uname.processor 51 | } 52 | 53 | def _get_cpu_info(self) -> Dict: 54 | try: 55 | cpu_freq = psutil.cpu_freq() 56 | cpu_percent = psutil.cpu_percent(interval=0.1, percpu=True) 57 | return { 58 | "Physical Cores": psutil.cpu_count(logical=False), 59 | "Total Cores": psutil.cpu_count(logical=True), 60 | "Current Frequency": f"{cpu_freq.current:.1f}MHz" if cpu_freq else "N/A", 61 | "Per Core Usage": cpu_percent, 62 | "Average Usage": sum(cpu_percent) / len(cpu_percent) if cpu_percent else 0.0 63 | } 64 | except Exception as e: 65 | return { 66 | "Physical Cores": psutil.cpu_count(logical=False), 67 | "Total Cores": psutil.cpu_count(logical=True), 68 | "Current Frequency": "N/A", 69 | "Per Core Usage": [], 70 | "Average Usage": 0.0 71 | } 72 | 73 | def _get_memory_info(self) -> Dict: 74 | vm = psutil.virtual_memory() 75 | swap = psutil.swap_memory() 76 | return { 77 | "Total": f"{vm.total / (1024**3):.1f}GB", 78 | "Available": f"{vm.available / (1024**3):.1f}GB", 79 | "Used": f"{vm.used / (1024**3):.1f}GB", 80 | "Percentage": vm.percent, 81 | "Swap Used": f"{swap.used / (1024**3):.1f}GB", 82 | "Swap Percentage": swap.percent 83 | } 84 | 85 | def _get_disk_info(self) -> List[Dict]: 86 | disk_info = [] 87 | for partition in psutil.disk_partitions(): 88 | try: 89 | usage = psutil.disk_usage(partition.mountpoint) 90 | disk_info.append({ 91 | "Device": partition.device, 92 | "Mountpoint": partition.mountpoint, 93 | "Total": f"{usage.total / (1024**3):.1f}GB", 94 | "Used": f"{usage.used / (1024**3):.1f}GB", 95 | "Free": f"{usage.free / (1024**3):.1f}GB", 96 | "Percentage": usage.percent 97 | }) 98 | except PermissionError: 99 | continue 100 | return disk_info 101 | 102 | def _get_network_info(self) -> Dict: 103 | network = psutil.net_io_counters() 104 | return { 105 | "Bytes Sent": f"{network.bytes_sent / (1024**2):.1f}MB", 106 | "Bytes Received": f"{network.bytes_recv / (1024**2):.1f}MB", 107 | "Packets Sent": network.packets_sent, 108 | "Packets Received": network.packets_recv, 109 | "Error In": network.errin, 110 | "Error Out": network.errout 111 | } 112 | 113 | def _get_process_info(self, limit: int = 10) -> List[Dict]: 114 | """ 115 | Get information about running processes, handling cases where CPU% might be None 116 | """ 117 | processes = [] 118 | try: 119 | for proc in psutil.process_iter(['pid', 'name', 'cpu_percent', 'memory_percent']): 120 | try: 121 | pinfo = proc.info 122 | # Only add processes with valid CPU percentage 123 | if pinfo['cpu_percent'] is not None: 124 | processes.append({ 125 | "PID": pinfo['pid'], 126 | "Name": pinfo['name'], 127 | "CPU%": pinfo['cpu_percent'] or 0.0, # Convert None to 0.0 128 | "Memory%": pinfo['memory_percent'] or 0.0 # Convert None to 0.0 129 | }) 130 | except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): 131 | continue 132 | except Exception as e: 133 | logging.warning(f"Error getting process info: {str(e)}") 134 | continue 135 | 136 | # Only try to sort if we have processes 137 | if processes: 138 | # Filter out any remaining None values and sort 139 | valid_processes = [p for p in processes if p['CPU%'] is not None] 140 | return sorted(valid_processes, key=lambda x: x['CPU%'], reverse=True)[:limit] 141 | else: 142 | return [] 143 | 144 | except Exception as e: 145 | logging.error(f"Error in _get_process_info: {str(e)}", exc_info=True) 146 | return [] # Return empty list on error 147 | 148 | def collect(self) -> Dict[str, Any]: 149 | logging.debug("Starting metrics collection") 150 | try: 151 | current_metrics = { 152 | 'timestamp': datetime.now(), 153 | 'cpu': self._get_cpu_info(), 154 | 'memory': self._get_memory_info(), 155 | 'disk': self._get_disk_info(), 156 | 'network': self._get_network_info(), 157 | 'processes': self._get_process_info() 158 | } 159 | logging.debug(f"Collected metrics: {current_metrics}") 160 | 161 | # Store in history 162 | for metric, value in current_metrics.items(): 163 | if metric != 'timestamp': 164 | self.history[metric].append({ 165 | 'timestamp': current_metrics['timestamp'], 166 | 'data': value 167 | }) 168 | 169 | return current_metrics 170 | except Exception as e: 171 | logging.error(f"Error in collect: {str(e)}", exc_info=True) 172 | raise 173 | 174 | class AlertManager: 175 | def __init__(self): 176 | self.thresholds = { 177 | 'cpu_average': 80.0, 178 | 'memory_percent': 90.0, 179 | 'disk_percent': 85.0, 180 | 'swap_percent': 60.0 181 | } 182 | self.alerts = deque(maxlen=100) # Keep last 100 alerts 183 | 184 | def check_metrics(self, metrics: Dict) -> List[Dict]: 185 | logging.debug(f"Checking metrics: {metrics}") 186 | new_alerts = [] 187 | 188 | try: 189 | # CPU alerts 190 | logging.debug("Checking CPU metrics") 191 | if 'cpu' in metrics: 192 | logging.debug(f"CPU metrics found: {metrics['cpu']}") 193 | if 'Average Usage' in metrics['cpu']: 194 | cpu_usage = metrics['cpu']['Average Usage'] 195 | logging.debug(f"CPU usage: {cpu_usage}") 196 | if cpu_usage > self.thresholds['cpu_average']: 197 | new_alerts.append({ 198 | 'level': 'critical' if cpu_usage > 90 else 'warning', 199 | 'component': 'CPU', 200 | 'message': f"High CPU usage: {cpu_usage:.1f}%" 201 | }) 202 | else: 203 | logging.warning("No CPU metrics found in data") 204 | 205 | # Memory alerts 206 | logging.debug("Checking memory metrics") 207 | if 'memory' in metrics: 208 | logging.debug(f"Memory metrics found: {metrics['memory']}") 209 | if 'Percentage' in metrics['memory']: 210 | mem_usage = metrics['memory']['Percentage'] 211 | logging.debug(f"Memory usage: {mem_usage}") 212 | if mem_usage > self.thresholds['memory_percent']: 213 | new_alerts.append({ 214 | 'level': 'critical' if mem_usage > 95 else 'warning', 215 | 'component': 'Memory', 216 | 'message': f"High memory usage: {mem_usage}%" 217 | }) 218 | else: 219 | logging.warning("No memory metrics found in data") 220 | 221 | # Disk alerts 222 | if 'disk' in metrics: 223 | for disk in metrics['disk']: 224 | if disk['Percentage'] > self.thresholds['disk_percent']: 225 | new_alerts.append({ 226 | 'level': 'warning', 227 | 'component': 'Disk', 228 | 'message': f"High disk usage on {disk['Mountpoint']}: {disk['Percentage']}%" 229 | }) 230 | 231 | if new_alerts: 232 | for alert in new_alerts: 233 | alert['timestamp'] = datetime.now() 234 | self.alerts.append(alert) 235 | 236 | return new_alerts 237 | 238 | except Exception as e: 239 | logging.error(f"Error in check_metrics: {str(e)}", exc_info=True) 240 | return [] 241 | 242 | class SystemAnalyzer: 243 | def __init__(self, use_openai: bool = False): 244 | self.use_openai = use_openai 245 | self.client = None 246 | if use_openai: 247 | try: 248 | import openai 249 | self.client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY")) 250 | except ImportError: 251 | logging.warning("OpenAI package not installed. Please install with 'pip install openai'") 252 | self.use_openai = False 253 | 254 | self.analyses = deque(maxlen=100) # Keep last 100 analyses 255 | self.visible_offset = 0 # Track where we are in the history 256 | 257 | 258 | def get_visible_messages(self, console_height: int) -> List[Dict]: 259 | """ 260 | Get messages that should be visible based on console height. 261 | Now with added padding for complete message visibility. 262 | """ 263 | messages = list(self.analyses) 264 | visible_messages = [] 265 | current_height = 0 266 | # Add extra padding (2 lines) to target height to ensure complete visibility 267 | target_height = min(console_height - 6, 20) # Increased padding from 4 to 6 268 | 269 | for msg in reversed(messages): # Start from newest 270 | # Add a small buffer (+1) to line estimation for safety 271 | msg_lines = (len(msg['analysis']) // 50 + 2) # Added +2 instead of +1 272 | if current_height + msg_lines > target_height: 273 | break 274 | visible_messages.insert(0, msg) # Insert at start to maintain order 275 | current_height += msg_lines 276 | 277 | return visible_messages 278 | 279 | def _serialize_datetime(self, obj): 280 | """Helper method to serialize datetime objects""" 281 | if isinstance(obj, datetime): 282 | return obj.isoformat() 283 | return str(obj) 284 | 285 | def get_previous_analyses(self, count: int = 2) -> List[str]: 286 | """Get the last N analyses, excluding the current one""" 287 | analyses_list = list(self.analyses) 288 | return [a['analysis'] for a in analyses_list[-count:]] if analyses_list else [] 289 | 290 | def _prepare_metrics_string(self, metrics: Dict, history: Dict) -> str: 291 | try: 292 | # Create a simplified, clean string representation of the metrics 293 | current_metrics = { 294 | 'CPU Usage': f"{metrics.get('cpu', {}).get('Average Usage', 0):.1f}%", 295 | 'Memory Usage': f"{metrics.get('memory', {}).get('Percentage', 0):.1f}%", 296 | 'Active Processes': len(metrics.get('processes', [])), 297 | 'Network Packets': metrics.get('network', {}).get('Packets Received', 0), 298 | # 'Disk Usage': [ 299 | # f"{disk.get('Mountpoint')}: {disk.get('Percentage')}%" 300 | # for disk in metrics.get('disk', []) 301 | # if isinstance(disk, dict) 302 | # ], 303 | 'Top Processes': [ # Add this new section 304 | f"{proc.get('Name', 'Unknown')} (CPU: {proc.get('CPU%', 0):.1f}%, Mem: {proc.get('Memory%', 0):.1f}%)" 305 | for proc in metrics.get('processes', [])[:3] # Show top 3 processes 306 | ] 307 | } 308 | 309 | metrics_str = "Current System Metrics:\n" 310 | for key, value in current_metrics.items(): 311 | if isinstance(value, list): 312 | metrics_str += f"{key}:\n" 313 | for item in value: 314 | metrics_str += f" - {item}\n" 315 | else: 316 | metrics_str += f"{key}: {value}\n" 317 | 318 | return metrics_str 319 | 320 | except Exception as e: 321 | logging.error(f"Error preparing metrics string: {str(e)}") 322 | return "Error preparing metrics data" 323 | 324 | def analyze(self, metrics: Dict, history: Dict) -> str: 325 | try: 326 | # Prepare metrics string 327 | metrics_str = self._prepare_metrics_string(metrics, history) 328 | 329 | # Get previous analyses 330 | prev_analyses = self.get_previous_analyses(2) 331 | prev_analyses_str = "\n".join([f"Previous analysis {i+1}: {analysis}" 332 | for i, analysis in enumerate(prev_analyses)]) 333 | 334 | if self.use_openai and self.client: 335 | system_prompt = """You are a system monitoring assistant. Your role is to: 336 | 1. Analyze current system metrics and identify the most significant insight or issue 337 | 2. Avoid repeating the same insights from the previous two analyses 338 | 3. Focus on trends and changes rather than static states 339 | 4. Provide one short, specific statement about the most important finding 340 | 5. If no significant changes or issues are found, note system stability""" 341 | 342 | user_prompt = f"""Previous analyses: 343 | {prev_analyses_str} 344 | 345 | Current system data: 346 | {metrics_str} 347 | 348 | Generate ONE short statement (max 15 words) about the most significant NEW insight or change.""" 349 | 350 | response = self.client.chat.completions.create( 351 | model="gpt-4o-mini-2024-07-18", 352 | messages=[ 353 | {"role": "system", "content": system_prompt}, 354 | {"role": "user", "content": user_prompt} 355 | ], 356 | max_tokens=50, 357 | temperature=0.3 358 | ) 359 | analysis = response.choices[0].message.content 360 | else: 361 | # Try to use Ollama, fall back to local analysis if not available 362 | try: 363 | import ollama 364 | ollama_prompt = f"""Previous analyses: 365 | {prev_analyses_str} 366 | 367 | Current system data: 368 | {metrics_str} 369 | You are a system monitoring assistant. Your role is to: 370 | 1. Analyze current system metrics and identify the most significant insight or issue 371 | 2. Do not mention the same insights from the previous analysis 372 | 3. Focus on trends and changes in curren stystem data rather than static states. Disk usage is not important. 373 | 4. Provide one very short, specific statement about the most important finding""" 374 | 375 | response = ollama.chat( 376 | model="llama3.2", 377 | messages=[{ 378 | "role": "user", 379 | "content": ollama_prompt 380 | }] 381 | ) 382 | analysis = response['message']['content'].strip() 383 | except ImportError: 384 | logging.warning("Ollama package not installed. Using local analysis.") 385 | analysis = self._generate_local_analysis(metrics) 386 | except Exception as e: 387 | logging.error(f"Ollama error: {str(e)}") 388 | analysis = self._generate_local_analysis(metrics) 389 | 390 | # Clean up the analysis text 391 | analysis = analysis.replace('\n', ' ').strip() 392 | 393 | # Store analysis with timestamp 394 | self.analyses.append({ 395 | 'timestamp': datetime.now(), 396 | 'analysis': analysis, 397 | 'estimated_lines': len(analysis) // 50 + 1 398 | }) 399 | 400 | return analysis 401 | 402 | except Exception as e: 403 | error_msg = f"Analysis Error: {str(e)}" 404 | logging.error(error_msg) 405 | self.analyses.append({ 406 | 'timestamp': datetime.now(), 407 | 'analysis': error_msg 408 | }) 409 | return error_msg 410 | 411 | # Clean up the analysis text 412 | analysis = analysis.replace('\n', ' ').strip() 413 | 414 | # Store analysis with timestamp 415 | self.analyses.append({ 416 | 'timestamp': datetime.now(), 417 | 'analysis': analysis, 418 | 'estimated_lines': len(analysis) // 50 + 1 419 | }) 420 | 421 | return analysis 422 | 423 | except Exception as e: 424 | error_msg = f"Analysis Error: {str(e)}" 425 | logging.error(error_msg) 426 | self.analyses.append({ 427 | 'timestamp': datetime.now(), 428 | 'analysis': error_msg 429 | }) 430 | return error_msg 431 | 432 | def _generate_local_analysis(self, metrics: Dict) -> str: 433 | """Generate a simple analysis without using an LLM""" 434 | try: 435 | cpu_usage = metrics.get('cpu', {}).get('Average Usage', 0) 436 | memory_usage = metrics.get('memory', {}).get('Percentage', 0) 437 | process_count = len(metrics.get('processes', [])) 438 | 439 | if cpu_usage > 80: 440 | return f"High CPU usage detected: {cpu_usage:.1f}%" 441 | elif memory_usage > 80: 442 | return f"High memory usage detected: {memory_usage:.1f}%" 443 | elif cpu_usage > 50 and memory_usage > 50: 444 | return f"Moderate system load: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%" 445 | else: 446 | return f"System running normally: {process_count} active processes" 447 | 448 | except Exception as e: 449 | logging.error(f"Error in local analysis: {str(e)}") 450 | return "Unable to generate analysis" 451 | 452 | def _generate_local_analysis(self, metrics: Dict) -> str: 453 | """Generate a simple analysis without using an LLM""" 454 | try: 455 | cpu_usage = metrics['cpu_usage'] 456 | memory_usage = metrics['memory_usage'] 457 | process_count = metrics['process_count'] 458 | 459 | if cpu_usage > 80: 460 | return f"High CPU usage detected: {cpu_usage:.1f}%" 461 | elif memory_usage > 80: 462 | return f"High memory usage detected: {memory_usage:.1f}%" 463 | elif cpu_usage > 50 and memory_usage > 50: 464 | return f"Moderate system load: CPU {cpu_usage:.1f}%, Memory {memory_usage:.1f}%" 465 | else: 466 | return f"System running normally: {process_count} active processes" 467 | 468 | except Exception as e: 469 | logging.error(f"Error in local analysis: {str(e)}") 470 | return "Unable to generate analysis" 471 | 472 | class UIComponent: 473 | def create_table(self, data: Dict, title: str, color: str = "white") -> Table: 474 | table = Table(show_header=False, box=box.ROUNDED, title=title, title_style=f"bold {color}") 475 | 476 | if isinstance(data, dict): 477 | table.add_column("Key", style=f"bold {color}") 478 | table.add_column("Value", style="white") 479 | for key, value in data.items(): 480 | table.add_row(str(key), str(value)) 481 | elif isinstance(data, list): 482 | if not data: # Handle empty list case 483 | table.add_column("Status", style=f"bold {color}") 484 | table.add_row("No data available") 485 | else: 486 | # Check if this is the processes table 487 | if self._is_process_table(data): 488 | return self._create_process_table(data, title, color) 489 | 490 | # Regular table handling 491 | headers = list(data[0].keys()) 492 | table.show_header = True 493 | table.columns = [] # Reset columns 494 | for header in headers: 495 | table.add_column(header, style=f"bold {color}") 496 | for item in data: 497 | table.add_row(*[str(item[header]) for header in headers]) 498 | 499 | return table 500 | 501 | def _is_process_table(self, data: List[Dict]) -> bool: 502 | """Check if the data structure matches process data""" 503 | if not data: 504 | return False 505 | expected_keys = {"PID", "Name", "CPU%", "Memory%"} 506 | return all(key in data[0] for key in expected_keys) 507 | 508 | def _create_process_table(self, data: List[Dict], title: str, color: str) -> Table: 509 | """Create a fixed-width table specifically for process data""" 510 | table = Table( 511 | title=title, 512 | title_style=f"bold {color}", 513 | box=box.ROUNDED, 514 | show_header=True, 515 | width=None, # Let the table take the full width of its container 516 | ) 517 | 518 | # Add columns with fixed widths 519 | table.add_column("PID", style=f"bold {color}", width=8, justify="right") 520 | table.add_column("Name", style=f"bold {color}", width=20, overflow="ellipsis") 521 | table.add_column("CPU%", style=f"bold {color}", width=8, justify="right") 522 | table.add_column("Memory%", style=f"bold {color}", width=10, justify="right") 523 | 524 | # Add rows with proper formatting 525 | for process in data: 526 | table.add_row( 527 | str(process['PID']), 528 | str(process['Name'])[:20], # Truncate long names 529 | f"{process['CPU%']:.1f}", 530 | f"{process['Memory%']:.1f}" 531 | ) 532 | 533 | return table 534 | 535 | class SystemMonitor: 536 | def __init__(self, update_frequency: int = 5, use_openai: bool = False): 537 | self.update_frequency = update_frequency 538 | self.metrics_collector = SystemMetricsCollector() 539 | self.alert_manager = AlertManager() 540 | self.analyzer = SystemAnalyzer(use_openai) 541 | self.ui = UIComponent() 542 | 543 | def generate_layout(self, current_metrics: Dict, alerts: List[Dict], analysis: str) -> Layout: 544 | # Ensure all required keys exist with default values 545 | current_metrics = { 546 | 'cpu': current_metrics.get('cpu', { 547 | "Physical Cores": "N/A", 548 | "Total Cores": "N/A", 549 | "Current Frequency": "N/A", 550 | "Per Core Usage": [], 551 | "Average Usage": 0.0 552 | }), 553 | 'memory': current_metrics.get('memory', { 554 | "Total": "N/A", 555 | "Available": "N/A", 556 | "Used": "N/A", 557 | "Percentage": 0, 558 | "Swap Used": "N/A", 559 | "Swap Percentage": 0 560 | }), 561 | 'network': current_metrics.get('network', { 562 | "Bytes Sent": "0MB", 563 | "Bytes Received": "0MB", 564 | "Packets Sent": 0, 565 | "Packets Received": 0, 566 | "Error In": 0, 567 | "Error Out": 0 568 | }), 569 | 'disk': current_metrics.get('disk', []), 570 | 'processes': current_metrics.get('processes', []) 571 | } 572 | 573 | # Initialize main layout 574 | layout = Layout() 575 | 576 | # Main layout structure 577 | layout.split_column( 578 | Layout(name="header", size=3), 579 | Layout(name="body", ratio=8), 580 | Layout(name="footer", size=3) 581 | ) 582 | 583 | # Body layout 584 | layout["body"].split_row( 585 | Layout(name="metrics", ratio=2), 586 | Layout(name="sidebar", ratio=1) 587 | ) 588 | 589 | # Metrics layout 590 | layout["metrics"].split_column( 591 | Layout(name="system", ratio=2), 592 | Layout(name="processes", ratio=1) 593 | ) 594 | 595 | # System metrics layout 596 | layout["system"].split_row( 597 | Layout(name="performance", ratio=1), 598 | Layout(name="resources", ratio=1) 599 | ) 600 | 601 | # Header with system information 602 | layout["header"].update( 603 | Panel( 604 | self.ui.create_table( 605 | self.metrics_collector.system_info, 606 | "System Information", 607 | "cyan" 608 | ), 609 | box=box.ROUNDED 610 | ) 611 | ) 612 | 613 | # Performance metrics (CPU & Network) 614 | perf_layout = Layout() 615 | perf_layout.split_column( 616 | Layout(name="cpu"), 617 | Layout(name="network") 618 | ) 619 | perf_layout["cpu"].update(Panel( 620 | self.ui.create_table(current_metrics['cpu'], "CPU", "green"), 621 | box=box.ROUNDED 622 | )) 623 | perf_layout["network"].update(Panel( 624 | self.ui.create_table(current_metrics['network'], "Network", "blue"), 625 | box=box.ROUNDED 626 | )) 627 | layout["metrics"]["system"]["performance"].update(perf_layout) 628 | 629 | # Resource metrics (Memory & Disk) 630 | res_layout = Layout() 631 | res_layout.split_column( 632 | Layout(name="memory"), 633 | Layout(name="disk") 634 | ) 635 | res_layout["memory"].update(Panel( 636 | self.ui.create_table(current_metrics['memory'], "Memory", "magenta"), 637 | box=box.ROUNDED 638 | )) 639 | res_layout["disk"].update(Panel( 640 | self.ui.create_table(current_metrics['disk'], "Disk", "yellow"), 641 | box=box.ROUNDED 642 | )) 643 | layout["metrics"]["system"]["resources"].update(res_layout) 644 | 645 | # Processes 646 | layout["metrics"]["processes"].update(Panel( 647 | self.ui.create_table(current_metrics['processes'], "Top Processes", "red"), 648 | box=box.ROUNDED 649 | )) 650 | 651 | # Sidebar (Alerts & Analysis) 652 | sidebar = Layout() 653 | sidebar.split_column( 654 | Layout(name="alerts", size=10), 655 | Layout(name="analysis", ratio=1) # Give analysis more space 656 | ) 657 | 658 | # Format alerts 659 | alert_text = "\n".join([ 660 | f"[{a['level']}] {a['timestamp'].strftime('%H:%M:%S')} - {a['message']}" 661 | for a in list(self.alert_manager.alerts)[-5:] # Show last 5 alerts 662 | ]) if alerts else "No active alerts" 663 | 664 | sidebar["alerts"].update(Panel( 665 | Text(alert_text, style="bold red"), 666 | title="Alerts", 667 | box=box.ROUNDED 668 | )) 669 | 670 | # Get console dimensions for dynamic sizing 671 | console = Console() 672 | _, console_height = console.size 673 | # Add extra padding to panel height 674 | analysis_panel_height = min(console_height - 13, 22) # Adjusted from -15 to -13 and max height from 20 to 22 675 | 676 | # Get messages that will fit 677 | visible_messages = self.analyzer.get_visible_messages(analysis_panel_height) 678 | 679 | 680 | text_elements = [] 681 | for item in visible_messages: 682 | timestamp = Text(f"[{item['timestamp'].strftime('%H:%M:%S')}] ", style="bold cyan") 683 | message = Text(item['analysis'], style="yellow", overflow="fold") 684 | combined = Text.assemble(timestamp, message) 685 | text_elements.append(combined) 686 | text_elements.append(Text("")) # Add an empty line between messages 687 | 688 | analysis_text = Group(*text_elements) if text_elements else Text("No analysis available") 689 | 690 | sidebar["analysis"].update(Panel( 691 | analysis_text, 692 | title="LLM Analysis", 693 | box=box.ROUNDED, 694 | height=analysis_panel_height + 2 # Add padding to panel height 695 | )) 696 | 697 | layout["body"]["sidebar"].update(sidebar) 698 | 699 | 700 | # Footer 701 | layout["footer"].update(Panel( 702 | Text(f"Last updated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | " 703 | f"Refresh: {self.update_frequency}s", 704 | style="bold white"), 705 | box=box.ROUNDED 706 | )) 707 | 708 | return layout 709 | 710 | def run(self): 711 | analysis_text = "Starting analysis..." 712 | logging.info("Starting system monitor") 713 | 714 | # Create initial metrics structure with empty values 715 | initial_metrics = { 716 | 'cpu': { 717 | "Physical Cores": 0, 718 | "Total Cores": 0, 719 | "Current Frequency": "N/A", 720 | "Per Core Usage": [], 721 | "Average Usage": 0.0 722 | }, 723 | 'memory': { 724 | "Total": "0GB", 725 | "Available": "0GB", 726 | "Used": "0GB", 727 | "Percentage": 0, 728 | "Swap Used": "0GB", 729 | "Swap Percentage": 0 730 | }, 731 | 'disk': [{ 732 | "Device": "N/A", 733 | "Mountpoint": "N/A", 734 | "Total": "0GB", 735 | "Used": "0GB", 736 | "Free": "0GB", 737 | "Percentage": 0 738 | }], 739 | 'network': { 740 | "Bytes Sent": "0MB", 741 | "Bytes Received": "0MB", 742 | "Packets Sent": 0, 743 | "Packets Received": 0, 744 | "Error In": 0, 745 | "Error Out": 0 746 | }, 747 | 'processes': [] 748 | } 749 | 750 | try: 751 | with Live( 752 | self.generate_layout(initial_metrics, [], analysis_text), 753 | refresh_per_second=4, 754 | screen=True 755 | ) as live: 756 | while True: 757 | try: 758 | # Collect metrics 759 | logging.debug("Collecting metrics...") 760 | current_metrics = self.metrics_collector.collect() 761 | logging.debug(f"Current metrics: {current_metrics}") 762 | 763 | # Check for alerts 764 | logging.debug("Checking alerts...") 765 | alerts = self.alert_manager.check_metrics(current_metrics) 766 | logging.debug(f"Current alerts: {alerts}") 767 | 768 | # Generate analysis 769 | logging.debug("Generating analysis...") 770 | analysis = self.analyzer.analyze( 771 | current_metrics, 772 | self.metrics_collector.history 773 | ) 774 | 775 | # Update the live display 776 | live.update( 777 | self.generate_layout( 778 | current_metrics, 779 | alerts, 780 | analysis 781 | ) 782 | ) 783 | 784 | # Wait for next update 785 | time.sleep(self.update_frequency) 786 | 787 | except KeyboardInterrupt: 788 | logging.info("Keyboard interrupt received") 789 | console.print("[bold red]Monitoring stopped.[/bold red]") 790 | break 791 | except Exception as e: 792 | logging.error(f"Error in main loop: {str(e)}", exc_info=True) 793 | console.print(f"[bold red]Error: {str(e)}[/bold red]") 794 | time.sleep(1) # Prevent rapid error loops 795 | except Exception as e: 796 | logging.error(f"Fatal error in run method: {str(e)}", exc_info=True) 797 | raise 798 | 799 | def main(): 800 | parser = argparse.ArgumentParser(description="Enhanced System Monitor") 801 | parser.add_argument( 802 | "--update-frequency", 803 | type=int, 804 | default=5, 805 | help="Update frequency in seconds (default: 5)" 806 | ) 807 | parser.add_argument( 808 | "--use-openai", 809 | action="store_true", 810 | help="Use OpenAI instead of local model" 811 | ) 812 | parser.add_argument( 813 | "--history-length", 814 | type=int, 815 | default=60, 816 | help="Number of historical data points to keep (default: 60)" 817 | ) 818 | args = parser.parse_args() 819 | 820 | # Initialize and run the monitor 821 | monitor = SystemMonitor( 822 | update_frequency=args.update_frequency, 823 | use_openai=args.use_openai 824 | ) 825 | 826 | try: 827 | monitor.run() 828 | except Exception as e: 829 | console.print(f"[bold red]Fatal error: {str(e)}[/bold red]") 830 | 831 | if __name__ == "__main__": 832 | main() -------------------------------------------------------------------------------- /manifest.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # Build Tools 2 | setuptools>=65.0.0 3 | wheel>=0.40.0 4 | build>=1.0.0 5 | 6 | # Terminal UI 7 | rich>=13.7.0 8 | 9 | # System Monitoring 10 | psutil>=5.9.0 11 | GPUtil>=1.4.0 12 | 13 | # LLM Integration 14 | openai>=1.12.0 15 | ollama>=0.1.6 16 | 17 | # Core Dependencies 18 | python-dateutil>=2.8.2 19 | typing-extensions>=4.9.0 20 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arinbjornk/llmtop/e94d81acf640dbe80026bd764e04a8648d0e79c1/screenshot.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | with open("README.md", "r", encoding="utf-8") as fh: 4 | long_description = fh.read() 5 | 6 | setup( 7 | name="llmtop", 8 | version="0.1.0", 9 | author="Arinbjörn Kolbeinsson", 10 | description="LLM-powered system monitoring with real-time performance insights", 11 | long_description=long_description, 12 | long_description_content_type="text/markdown", 13 | packages=find_packages(), 14 | classifiers=[ 15 | "Development Status :: 4 - Beta", 16 | "Programming Language :: Python :: 3", 17 | "Programming Language :: Python :: 3.8", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Operating System :: OS Independent", 22 | "Topic :: System :: Monitoring", 23 | ], 24 | python_requires=">=3.8", 25 | install_requires=[ 26 | "rich>=13.7.0", 27 | "psutil>=5.9.0", 28 | "GPUtil>=1.4.0", 29 | "openai>=1.12.0", 30 | "ollama>=0.1.6", 31 | "python-dateutil>=2.8.2", 32 | "typing-extensions>=4.9.0" 33 | ], 34 | entry_points={ 35 | 'console_scripts': [ 36 | 'llmtop=llmtop.llmtop:main', 37 | ], 38 | }, 39 | ) --------------------------------------------------------------------------------