diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index 1eb8445c..4297d6b1 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -115,6 +115,41 @@ class ContainerManager: except Exception as e: return False, "", f"Command execution failed: {e}" + async def _run_compose_command_streaming(self, args: List[str], cpu_mode: Optional[bool] = None) -> AsyncIterator[str]: + """Run a compose command and yield output lines in real-time.""" + if not self.is_available(): + yield "No container runtime available" + return + + if cpu_mode is None: + cpu_mode = self.use_cpu_compose + compose_file = self.cpu_compose_file if cpu_mode else self.compose_file + cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args + + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output + cwd=Path.cwd() + ) + + # Simple approach: read line by line and yield each one + while True: + line = await process.stdout.readline() + if not line: + break + + line_text = line.decode().rstrip() + if line_text: + yield line_text + + # Wait for process to complete + await process.wait() + + except Exception as e: + yield f"Command execution failed: {e}" + async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: """Run a runtime command (docker/podman) and return (success, stdout, stderr).""" if not self.is_available(): @@ -386,22 +421,31 @@ class ContainerManager: """Upgrade services (pull latest images and restart) and yield progress updates.""" yield False, "Pulling latest images..." - # Pull latest images - success, stdout, stderr = await self._run_compose_command(["pull"], cpu_mode) + # Pull latest images with streaming output + pull_success = True + async for line in self._run_compose_command_streaming(["pull"], cpu_mode): + yield False, line + # Check for error patterns in the output + if "error" in line.lower() or "failed" in line.lower(): + pull_success = False - if not success: - yield False, f"Failed to pull images: {stderr}" - return + if not pull_success: + yield False, "Failed to pull some images, but continuing with restart..." yield False, "Images updated, restarting services..." - # Restart with new images - success, stdout, stderr = await self._run_compose_command(["up", "-d", "--force-recreate"], cpu_mode) + # Restart with new images using streaming output + restart_success = True + async for line in self._run_compose_command_streaming(["up", "-d", "--force-recreate"], cpu_mode): + yield False, line + # Check for error patterns in the output + if "error" in line.lower() or "failed" in line.lower(): + restart_success = False - if success: + if restart_success: yield True, "Services upgraded and restarted successfully" else: - yield False, f"Failed to restart services after upgrade: {stderr}" + yield False, "Some errors occurred during service restart" async def reset_services(self) -> AsyncIterator[tuple[bool, str]]: """Reset all services (stop, remove containers/volumes, clear data) and yield progress updates.""" diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index 61ec2f07..1a0766ba 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -3,6 +3,7 @@ import os import secrets import string +from datetime import datetime from pathlib import Path from typing import Dict, Optional, List from dataclasses import dataclass, field @@ -181,9 +182,10 @@ class EnvManager: try: # Ensure secure defaults (including Langflow secret key) are set before saving self.setup_secure_defaults() - # Create backup if file exists + # Create timestamped backup if file exists if self.env_file.exists(): - backup_file = self.env_file.with_suffix('.env.backup') + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + backup_file = self.env_file.with_suffix(f'.env.backup.{timestamp}') self.env_file.rename(backup_file) with open(self.env_file, 'w') as f: diff --git a/src/tui/screens/config.py b/src/tui/screens/config.py index 1a0fb683..eea547aa 100644 --- a/src/tui/screens/config.py +++ b/src/tui/screens/config.py @@ -399,6 +399,17 @@ class ConfigScreen(Screen): # Add spacing yield Static(" ") + def on_mount(self) -> None: + """Initialize the screen when mounted.""" + # Focus the first input field + try: + # Find the first input field and focus it + inputs = self.query(Input) + if inputs: + inputs[0].focus() + except Exception: + pass + def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "generate-btn": diff --git a/src/tui/screens/diagnostics.py b/src/tui/screens/diagnostics.py index 16aabc57..ad35baec 100644 --- a/src/tui/screens/diagnostics.py +++ b/src/tui/screens/diagnostics.py @@ -11,6 +11,7 @@ from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.screen import Screen from textual.widgets import Header, Footer, Static, Button, Label, Log +from rich.text import Text from ..managers.container_manager import ContainerManager @@ -76,6 +77,12 @@ class DiagnosticsScreen(Screen): def on_mount(self) -> None: """Initialize the screen.""" self.run_diagnostics() + + # Focus the first button (refresh-btn) + try: + self.query_one("#refresh-btn").focus() + except Exception: + pass def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" @@ -228,17 +235,31 @@ class DiagnosticsScreen(Screen): """Go back to previous screen.""" self.app.pop_screen() + def _get_system_info(self) -> Text: + """Get system information text.""" + info_text = Text() + + runtime_info = self.container_manager.get_runtime_info() + + info_text.append("Container Runtime Information\n", style="bold") + info_text.append("=" * 30 + "\n") + info_text.append(f"Type: {runtime_info.runtime_type.value}\n") + info_text.append(f"Compose Command: {' '.join(runtime_info.compose_command)}\n") + info_text.append(f"Runtime Command: {' '.join(runtime_info.runtime_command)}\n") + + if runtime_info.version: + info_text.append(f"Version: {runtime_info.version}\n") + + return info_text + def run_diagnostics(self) -> None: """Run all diagnostics.""" log = self.query_one("#diagnostics-log", Log) log.clear() # System information - log.write("[bold green]System Information[/bold green]") - log.write(f"Runtime: {self.container_manager.runtime_info.runtime_type.value}") - log.write(f"Version: {self.container_manager.runtime_info.version or 'Unknown'}") - log.write(f"Compose file: {self.container_manager.compose_file}") - log.write(f"CPU mode: {self.container_manager.use_cpu_compose}") + system_info = self._get_system_info() + log.write(str(system_info)) log.write("") # Run async diagnostics diff --git a/src/tui/screens/logs.py b/src/tui/screens/logs.py index c5605a21..803afbd8 100644 --- a/src/tui/screens/logs.py +++ b/src/tui/screens/logs.py @@ -19,41 +19,36 @@ class LogsScreen(Screen): ("f", "follow", "Follow Logs"), ("c", "clear", "Clear"), ("r", "refresh", "Refresh"), + ("a", "toggle_auto_scroll", "Toggle Auto Scroll"), + ("g", "scroll_top", "Go to Top"), + ("G", "scroll_bottom", "Go to Bottom"), + ("j", "scroll_down", "Scroll Down"), + ("k", "scroll_up", "Scroll Up"), + ("ctrl+u", "scroll_page_up", "Page Up"), + ("ctrl+f", "scroll_page_down", "Page Down"), ] def __init__(self, initial_service: str = "openrag-backend"): super().__init__() self.container_manager = ContainerManager() + + # Validate the initial service against available options + valid_services = ["openrag-backend", "openrag-frontend", "opensearch", "langflow", "dashboards"] + if initial_service not in valid_services: + initial_service = "openrag-backend" # fallback + self.current_service = initial_service self.logs_area = None self.following = False self.follow_task = None + self.auto_scroll = True def compose(self) -> ComposeResult: """Create the logs screen layout.""" - yield Header() yield Container( Vertical( - Static("Service Logs", id="logs-title"), - Horizontal( - Static("Service:", classes="label"), - Select([ - ("openrag-backend", "Backend"), - ("openrag-frontend", "Frontend"), - ("opensearch", "OpenSearch"), - ("langflow", "Langflow"), - ("dashboards", "Dashboards") - ], value=self.current_service, id="service-select"), - Button("Refresh", variant="default", id="refresh-btn"), - Button("Follow", variant="primary", id="follow-btn"), - Button("Clear", variant="default", id="clear-btn"), - classes="controls-row" - ), + Static(f"Service Logs: {self.current_service}", id="logs-title"), self._create_logs_area(), - Horizontal( - Button("Back", variant="default", id="back-btn"), - classes="button-row" - ), id="logs-content" ), id="main-container" @@ -72,29 +67,30 @@ class LogsScreen(Screen): async def on_mount(self) -> None: """Initialize the screen when mounted.""" + # Set the correct service in the select widget after a brief delay + try: + select = self.query_one("#service-select") + # Set a default first, then set the desired value + select.value = "openrag-backend" + if self.current_service in ["openrag-backend", "openrag-frontend", "opensearch", "langflow", "dashboards"]: + select.value = self.current_service + except Exception as e: + # If setting the service fails, just use the default + pass + await self._load_logs() + + # Focus the logs area since there are no buttons + try: + self.logs_area.focus() + except Exception: + pass def on_unmount(self) -> None: """Clean up when unmounting.""" self._stop_following() - def on_select_changed(self, event: Select.Changed) -> None: - """Handle service selection change.""" - if event.select.id == "service-select": - self.current_service = event.value - self._stop_following() - self.run_worker(self._load_logs()) - def on_button_pressed(self, event: Button.Pressed) -> None: - """Handle button presses.""" - if event.button.id == "refresh-btn": - self.action_refresh() - elif event.button.id == "follow-btn": - self.action_follow() - elif event.button.id == "clear-btn": - self.action_clear() - elif event.button.id == "back-btn": - self.action_back() async def _load_logs(self, lines: int = 200) -> None: """Load recent logs for the current service.""" @@ -106,21 +102,19 @@ class LogsScreen(Screen): if success: self.logs_area.text = logs - # Scroll to bottom - self.logs_area.cursor_position = len(logs) + # Scroll to bottom if auto scroll is enabled + if self.auto_scroll: + self.logs_area.scroll_end() else: self.logs_area.text = f"Failed to load logs: {logs}" def _stop_following(self) -> None: """Stop following logs.""" self.following = False - if self.follow_task and not self.follow_task.done(): + if self.follow_task and not self.follow_task.is_finished: self.follow_task.cancel() - # Update button text - follow_btn = self.query_one("#follow-btn") - follow_btn.label = "Follow" - follow_btn.variant = "primary" + # No button to update since we removed it async def _follow_logs(self) -> None: """Follow logs in real-time.""" @@ -143,8 +137,9 @@ class LogsScreen(Screen): new_text = '\n'.join(lines) self.logs_area.text = new_text - # Scroll to bottom - self.logs_area.cursor_position = len(new_text) + # Scroll to bottom if auto scroll is enabled + if self.auto_scroll: + self.logs_area.scroll_end() except asyncio.CancelledError: pass @@ -165,9 +160,6 @@ class LogsScreen(Screen): self._stop_following() else: self.following = True - follow_btn = self.query_one("#follow-btn") - follow_btn.label = "Stop Following" - follow_btn.variant = "error" # Start following self.follow_task = self.run_worker(self._follow_logs(), exclusive=False) @@ -176,6 +168,51 @@ class LogsScreen(Screen): """Clear the logs area.""" self.logs_area.text = "" + def action_toggle_auto_scroll(self) -> None: + """Toggle auto scroll on/off.""" + self.auto_scroll = not self.auto_scroll + status = "enabled" if self.auto_scroll else "disabled" + self.notify(f"Auto scroll {status}", severity="information") + + def action_scroll_top(self) -> None: + """Scroll to the top of logs.""" + self.logs_area.scroll_home() + + def action_scroll_bottom(self) -> None: + """Scroll to the bottom of logs.""" + self.logs_area.scroll_end() + + def action_scroll_down(self) -> None: + """Scroll down one line.""" + self.logs_area.scroll_down() + + def action_scroll_up(self) -> None: + """Scroll up one line.""" + self.logs_area.scroll_up() + + def action_scroll_page_up(self) -> None: + """Scroll up one page.""" + self.logs_area.scroll_page_up() + + def action_scroll_page_down(self) -> None: + """Scroll down one page.""" + self.logs_area.scroll_page_down() + + def on_key(self, event) -> None: + """Handle key presses that might be intercepted by TextArea.""" + key = event.key + + # Handle keys that TextArea might intercept + if key == "ctrl+u": + self.action_scroll_page_up() + event.prevent_default() + elif key == "ctrl+f": + self.action_scroll_page_down() + event.prevent_default() + elif key.upper() == "G": + self.action_scroll_bottom() + event.prevent_default() + def action_back(self) -> None: """Go back to previous screen.""" self._stop_following() diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 8335093b..617bfd78 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -10,7 +10,7 @@ ButtonVariant = Literal["default", "primary", "success", "warning", "error"] from textual.app import ComposeResult from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.screen import Screen -from textual.widgets import Header, Footer, Static, Button, DataTable, TabbedContent, TabPane +from textual.widgets import Header, Footer, Static, Button, DataTable from textual.timer import Timer from rich.text import Text from rich.table import Table @@ -31,6 +31,9 @@ class MonitorScreen(Screen): ("t", "stop", "Stop Services"), ("u", "upgrade", "Upgrade"), ("x", "reset", "Reset"), + ("l", "logs", "View Logs"), + ("j", "cursor_down", "Move Down"), + ("k", "cursor_up", "Move Up"), ] def __init__(self): @@ -47,15 +50,8 @@ class MonitorScreen(Screen): def compose(self) -> ComposeResult: """Create the monitoring screen layout.""" - yield Header() - - with TabbedContent(id="monitor-tabs"): - with TabPane("Services", id="services-tab"): - yield from self._create_services_tab() - with TabPane("Logs", id="logs-tab"): - yield from self._create_logs_tab() - with TabPane("System", id="system-tab"): - yield from self._create_system_tab() + # Just show the services content directly (no header, no tabs) + yield from self._create_services_tab() yield Footer() @@ -70,7 +66,8 @@ class MonitorScreen(Screen): ) # Images summary table (above services) yield Static("Container Images", classes="tab-header") - self.images_table = DataTable(id="images-table") + self.images_table = DataTable(id="images-table", show_cursor=False) + self.images_table.can_focus = False self.images_table.add_columns("Image", "Digest") yield self.images_table yield Static(" ") @@ -80,32 +77,7 @@ class MonitorScreen(Screen): self.services_table = DataTable(id="services-table") self.services_table.add_columns("Service", "Status", "Health", "Ports", "Image", "Digest") yield self.services_table - yield Horizontal( - Button("Refresh", variant="default", id="refresh-services-btn"), - Button("Back", variant="default", id="back-services-btn"), - classes="button-row" - ) - def _create_logs_tab(self) -> ComposeResult: - """Create the logs viewing tab.""" - logs_content = Static("Select a service to view logs", id="logs-content", markup=False) - - yield Static("Service Logs", id="logs-header") - yield Horizontal( - Button("Backend", variant="default", id="logs-backend"), - Button("Frontend", variant="default", id="logs-frontend"), - Button("OpenSearch", variant="default", id="logs-opensearch"), - Button("Langflow", variant="default", id="logs-langflow"), - classes="button-row" - ) - yield ScrollableContainer(logs_content, id="logs-scroll") - - def _create_system_tab(self) -> ComposeResult: - """Create the system information tab.""" - system_info = Static(self._get_system_info(), id="system-info") - - yield Static("System Information", id="system-header") - yield system_info def _get_runtime_status(self) -> Text: """Get container runtime status text.""" @@ -136,29 +108,18 @@ class MonitorScreen(Screen): return status_text - def _get_system_info(self) -> Text: - """Get system information text.""" - info_text = Text() - - runtime_info = self.container_manager.get_runtime_info() - - info_text.append("Container Runtime Information\n", style="bold") - info_text.append("=" * 30 + "\n") - info_text.append(f"Type: {runtime_info.runtime_type.value}\n") - info_text.append(f"Compose Command: {' '.join(runtime_info.compose_command)}\n") - info_text.append(f"Runtime Command: {' '.join(runtime_info.runtime_command)}\n") - - if runtime_info.version: - info_text.append(f"Version: {runtime_info.version}\n") - # Removed compose files section for cleaner display - - return info_text async def on_mount(self) -> None: """Initialize the screen when mounted.""" await self._refresh_services() # Set up auto-refresh every 5 seconds self.refresh_timer = self.set_interval(5.0, self._auto_refresh) + + # Focus the services table + try: + self.services_table.focus() + except Exception: + pass def on_unmount(self) -> None: """Clean up when unmounting.""" @@ -257,9 +218,9 @@ class MonitorScreen(Screen): self.run_worker(self._reset_services()) elif button_id == "toggle-mode-btn": self.action_toggle_mode() - elif button_id == "refresh-services-btn" or button_id == "refresh-btn": + elif button_id.startswith("refresh-btn"): self.action_refresh() - elif button_id == "back-services-btn" or button_id == "back-btn": + elif button_id.startswith("back-btn"): self.action_back() elif button_id.startswith("logs-"): # Map button IDs to actual service names @@ -439,6 +400,20 @@ class MonitorScreen(Screen): """Refresh services manually.""" self.run_worker(self._refresh_services()) + def action_cursor_down(self) -> None: + """Move cursor down in services table.""" + try: + self.services_table.action_cursor_down() + except Exception: + pass + + def action_cursor_up(self) -> None: + """Move cursor up in services table.""" + try: + self.services_table.action_cursor_up() + except Exception: + pass + def _update_mode_row(self) -> None: """Update the mode indicator and toggle button label.""" try: @@ -522,3 +497,37 @@ class MonitorScreen(Screen): def action_reset(self) -> None: """Reset services.""" self.run_worker(self._reset_services()) + + def action_logs(self) -> None: + """View logs for the selected service.""" + try: + # Get the currently focused row in the services table + table = self.query_one("#services-table", DataTable) + + if table.cursor_row is not None and table.cursor_row >= 0: + # Get the service name from the first column of the selected row + row_data = table.get_row_at(table.cursor_row) + if row_data: + service_name = str(row_data[0]) # First column is service name + + # Map display names to actual service names + service_mapping = { + "openrag-backend": "openrag-backend", + "openrag-frontend": "openrag-frontend", + "opensearch": "opensearch", + "langflow": "langflow", + "dashboards": "dashboards" + } + + actual_service_name = service_mapping.get(service_name, service_name) + + # Push the logs screen with the selected service + from .logs import LogsScreen + logs_screen = LogsScreen(initial_service=actual_service_name) + self.app.push_screen(logs_screen) + else: + self.notify("No service selected", severity="warning") + else: + self.notify("No service selected", severity="warning") + except Exception as e: + self.notify(f"Error opening logs: {e}", severity="error") diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index 2c5ff0b9..d40eebb9 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -97,9 +97,6 @@ class WelcomeScreen(Screen): # Always show monitor option buttons.append(Button("Monitor Services", variant="default", id="monitor-btn")) - # Always show diagnostics button - buttons.append(Button("Diagnostics", variant="default", id="diagnostics-btn")) - return Horizontal(*buttons, classes="button-row") async def on_mount(self) -> None: diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py index bb6a4dc0..e6c54845 100644 --- a/src/tui/widgets/command_modal.py +++ b/src/tui/widgets/command_modal.py @@ -7,7 +7,8 @@ from textual.app import ComposeResult from textual.worker import Worker from textual.containers import Container, ScrollableContainer from textual.screen import ModalScreen -from textual.widgets import Button, Static, Label, Log +from textual.widgets import Button, Static, Label, RichLog +from rich.console import Console class CommandOutputModal(ModalScreen): @@ -86,7 +87,7 @@ class CommandOutputModal(ModalScreen): with Container(id="dialog"): yield Label(self.title_text, id="title") with ScrollableContainer(id="output-container"): - yield Log(id="command-output", highlight=True) + yield RichLog(id="command-output", highlight=True, markup=True) with Container(id="button-row"): yield Button("Close", variant="primary", id="close-btn") @@ -102,12 +103,12 @@ class CommandOutputModal(ModalScreen): async def _run_command(self) -> None: """Run the command and update the output in real-time.""" - output = self.query_one("#command-output", Log) + output = self.query_one("#command-output", RichLog) try: async for is_complete, message in self.command_generator: - # Add message to output - output.write(message) + # Simple approach: just append each line as it comes + output.write(message + "\n") # Scroll to bottom container = self.query_one("#output-container", ScrollableContainer) @@ -115,16 +116,17 @@ class CommandOutputModal(ModalScreen): # If command is complete, update UI if is_complete: - output.write("[bold green]Command completed successfully[/bold green]") + output.write("[bold green]Command completed successfully[/bold green]\n") # Call the completion callback if provided if self.on_complete: await asyncio.sleep(0.5) # Small delay for better UX self.on_complete() except Exception as e: - output.write(f"[bold red]Error: {e}[/bold red]") + output.write(f"[bold red]Error: {e}[/bold red]\n") - # Enable the close button + # Enable the close button and focus it close_btn = self.query_one("#close-btn", Button) close_btn.disabled = False + close_btn.focus() # Made with Bob