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

1""" 

2OpenHCS Reactive Log Monitor Widget 

3 

4A clean, low-entropy reactive log monitoring system that provides real-time 

5log viewing with a dropdown selector interface. 

6 

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""" 

12 

13import logging 

14import os 

15import re 

16from pathlib import Path 

17from typing import Set, Dict, Optional, Callable, List 

18from dataclasses import dataclass 

19 

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 

26 

27# Import file system watching 

28from watchdog.observers import Observer 

29from watchdog.events import FileSystemEventHandler, FileCreatedEvent, FileModifiedEvent 

30 

31# Import core log utilities 

32from openhcs.core.log_utils import LogFileInfo, discover_logs, classify_log_file, is_relevant_log_file 

33 

34# Toolong components are imported in ToolongWidget 

35 

36logger = logging.getLogger(__name__) 

37 

38 

39 

40 

41 

42class ReactiveLogFileHandler(FileSystemEventHandler): 

43 """File system event handler for reactive log monitoring.""" 

44 

45 def __init__(self, monitor: 'ReactiveLogMonitor'): 

46 self.monitor = monitor 

47 

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) 

55 

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) 

62 

63 

64class ReactiveLogMonitor(Widget): 

65 """ 

66 Reactive log monitor with dropdown selector. 

67  

68 Provides real-time monitoring of OpenHCS log files with a clean dropdown 

69 interface for selecting which log to view. 

70 """ 

71 

72 # Reactive properties 

73 active_logs: reactive[Set[Path]] = reactive(set()) 

74 base_log_path: reactive[str] = reactive("") 

75 

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. 

85  

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 

95 

96 # Internal state 

97 self._log_info_cache: Dict[Path, LogFileInfo] = {} 

98 # ToolongWidget will manage its own watcher 

99 

100 # File system watcher (will be set up in on_mount) 

101 self._file_observer = None 

102 

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") 

114 

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") 

123 

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() 

128 

129 def start_monitoring(self, base_log_path: str = None) -> None: 

130 """ 

131 Start monitoring for log files. 

132 

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}'") 

137 

138 if base_log_path: 

139 self.base_log_path = base_log_path 

140 

141 logger.debug(f"Current state: base_log_path='{self.base_log_path}', include_tui_log={self.include_tui_log}") 

142 

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") 

146 

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") 

151 

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") 

156 

157 # Start file system watcher (only if we have subprocess logs to watch) 

158 if self.base_log_path: 

159 self._start_file_watcher() 

160 

161 logger.debug("Log monitoring started successfully") 

162 

163 def stop_monitoring(self) -> None: 

164 """Stop all log monitoring with proper thread cleanup.""" 

165 logger.debug("Stopping reactive log monitoring") 

166 

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}") 

172 

173 # ToolongWidget now manages its own watcher, so no need to stop it here 

174 logger.debug("ReactiveLogMonitor stopped (ToolongWidget manages its own watcher)") 

175 

176 # Clear state 

177 self.active_logs = set() 

178 self._log_info_cache.clear() 

179 

180 logger.debug("Reactive log monitoring stopped") 

181 

182 

183 

184 

185 

186 

187 

188 

189 

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) 

195 

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 

200 

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 

204 

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 

209 

210 logger.debug(f"Added log file to monitoring: {log_info.display_name} ({log_path})") 

211 

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() 

218 

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}") 

222 

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}") 

232 

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]}") 

237 

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())}") 

247 

248 logger.debug(f"Built options: {options}") 

249 

250 if not options: 

251 logger.error("CRITICAL: No options built! This should never happen with TUI log.") 

252 options = [("No logs available", "none")] 

253 

254 # Update selector options 

255 log_selector.set_options(options) 

256 logger.debug(f"Set options on selector, current value: {log_selector.value}") 

257 

258 # Force refresh the selector 

259 log_selector.refresh() 

260 logger.debug("Forced selector refresh") 

261 

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") 

271 

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 

275 

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)}") 

279 

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 

284 

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}") 

291 

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}") 

298 

299 # Clear existing content 

300 existing_widgets = log_container.query("*") 

301 logger.debug(f"Clearing {len(existing_widgets)} existing widgets") 

302 existing_widgets.remove() 

303 

304 # Create complete ToolongWidget - this encapsulates all Toolong functionality 

305 from openhcs.textual_tui.widgets.toolong_widget import ToolongWidget 

306 

307 logger.debug(f"Creating ToolongWidget for: {log_path}") 

308 

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}") 

315 

316 # Mount the complete ToolongWidget 

317 logger.debug("Mounting ToolongWidget to container") 

318 log_container.mount(toolong_widget) 

319 

320 logger.debug(f"Successfully showing log file with ToolongWidget: {log_path}") 

321 

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}") 

332 

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 = [] 

339 

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 

345 

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) 

354 

355 # Sort workers by well ID 

356 worker_logs.sort(key=lambda x: x[0]) 

357 

358 return tui_logs + main_logs + [log_path for _, log_path in worker_logs] + unknown_logs 

359 

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 

365 

366 base_path = Path(self.base_log_path) 

367 watch_directory = base_path.parent 

368 

369 if not watch_directory.exists(): 

370 logger.warning(f"Watch directory does not exist: {watch_directory}") 

371 return 

372 

373 try: 

374 # Stop any existing watcher 

375 self._stop_file_watcher() 

376 

377 # Create new watcher as daemon thread 

378 self._file_observer = Observer() 

379 self._file_observer.daemon = True # Don't block app shutdown 

380 

381 # Create event handler 

382 event_handler = ReactiveLogFileHandler(self) 

383 

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 ) 

390 

391 # Start watching 

392 self._file_observer.start() 

393 

394 logger.debug(f"Started file system watcher for: {watch_directory}") 

395 

396 except Exception as e: 

397 logger.error(f"Failed to start file system watcher: {e}") 

398 self._file_observer = None 

399 

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() 

406 

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 

410 

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") 

422 

423 except Exception as e: 

424 logger.error(f"Error stopping file system watcher: {e}") 

425 finally: 

426 self._file_observer = None 

427 

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) 

431 

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