Coverage for openhcs/textual_tui/widgets/reactive_log_monitor.py: 0.0%
228 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 Reactive Log Monitor Widget
4A clean, low-entropy reactive log monitoring system that provides real-time
5log viewing with a dropdown selector interface.
7Mathematical properties:
8- Reactive: UI updates are pure functions of file system events
9- Monotonic: Logs only get added during execution
10- Deterministic: Same file system state always produces same UI
11"""
13import logging
14import os
15import re
16from pathlib import Path
17from typing import Set, Dict, Optional, Callable, List
18from dataclasses import dataclass
20from textual.app import ComposeResult
21from textual.containers import Container
22from textual.widgets import Static, Select
23from textual.widget import Widget
24from textual.reactive import reactive
25from textual.containers import Horizontal, Vertical
27# Import file system watching
28from watchdog.observers import Observer
29from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent
31# Import core log utilities
32from openhcs.core.log_utils import LogFileInfo, discover_logs, classify_log_file, is_relevant_log_file
34# Toolong components are imported in ToolongWidget
36logger = logging.getLogger(__name__)
42class ReactiveLogFileHandler(FileSystemEventHandler):
43 """File system event handler for reactive log monitoring."""
45 def __init__(self, monitor: 'ReactiveLogMonitor'):
46 self.monitor = monitor
48 def on_created(self, event):
49 """Handle file creation events."""
50 if not event.is_directory and event.src_path.endswith('.log'):
51 file_path = Path(event.src_path)
52 if is_relevant_log_file(file_path, self.monitor.base_log_path):
53 logger.debug(f"Log file created: {file_path}")
54 self.monitor._handle_log_file_created(file_path)
56 def on_modified(self, event):
57 """Handle file modification events."""
58 if not event.is_directory and event.src_path.endswith('.log'):
59 file_path = Path(event.src_path)
60 if is_relevant_log_file(file_path, self.monitor.base_log_path):
61 self.monitor._handle_log_file_modified(file_path)
64class ReactiveLogMonitor(Widget):
65 """
66 Reactive log monitor with dropdown selector.
68 Provides real-time monitoring of OpenHCS log files with a clean dropdown
69 interface for selecting which log to view.
70 """
72 # Reactive properties
73 active_logs: reactive[Set[Path]] = reactive(set())
74 base_log_path: reactive[str] = reactive("")
76 def __init__(
77 self,
78 base_log_path: str = "",
79 auto_start: bool = True,
80 include_tui_log: bool = True,
81 **kwargs
82 ):
83 """
84 Initialize ReactiveLogMonitor.
86 Args:
87 base_log_path: Base path for subprocess log files
88 auto_start: Whether to automatically start monitoring when mounted
89 include_tui_log: Whether to include the current TUI process log
90 """
91 super().__init__(**kwargs)
92 self.base_log_path = base_log_path
93 self.auto_start = auto_start
94 self.include_tui_log = include_tui_log
96 # Internal state
97 self._log_info_cache: Dict[Path, LogFileInfo] = {}
98 # ToolongWidget will manage its own watcher
100 # File system watcher (will be set up in on_mount)
101 self._file_observer = None
103 def compose(self) -> ComposeResult:
104 """Compose the reactive log monitor layout with dropdown selector."""
105 # Simple layout like other widgets - no complex containers
106 yield Static("Log File:")
107 yield Select(
108 options=[("Loading...", "loading")],
109 value="loading",
110 id="log_selector",
111 compact=True
112 )
113 yield Container(id="log_view_container")
115 def on_mount(self) -> None:
116 """Set up log monitoring when widget is mounted."""
117 logger.debug(f"ReactiveLogMonitor.on_mount() called, auto_start={self.auto_start}")
118 if self.auto_start:
119 logger.debug("Starting monitoring from on_mount")
120 self.start_monitoring()
121 else:
122 logger.warning("Auto-start disabled, not starting monitoring")
124 def on_unmount(self) -> None:
125 """Clean up when widget is unmounted."""
126 logger.debug("ReactiveLogMonitor unmounting, cleaning up watchers...")
127 self.stop_monitoring()
129 def start_monitoring(self, base_log_path: str = None) -> None:
130 """
131 Start monitoring for log files.
133 Args:
134 base_log_path: Optional new base path to monitor
135 """
136 logger.debug(f"start_monitoring() called with base_log_path='{base_log_path}'")
138 if base_log_path:
139 self.base_log_path = base_log_path
141 logger.debug(f"Current state: base_log_path='{self.base_log_path}', include_tui_log={self.include_tui_log}")
143 # We can monitor even without base_log_path if include_tui_log is True
144 if not self.base_log_path and not self.include_tui_log:
145 raise RuntimeError("Cannot start log monitoring: no base log path and TUI log disabled")
147 if self.base_log_path:
148 logger.debug(f"Starting reactive log monitoring for subprocess: {self.base_log_path}")
149 else:
150 logger.debug("Starting reactive log monitoring for TUI log only")
152 # Discover existing logs - THIS SHOULD CRASH IF NO TUI LOG FOUND
153 logger.debug("About to discover existing logs...")
154 self._discover_existing_logs()
155 logger.debug("Finished discovering existing logs")
157 # Start file system watcher (only if we have subprocess logs to watch)
158 if self.base_log_path:
159 self._start_file_watcher()
161 logger.debug("Log monitoring started successfully")
163 def stop_monitoring(self) -> None:
164 """Stop all log monitoring with proper thread cleanup."""
165 logger.debug("Stopping reactive log monitoring")
167 try:
168 # Stop file system watcher first
169 self._stop_file_watcher()
170 except Exception as e:
171 logger.error(f"Error stopping file watcher: {e}")
173 # ToolongWidget now manages its own watcher, so no need to stop it here
174 logger.debug("ReactiveLogMonitor stopped (ToolongWidget manages its own watcher)")
176 # Clear state
177 self.active_logs = set()
178 self._log_info_cache.clear()
180 logger.debug("Reactive log monitoring stopped")
190 def _discover_existing_logs(self) -> None:
191 """Discover and add existing log files."""
192 discovered = discover_logs(self.base_log_path, self.include_tui_log)
193 for log_path in discovered:
194 self._add_log_file(log_path)
196 def _add_log_file(self, log_path: Path) -> None:
197 """Add a log file to monitoring (internal method)."""
198 if log_path in self.active_logs:
199 return # Already monitoring
201 # Classify the log file
202 log_info = classify_log_file(log_path, self.base_log_path, self.include_tui_log)
203 self._log_info_cache[log_path] = log_info
205 # Add to active logs (triggers reactive update)
206 new_logs = set(self.active_logs)
207 new_logs.add(log_path)
208 self.active_logs = new_logs
210 logger.debug(f"Added log file to monitoring: {log_info.display_name} ({log_path})")
212 def watch_active_logs(self, logs: Set[Path]) -> None:
213 """Reactive: Update dropdown when active logs change."""
214 logger.debug(f"Active logs changed: {len(logs)} logs")
215 # Always try to update - the _update_log_selector method has its own safety checks
216 logger.debug("Updating log selector")
217 self._update_log_selector()
219 def _update_log_selector(self) -> None:
220 """Update dropdown selector with available logs."""
221 logger.debug(f"_update_log_selector called, is_mounted={self.is_mounted}")
223 try:
224 # Check if the selector exists (might not be ready yet or removed during unmount)
225 try:
226 log_selector = self.query_one("#log_selector", Select)
227 logger.debug(f"Found log selector widget: {log_selector}")
228 except Exception as e:
229 logger.debug(f"Log selector not found (widget not ready or unmounting?): {e}")
230 return
231 logger.debug(f"Found log selector widget: {log_selector}")
233 # Sort logs: TUI first, then main subprocess, then workers by well ID
234 sorted_logs = self._sort_logs_for_display(self.active_logs)
235 logger.debug(f"Active logs: {[str(p) for p in self.active_logs]}")
236 logger.debug(f"Sorted logs: {[str(p) for p in sorted_logs]}")
238 # Build dropdown options
239 options = []
240 for log_path in sorted_logs:
241 log_info = self._log_info_cache.get(log_path)
242 logger.debug(f"Log path {log_path} -> log_info: {log_info}")
243 if log_info:
244 options.append((log_info.display_name, str(log_path)))
245 else:
246 logger.warning(f"No log_info found for {log_path} in cache: {list(self._log_info_cache.keys())}")
248 logger.debug(f"Built options: {options}")
250 if not options:
251 logger.error("CRITICAL: No options built! This should never happen with TUI log.")
252 options = [("No logs available", "none")]
254 # Update selector options
255 log_selector.set_options(options)
256 logger.debug(f"Set options on selector, current value: {log_selector.value}")
258 # Force refresh the selector
259 log_selector.refresh()
260 logger.debug("Forced selector refresh")
262 # Auto-select first option (TUI log) if nothing selected
263 if options and (log_selector.value == "loading" or log_selector.value not in [opt[1] for opt in options]):
264 logger.debug(f"Auto-selecting first option: {options[0]}")
265 log_selector.value = options[0][1]
266 logger.debug(f"About to show log file: {options[0][1]}")
267 self._show_log_file(Path(options[0][1]))
268 logger.debug(f"Finished showing log file: {options[0][1]}")
269 else:
270 logger.debug("Not auto-selecting, current selection is valid")
272 except Exception as e:
273 # FAIL LOUD - UI updates should not silently fail
274 raise RuntimeError(f"Failed to update log selector: {e}") from e
276 def on_select_changed(self, event: Select.Changed) -> None:
277 """Handle log file selection change."""
278 logger.debug(f"Select changed: value={event.value}, type={type(event.value)}")
280 # Handle NoSelection/BLANK - this should not happen if we always have TUI log
281 if event.value == Select.BLANK or event.value is None:
282 logger.error("CRITICAL: Select widget has no selection! This should never happen.")
283 return
285 # Handle valid selections
286 if event.value and event.value != "loading" and event.value != "none":
287 logger.debug(f"Showing log file: {event.value}")
288 self._show_log_file(Path(event.value))
289 else:
290 logger.warning(f"Ignoring invalid selection: {event.value}")
292 def _show_log_file(self, log_path: Path) -> None:
293 """Show the selected log file using proper Toolong structure."""
294 logger.debug(f"_show_log_file called with: {log_path}")
295 try:
296 log_container = self.query_one("#log_view_container", Container)
297 logger.debug(f"Found log container: {log_container}")
299 # Clear existing content
300 existing_widgets = log_container.query("*")
301 logger.debug(f"Clearing {len(existing_widgets)} existing widgets")
302 existing_widgets.remove()
304 # Create complete ToolongWidget - this encapsulates all Toolong functionality
305 from openhcs.textual_tui.widgets.toolong_widget import ToolongWidget
307 logger.debug(f"Creating ToolongWidget for: {log_path}")
309 # Create ToolongWidget for the selected file
310 toolong_widget = ToolongWidget.from_single_file(
311 str(log_path),
312 can_tail=True
313 )
314 logger.debug(f"Created ToolongWidget: {toolong_widget}")
316 # Mount the complete ToolongWidget
317 logger.debug("Mounting ToolongWidget to container")
318 log_container.mount(toolong_widget)
320 logger.debug(f"Successfully showing log file with ToolongWidget: {log_path}")
322 except Exception as e:
323 logger.error(f"Failed to show log file {log_path}: {e}", exc_info=True)
324 # Show error message
325 try:
326 log_container = self.query_one("#log_view_container", Container)
327 log_container.query("*").remove()
328 log_container.mount(Static(f"Error loading log: {e}", classes="error-message"))
329 logger.debug("Mounted error message")
330 except Exception as e2:
331 logger.error(f"Failed to show error message: {e2}")
333 def _sort_logs_for_display(self, logs: Set[Path]) -> List[Path]:
334 """Sort logs for display: TUI first, then main subprocess, then workers by well ID."""
335 tui_logs = []
336 main_logs = []
337 worker_logs = []
338 unknown_logs = []
340 for log_path in logs:
341 log_info = self._log_info_cache.get(log_path)
342 if not log_info:
343 unknown_logs.append(log_path)
344 continue
346 if log_info.log_type == "tui":
347 tui_logs.append(log_path)
348 elif log_info.log_type == "main":
349 main_logs.append(log_path)
350 elif log_info.log_type == "worker":
351 worker_logs.append((log_info.well_id or "", log_path))
352 else:
353 unknown_logs.append(log_path)
355 # Sort workers by well ID
356 worker_logs.sort(key=lambda x: x[0])
358 return tui_logs + main_logs + [log_path for _, log_path in worker_logs] + unknown_logs
360 def _start_file_watcher(self) -> None:
361 """Start file system watcher for new log files."""
362 if not self.base_log_path:
363 logger.warning("Cannot start file watcher: no base log path")
364 return
366 base_path = Path(self.base_log_path)
367 watch_directory = base_path.parent
369 if not watch_directory.exists():
370 logger.warning(f"Watch directory does not exist: {watch_directory}")
371 return
373 try:
374 # Stop any existing watcher
375 self._stop_file_watcher()
377 # Create new watcher as daemon thread
378 self._file_observer = Observer()
379 self._file_observer.daemon = True # Don't block app shutdown
381 # Create event handler
382 event_handler = ReactiveLogFileHandler(self)
384 # Schedule watching
385 self._file_observer.schedule(
386 event_handler,
387 str(watch_directory),
388 recursive=False # Only watch the log directory, not subdirectories
389 )
391 # Start watching
392 self._file_observer.start()
394 logger.debug(f"Started file system watcher for: {watch_directory}")
396 except Exception as e:
397 logger.error(f"Failed to start file system watcher: {e}")
398 self._file_observer = None
400 def _stop_file_watcher(self) -> None:
401 """Stop file system watcher with aggressive thread cleanup."""
402 if self._file_observer:
403 try:
404 logger.debug("Stopping file system observer...")
405 self._file_observer.stop()
407 # Wait for observer thread to finish with timeout
408 logger.debug("Waiting for file system observer thread to join...")
409 self._file_observer.join(timeout=0.5) # Shorter timeout
411 if self._file_observer.is_alive():
412 logger.warning("File system observer thread did not stop cleanly, forcing cleanup")
413 # Force cleanup by setting daemon flag
414 try:
415 for thread in self._file_observer._threads:
416 if hasattr(thread, 'daemon'):
417 thread.daemon = True
418 except:
419 pass
420 else:
421 logger.debug("File system observer stopped cleanly")
423 except Exception as e:
424 logger.error(f"Error stopping file system watcher: {e}")
425 finally:
426 self._file_observer = None
428 def _handle_log_file_created(self, file_path: Path) -> None:
429 """Handle creation of a new log file."""
430 self._add_log_file(file_path)
432 def _handle_log_file_modified(self, file_path: Path) -> None:
433 """Handle modification of an existing log file."""
434 # Toolong LogView handles live tailing automatically
435 pass