Coverage for openhcs/textual_tui/widgets/log_monitor.py: 0.0%
110 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
1"""
2OpenHCS Log Monitor Widget
4Integrates the modernized Toolong log viewing capabilities into the OpenHCS TUI
5for monitoring subprocess and worker process logs in real-time.
7This uses our Textual 3.x compatible fork of Toolong.
8"""
10import logging
11from pathlib import Path
12from typing import List, Optional
14from textual.app import ComposeResult
15from textual.containers import Container, Horizontal, Vertical
16from textual.widgets import Button, Label, Static
17from textual.widget import Widget
18from textual.reactive import reactive
20# Import modernized Toolong components (Textual 3.x compatible)
21from toolong.ui import UI
22from toolong.log_view import LogView
23from toolong.log_file import LogFile
24from toolong.watcher import get_watcher
26logger = logging.getLogger(__name__)
29class LogMonitorWidget(Widget):
30 """
31 Widget for monitoring OpenHCS subprocess and worker logs using Toolong components.
33 Features:
34 - Live tailing of multiple log files
35 - Syntax highlighting for log formats
36 - Search and filtering capabilities
37 - Automatic detection of new worker log files
38 """
40 # Reactive properties
41 log_files: reactive[List[str]] = reactive([], layout=True)
42 current_log: reactive[Optional[str]] = reactive(None)
44 def __init__(
45 self,
46 log_file_base: Optional[str] = None,
47 auto_detect_workers: bool = True,
48 **kwargs
49 ):
50 """
51 Initialize the log monitor.
53 Args:
54 log_file_base: Base path for log files (e.g., "openhcs_subprocess_plates_A_B_1234567890")
55 auto_detect_workers: Whether to automatically detect new worker log files
56 """
57 super().__init__(**kwargs)
58 self.log_file_base = log_file_base
59 self.auto_detect_workers = auto_detect_workers
60 self.log_views = {} # Map of log file path to LogView widget
61 self.watchers = {} # Map of log file path to file watcher
63 def compose(self) -> ComposeResult:
64 """Compose the log monitor layout."""
65 with Vertical():
66 # Header with controls
67 with Horizontal(classes="log-monitor-header"):
68 yield Label("📋 Log Monitor", classes="log-monitor-title")
69 yield Button("🔄 Refresh", id="refresh-logs", variant="primary")
70 yield Button("🔍 Search", id="search-logs", variant="default")
71 yield Button("⏸️ Pause", id="pause-tailing", variant="default")
73 # Log file tabs/selector
74 with Horizontal(classes="log-file-selector"):
75 yield Label("Active Logs:", classes="selector-label")
76 # Dynamic log file buttons will be added here
78 # Main log viewing area
79 with Container(classes="log-view-container"):
80 yield Static("Select a log file to view", classes="log-placeholder")
82 def on_mount(self) -> None:
83 """Set up the log monitor when mounted."""
84 if self.log_file_base:
85 self.discover_log_files()
86 if self.auto_detect_workers:
87 self.start_worker_detection()
89 def discover_log_files(self) -> None:
90 """Discover existing log files based on the base path."""
91 if not self.log_file_base:
92 return
94 base_path = Path(self.log_file_base)
95 log_dir = base_path.parent
96 base_name = base_path.name
98 # Find main subprocess log
99 main_log = f"{self.log_file_base}.log"
100 if Path(main_log).exists():
101 self.add_log_file(main_log, "Main Process")
103 # Find worker logs
104 if log_dir.exists():
105 for log_file in log_dir.glob(f"{base_name}_worker_*.log"):
106 worker_id = log_file.stem.split('_worker_')[-1]
107 self.add_log_file(str(log_file), f"Worker {worker_id}")
109 def add_log_file(self, log_path: str, display_name: str = None) -> None:
110 """Add a log file to the monitor."""
111 if log_path in self.log_views:
112 return # Already monitoring this file
114 try:
115 # Verify file exists
116 if not Path(log_path).exists():
117 logger.warning(f"Log file does not exist: {log_path}")
118 return
120 # Create a simple UI wrapper for this log file
121 # We'll use Toolong's UI components directly
122 self.log_views[log_path] = {
123 'path': log_path,
124 'display_name': display_name or Path(log_path).name
125 }
127 # Update the UI
128 self.update_log_selector()
130 logger.info(f"Added log file to monitor: {log_path}")
132 except Exception as e:
133 logger.error(f"Failed to add log file {log_path}: {e}")
135 def update_log_selector(self) -> None:
136 """Update the log file selector buttons."""
137 selector = self.query_one(".log-file-selector")
139 # Remove existing log buttons (keep the label)
140 for button in selector.query("Button.log-file-button"):
141 button.remove()
143 # Add buttons for each log file
144 for i, (log_path, log_info) in enumerate(self.log_views.items()):
145 button = Button(
146 log_info['display_name'],
147 id=f"log-{i}-{abs(hash(log_path)) % 10000}", # More unique ID
148 classes="log-file-button",
149 variant="default"
150 )
151 selector.mount(button)
153 def switch_to_log(self, log_path: str) -> None:
154 """Switch the main view to show a specific log file using Toolong UI."""
155 if log_path not in self.log_views:
156 return
158 try:
159 container = self.query_one(".log-view-container")
161 # Remove current content
162 container.query("*").remove()
164 # Create Toolong UI for this log file
165 toolong_ui = UI([log_path], merge=False)
167 # Mount the Toolong UI
168 container.mount(toolong_ui)
170 self.current_log = log_path
171 logger.info(f"Switched to log: {log_path}")
173 except Exception as e:
174 logger.error(f"Failed to switch to log {log_path}: {e}")
175 # Show error message in container
176 container = self.query_one(".log-view-container")
177 container.query("*").remove()
178 container.mount(Static(f"Error loading log: {e}", classes="log-error"))
180 def on_button_pressed(self, event: Button.Pressed) -> None:
181 """Handle button presses."""
182 button_id = event.button.id
184 if button_id == "refresh-logs":
185 self.discover_log_files()
186 elif button_id == "search-logs":
187 self.show_search_dialog()
188 elif button_id == "pause-tailing":
189 self.toggle_tailing()
190 elif button_id and button_id.startswith("log-"):
191 # Find the log file for this button
192 for i, (log_path, log_info) in enumerate(self.log_views.items()):
193 if f"log-{i}-{abs(hash(log_path)) % 10000}" == button_id:
194 self.switch_to_log(log_path)
195 break
197 def show_search_dialog(self) -> None:
198 """Show search dialog for the current log."""
199 # TODO: Implement search functionality using Toolong's FindDialog
200 logger.info("Search functionality not yet implemented")
202 def toggle_tailing(self) -> None:
203 """Toggle live tailing on/off."""
204 # TODO: Implement tailing toggle
205 logger.info("Tailing toggle not yet implemented")
207 def start_worker_detection(self) -> None:
208 """Start monitoring for new worker log files."""
209 # TODO: Implement automatic detection of new worker logs
210 logger.info("Worker detection not yet implemented")
212 def cleanup(self) -> None:
213 """Clean up watchers and resources."""
214 for watcher in self.watchers.values():
215 if hasattr(watcher, 'stop'):
216 watcher.stop()
217 self.watchers.clear()
218 self.log_views.clear()