Coverage for openhcs/textual_tui/widgets/system_monitor.py: 0.0%
152 statements
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
« prev ^ index » next coverage.py v7.10.3, created at 2025-08-14 05:57 +0000
1from textual.app import App, ComposeResult
2from textual.widgets import Static, Header, Footer
3from textual.containers import Container, Horizontal, Vertical, Grid
4from typing import Optional
5from textual import events, work
6from textual.reactive import reactive
7from textual.worker import get_current_worker
8import asyncio
9import psutil
10from collections import deque
11import time
13# Use textual-plotext for proper Textual integration
14try:
15 from textual_plotext import PlotextPlot
16 PLOTEXT_AVAILABLE = True
17except ImportError:
18 PLOTEXT_AVAILABLE = False
20# Import the SystemMonitor from services
21from openhcs.textual_tui.services.system_monitor import SystemMonitor
23try:
24 import GPUtil
25 GPU_AVAILABLE = True
26except ImportError:
27 GPU_AVAILABLE = False
29class SystemMonitorTextual(Container):
30 """A Textual widget for system monitoring using proper Textual components"""
32 DEFAULT_CSS = """
33 SystemMonitorTextual {
34 background: $background;
35 color: $text;
36 }
38 #top_row {
39 height: 9;
40 background: $surface;
41 }
43 #header {
44 width: 1fr;
45 text-align: left;
46 color: $accent;
47 padding: 1;
48 }
50 #system_info {
51 width: 1fr;
52 color: $text-muted;
53 text-align: right;
54 padding: 1;
55 content-align: center middle;
56 }
58 #plots_grid {
59 height: 1fr;
60 grid-size: 2 2;
61 grid-gutter: 1;
62 }
64 #fallback_display {
65 height: 1fr;
66 text-align: center;
67 color: $primary;
68 }
71 """
73 update_interval = reactive(1.0)
74 is_monitoring = reactive(True)
76 def __init__(self):
77 super().__init__()
78 self.monitor = SystemMonitor()
79 self.monitor_worker = None
81 def compose(self) -> ComposeResult:
82 """Compose the system monitor layout"""
83 # Top row with title on left and system info on right
84 with Horizontal(id="top_row"):
85 yield Static(self._get_ascii_header(), id="header")
86 yield Static("", id="system_info")
88 if PLOTEXT_AVAILABLE:
89 # Use Grid layout for plots
90 with Grid(id="plots_grid"):
91 yield PlotextPlot(id="cpu_plot")
92 yield PlotextPlot(id="ram_plot")
93 yield PlotextPlot(id="gpu_plot")
94 yield PlotextPlot(id="vram_plot")
95 else:
96 # Fallback to text-based display
97 yield Static("", id="fallback_display")
99 async def on_mount(self) -> None:
100 """Start the monitoring when widget is mounted"""
101 # Start the worker using the @work decorated method
102 self.monitor_worker = self._monitor_worker()
103 self.is_monitoring = True
105 def on_unmount(self) -> None:
106 """Stop monitoring and cleanup background thread when widget is unmounted"""
107 self._stop_monitoring()
109 def _start_monitoring(self) -> None:
110 """Start the monitoring Textual worker"""
111 # Stop any existing monitoring
112 self._stop_monitoring()
114 # Start Textual worker using @work decorated method
115 self.monitor_worker = self._monitor_worker()
116 self.is_monitoring = True
118 def _stop_monitoring(self) -> None:
119 """Stop the monitoring worker"""
120 if self.monitor_worker and not self.monitor_worker.is_finished:
121 self.monitor_worker.cancel()
122 self.monitor_worker = None
123 self.is_monitoring = False
125 def toggle_monitoring(self) -> None:
126 """Toggle monitoring on/off - called from menu"""
127 if self.is_monitoring:
128 self._stop_monitoring()
129 else:
130 self._start_monitoring()
132 async def manual_refresh(self) -> None:
133 """Manual refresh - called from menu"""
134 await self.update_display()
136 @work(thread=True, exclusive=True)
137 def _monitor_worker(self) -> None:
138 """Textual worker that collects system metrics and updates UI."""
139 worker = get_current_worker()
140 import logging
141 logger = logging.getLogger(__name__)
142 logger.info("SystemMonitor worker started")
144 while not worker.is_cancelled:
145 try:
146 # Collect system metrics
147 self.monitor.update_metrics()
149 # Update UI via call_from_thread every cycle (1 second updates)
150 if not worker.is_cancelled:
151 self.app.call_from_thread(self._update_ui_from_thread)
153 # Sleep for the update interval (default 1 second)
154 time.sleep(self.update_interval)
156 except Exception as e:
157 import logging
158 logger = logging.getLogger(__name__)
159 logger.debug(f"Error in system monitor worker: {e}")
160 time.sleep(2.0) # Longer sleep on error
162 def _update_ui_from_thread(self) -> None:
163 """Update UI components - called from background thread via call_from_thread."""
164 if PLOTEXT_AVAILABLE:
165 self._update_plots()
166 else:
167 self._update_fallback_display()
169 self._update_system_info()
171 def _get_ascii_header(self) -> str:
172 """Get the OpenHCS ASCII art header"""
173 return """
174╔══════════════════════════════════════════════════════════════╗
175║ ██████╗ ██████╗ ███████╗███╗ ██╗██╗ ██╗ ██████╗███████╗ ║
176║ ██╔═══██╗██╔══██╗██╔════╝████╗ ██║██║ ██║██╔════╝██╔════╝ ║
177║ ██║ ██║██████╔╝█████╗ ██╔██╗ ██║███████║██║ ███████╗ ║
178║ ██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██╔══██║██║ ╚════██║ ║
179║ ╚██████╔╝██║ ███████╗██║ ╚████║██║ ██║╚██████╗███████║ ║
180║ ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝╚═╝ ╚═╝ ╚═════╝╚══════╝ ║
181╚══════════════════════════════════════════════════════════════╝"""
183 async def update_display(self) -> None:
184 """Update the display with new system stats - for manual refresh only"""
185 # Update metrics using the SystemMonitor from services - wrap in executor to avoid blocking
186 import asyncio
187 await asyncio.get_event_loop().run_in_executor(None, self.monitor.update_metrics)
189 # Update UI directly (this is for manual refresh)
190 self._update_ui_from_thread()
192 def _update_plots(self) -> None:
193 """Update the plotext plots"""
194 try:
195 # Get current metrics
196 current_cpu = self.monitor.cpu_history[-1] if self.monitor.cpu_history else 0
197 current_ram = self.monitor.ram_history[-1] if self.monitor.ram_history else 0
198 current_gpu = self.monitor.gpu_history[-1] if self.monitor.gpu_history else 0
199 current_vram = self.monitor.vram_history[-1] if self.monitor.vram_history else 0
201 x_range = list(range(len(self.monitor.cpu_history)))
203 # Update CPU plot with Braille patterns for higher resolution
204 cpu_plot = self.query_one("#cpu_plot", PlotextPlot)
205 cpu_plot.plt.clear_data()
206 cpu_plot.plt.plot(x_range, list(self.monitor.cpu_history), marker="braille")
207 cpu_plot.plt.title(f"CPU Usage: {current_cpu:.1f}%")
208 cpu_plot.plt.ylim(0, 100)
209 cpu_plot.refresh() # Force visual refresh
211 # Update RAM plot with Braille patterns for higher resolution
212 ram_plot = self.query_one("#ram_plot", PlotextPlot)
213 ram_plot.plt.clear_data()
214 ram_plot.plt.plot(x_range, list(self.monitor.ram_history), marker="braille")
215 ram_plot.plt.title(f"RAM Usage: {current_ram:.1f}%")
216 ram_plot.plt.ylim(0, 100)
217 ram_plot.refresh() # Force visual refresh
219 # Update GPU plot with Braille patterns for higher resolution
220 gpu_plot = self.query_one("#gpu_plot", PlotextPlot)
221 gpu_plot.plt.clear_data()
222 if GPU_AVAILABLE and any(self.monitor.gpu_history):
223 gpu_plot.plt.plot(x_range, list(self.monitor.gpu_history), marker="braille")
224 gpu_plot.plt.title(f"GPU Usage: {current_gpu:.1f}%")
225 else:
226 gpu_plot.plt.title("GPU: Not Available")
227 gpu_plot.plt.ylim(0, 100)
228 gpu_plot.refresh() # Force visual refresh
230 # Update VRAM plot with Braille patterns for higher resolution
231 vram_plot = self.query_one("#vram_plot", PlotextPlot)
232 vram_plot.plt.clear_data()
233 if GPU_AVAILABLE and any(self.monitor.vram_history):
234 vram_plot.plt.plot(x_range, list(self.monitor.vram_history), marker="braille")
235 vram_plot.plt.title(f"VRAM Usage: {current_vram:.1f}%")
236 else:
237 vram_plot.plt.title("VRAM: Not Available")
238 vram_plot.plt.ylim(0, 100)
239 vram_plot.refresh() # Force visual refresh
241 except Exception as e:
242 # If plots fail, fall back to text display
243 self._update_fallback_display()
245 def _update_fallback_display(self) -> None:
246 """Update with text-based display if plots aren't available"""
247 try:
248 metrics = self.monitor.get_metrics_dict()
250 display_text = f"""
251┌─────────────────────────────────────────────────────────────────┐
252│ CPU: {self._create_bar(metrics.get('cpu_percent', 0))} {metrics.get('cpu_percent', 0):5.1f}%
253│ RAM: {self._create_bar(metrics.get('ram_percent', 0))} {metrics.get('ram_percent', 0):5.1f}% ({metrics.get('ram_used_gb', 0):.1f}/{metrics.get('ram_total_gb', 0):.1f}GB)
254│ GPU: {self._create_bar(metrics.get('gpu_percent', 0))} {metrics.get('gpu_percent', 0):5.1f}%
255│ VRAM: {self._create_bar(metrics.get('vram_percent', 0))} {metrics.get('vram_percent', 0):5.1f}%
256└─────────────────────────────────────────────────────────────────┘
257"""
259 fallback_widget = self.query_one("#fallback_display", Static)
260 fallback_widget.update(display_text)
261 except Exception:
262 pass
264 def _update_system_info(self) -> None:
265 """Update system information in top-right"""
266 try:
267 from datetime import datetime
268 # Now get_metrics_dict() just returns cached data - no blocking calls!
269 metrics = self.monitor.get_metrics_dict()
271 info_text = f"""
272═══════════════════════════════════════════════════════════════════════
273System Information | Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
274═══════════════════════════════════════════════════════════════════════
275CPU Cores: {metrics.get('cpu_cores', 'N/A')} | CPU Frequency: {metrics.get('cpu_freq_mhz', 0):.0f} MHz
276Total RAM: {metrics.get('ram_total_gb', 0):.1f} GB | Used RAM: {metrics.get('ram_used_gb', 0):.1f} GB"""
278 if GPU_AVAILABLE and 'gpu_name' in metrics:
279 info_text += f"\nGPU: {metrics.get('gpu_name', 'N/A')} | Temperature: {metrics.get('gpu_temp', 'N/A')}°C"
280 info_text += f"\nVRAM: {metrics.get('vram_used_mb', 0):.0f}/{metrics.get('vram_total_mb', 0):.0f} MB"
282 info_text += "\n═══════════════════════════════════════════════════════════════════════"
284 info_widget = self.query_one("#system_info", Static)
285 info_widget.update(info_text)
286 except Exception:
287 pass
289 def _create_bar(self, percent):
290 """Create a text-based progress bar"""
291 bar_length = 20
292 filled = int(bar_length * percent / 100)
293 bar = '█' * filled + '░' * (bar_length - filled)
294 return f"[{bar}]"