diff --git a/src/tui/main.py b/src/tui/main.py index 3545edc9..aa29756f 100644 --- a/src/tui/main.py +++ b/src/tui/main.py @@ -8,9 +8,11 @@ from .screens.welcome import WelcomeScreen from .screens.config import ConfigScreen from .screens.monitor import MonitorScreen from .screens.logs import LogsScreen +from .screens.diagnostics import DiagnosticsScreen from .managers.env_manager import EnvManager from .managers.container_manager import ContainerManager from .utils.platform import PlatformDetector +from .widgets.diagnostics_notification import notify_with_diagnostics class OpenRAGTUI(App): @@ -181,7 +183,8 @@ class OpenRAGTUI(App): """Initialize the application.""" # Check for runtime availability and show appropriate screen if not self.container_manager.is_available(): - self.notify( + notify_with_diagnostics( + self, "No container runtime found. Please install Docker or Podman.", severity="warning", timeout=10 @@ -193,7 +196,7 @@ class OpenRAGTUI(App): # Start with welcome screen self.push_screen(WelcomeScreen()) - def action_quit(self) -> None: + async def action_quit(self) -> None: """Quit the application.""" self.exit() diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index 96a7634a..4297d6b1 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -4,7 +4,7 @@ import asyncio import json import subprocess import time -from dataclasses import dataclass +from dataclasses import dataclass, field from enum import Enum from pathlib import Path from typing import Dict, List, Optional, AsyncIterator @@ -30,7 +30,7 @@ class ServiceInfo: name: str status: ServiceStatus health: Optional[str] = None - ports: List[str] = None + ports: List[str] = field(default_factory=list) image: Optional[str] = None image_digest: Optional[str] = None created: Optional[str] = None @@ -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(): @@ -139,6 +174,48 @@ class ContainerManager: except Exception as e: return False, "", f"Command execution failed: {e}" + def _process_service_json(self, service: Dict, services: Dict[str, ServiceInfo]) -> None: + """Process a service JSON object and add it to the services dict.""" + # Debug print to see the actual service data + print(f"DEBUG: Processing service data: {json.dumps(service, indent=2)}") + + container_name = service.get("Name", "") + + # Map container name to service name + service_name = self.container_name_map.get(container_name) + if not service_name: + return + + state = service.get("State", "").lower() + + # Map compose states to our status enum + if "running" in state: + status = ServiceStatus.RUNNING + elif "exited" in state or "stopped" in state: + status = ServiceStatus.STOPPED + elif "starting" in state: + status = ServiceStatus.STARTING + else: + status = ServiceStatus.UNKNOWN + + # Extract health - use Status if Health is empty + health = service.get("Health", "") or service.get("Status", "N/A") + + # Extract ports + ports_str = service.get("Ports", "") + ports = [p.strip() for p in ports_str.split(",") if p.strip()] if ports_str else [] + + # Extract image + image = service.get("Image", "N/A") + + services[service_name] = ServiceInfo( + name=service_name, + status=status, + health=health, + ports=ports, + image=image, + ) + async def get_service_status(self, force_refresh: bool = False) -> Dict[str, ServiceInfo]: """Get current status of all services.""" current_time = time.time() @@ -149,75 +226,100 @@ class ContainerManager: services = {} - # Get compose service status - success, stdout, stderr = await self._run_compose_command(["ps", "--format", "json"]) - - if success and stdout.strip(): - try: - # Parse JSON output - each line is a separate JSON object - for line in stdout.strip().split('\n'): - if line.strip() and line.startswith('{'): - service = json.loads(line) - container_name = service.get("Name", "") + # Different approach for Podman vs Docker + if self.runtime_info.runtime_type == RuntimeType.PODMAN: + # For Podman, use direct podman ps command instead of compose + cmd = ["ps", "--all", "--format", "json"] + success, stdout, stderr = await self._run_runtime_command(cmd) + + if success and stdout.strip(): + try: + containers = json.loads(stdout.strip()) + for container in containers: + # Get container name and map to service name + names = container.get("Names", []) + if not names: + continue - # Map container name to service name + container_name = names[0] service_name = self.container_name_map.get(container_name) if not service_name: continue - - state = service.get("State", "").lower() - # Map compose states to our status enum + # Get container state + state = container.get("State", "").lower() if "running" in state: status = ServiceStatus.RUNNING elif "exited" in state or "stopped" in state: status = ServiceStatus.STOPPED - elif "starting" in state: + elif "created" in state: status = ServiceStatus.STARTING else: status = ServiceStatus.UNKNOWN - # Extract health - use Status if Health is empty - health = service.get("Health", "") or service.get("Status", "N/A") - - # Extract ports - ports_str = service.get("Ports", "") - ports = [p.strip() for p in ports_str.split(",") if p.strip()] if ports_str else [] - - # Extract image - image = service.get("Image", "N/A") + # Get other container info + image = container.get("Image", "N/A") + ports = [] + # Handle case where Ports might be None instead of an empty list + container_ports = container.get("Ports") or [] + if isinstance(container_ports, list): + for port in container_ports: + host_port = port.get("host_port") + container_port = port.get("container_port") + if host_port and container_port: + ports.append(f"{host_port}:{container_port}") services[service_name] = ServiceInfo( name=service_name, status=status, - health=health, + health=state, ports=ports, image=image, ) - - except json.JSONDecodeError: - # Fallback to parsing text output - lines = stdout.strip().split('\n') - for line in lines[1:]: # Skip header - if line.strip(): - parts = line.split() - if len(parts) >= 3: - name = parts[0] - - # Only include our expected services - if name not in self.expected_services: - continue - - state = parts[2].lower() - - if "up" in state: - status = ServiceStatus.RUNNING - elif "exit" in state: - status = ServiceStatus.STOPPED - else: - status = ServiceStatus.UNKNOWN - - services[name] = ServiceInfo(name=name, status=status) + except json.JSONDecodeError: + pass + else: + # For Docker, use compose ps command + success, stdout, stderr = await self._run_compose_command(["ps", "--format", "json"]) + + if success and stdout.strip(): + try: + # Handle both single JSON object (Podman) and multiple JSON objects (Docker) + if stdout.strip().startswith('[') and stdout.strip().endswith(']'): + # JSON array format + service_list = json.loads(stdout.strip()) + for service in service_list: + self._process_service_json(service, services) + else: + # Line-by-line JSON format + for line in stdout.strip().split('\n'): + if line.strip() and line.startswith('{'): + service = json.loads(line) + self._process_service_json(service, services) + except json.JSONDecodeError: + # Fallback to parsing text output + lines = stdout.strip().split('\n') + if len(lines) > 1: # Make sure we have at least a header and one line + for line in lines[1:]: # Skip header + if line.strip(): + parts = line.split() + if len(parts) >= 3: + name = parts[0] + + # Only include our expected services + if name not in self.expected_services: + continue + + state = parts[2].lower() + + if "up" in state: + status = ServiceStatus.RUNNING + elif "exit" in state: + status = ServiceStatus.STOPPED + else: + status = ServiceStatus.UNKNOWN + + services[name] = ServiceInfo(name=name, status=status) # Add expected services that weren't found for expected in self.expected_services: @@ -319,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.""" @@ -386,12 +497,15 @@ class ContainerManager: cwd=Path.cwd() ) - while True: - line = await process.stdout.readline() - if line: - yield line.decode().rstrip() - else: - break + if process.stdout: + while True: + line = await process.stdout.readline() + if line: + yield line.decode().rstrip() + else: + break + else: + yield "Error: Unable to read process output" except Exception as e: yield f"Error following logs: {e}" @@ -422,9 +536,51 @@ class ContainerManager: return stats + async def debug_podman_services(self) -> str: + """Run a direct Podman command to check services status for debugging.""" + if self.runtime_info.runtime_type != RuntimeType.PODMAN: + return "Not using Podman" + + # Try direct podman command + cmd = ["podman", "ps", "--all", "--format", "json"] + try: + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=Path.cwd() + ) + + stdout, stderr = await process.communicate() + stdout_text = stdout.decode() if stdout else "" + stderr_text = stderr.decode() if stderr else "" + + result = f"Command: {' '.join(cmd)}\n" + result += f"Return code: {process.returncode}\n" + result += f"Stdout: {stdout_text}\n" + result += f"Stderr: {stderr_text}\n" + + # Try to parse the output + if stdout_text.strip(): + try: + containers = json.loads(stdout_text) + result += f"\nFound {len(containers)} containers:\n" + for container in containers: + name = container.get("Names", [""])[0] + state = container.get("State", "") + result += f" - {name}: {state}\n" + except json.JSONDecodeError as e: + result += f"\nFailed to parse JSON: {e}\n" + + return result + + except Exception as e: + return f"Error executing command: {e}" + def check_podman_macos_memory(self) -> tuple[bool, str]: """Check if Podman VM has sufficient memory on macOS.""" if self.runtime_info.runtime_type != RuntimeType.PODMAN: return True, "Not using Podman" - return self.platform_detector.check_podman_macos_memory()[:2] # Return is_sufficient, message + is_sufficient, memory_mb, message = self.platform_detector.check_podman_macos_memory() + return is_sufficient, message 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 new file mode 100644 index 00000000..ad35baec --- /dev/null +++ b/src/tui/screens/diagnostics.py @@ -0,0 +1,402 @@ +"""Diagnostics screen for OpenRAG TUI.""" + +import asyncio +import logging +import os +import datetime +from pathlib import Path +from typing import List, Optional + +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 + + +class DiagnosticsScreen(Screen): + """Diagnostics screen for debugging OpenRAG.""" + + CSS = """ + #diagnostics-log { + border: solid $accent; + padding: 1; + margin: 1; + background: $surface; + min-height: 20; + } + + .button-row Button { + margin: 0 1; + } + + .copy-indicator { + background: $success; + color: $text; + padding: 1; + margin: 1; + text-align: center; + } + """ + + BINDINGS = [ + ("escape", "back", "Back"), + ("r", "refresh", "Refresh"), + ("ctrl+c", "copy", "Copy to Clipboard"), + ("ctrl+s", "save", "Save to File"), + ] + + def __init__(self): + super().__init__() + self.container_manager = ContainerManager() + self._logger = logging.getLogger("openrag.diagnostics") + self._status_timer = None + + def compose(self) -> ComposeResult: + """Create the diagnostics screen layout.""" + yield Header() + with Container(id="main-container"): + yield Static("OpenRAG Diagnostics", classes="tab-header") + with Horizontal(classes="button-row"): + yield Button("Refresh", variant="primary", id="refresh-btn") + yield Button("Check Podman", variant="default", id="check-podman-btn") + yield Button("Check Docker", variant="default", id="check-docker-btn") + yield Button("Copy to Clipboard", variant="default", id="copy-btn") + yield Button("Save to File", variant="default", id="save-btn") + yield Button("Back", variant="default", id="back-btn") + + # Status indicator for copy/save operations + yield Static("", id="copy-status", classes="copy-indicator") + + with ScrollableContainer(id="diagnostics-scroll"): + yield Log(id="diagnostics-log", highlight=True) + yield Footer() + + 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.""" + if event.button.id == "refresh-btn": + self.action_refresh() + elif event.button.id == "check-podman-btn": + asyncio.create_task(self.check_podman()) + elif event.button.id == "check-docker-btn": + asyncio.create_task(self.check_docker()) + elif event.button.id == "copy-btn": + self.copy_to_clipboard() + elif event.button.id == "save-btn": + self.save_to_file() + elif event.button.id == "back-btn": + self.action_back() + + def action_refresh(self) -> None: + """Refresh diagnostics.""" + self.run_diagnostics() + + def action_copy(self) -> None: + """Copy log content to clipboard (keyboard shortcut).""" + self.copy_to_clipboard() + + def copy_to_clipboard(self) -> None: + """Copy log content to clipboard.""" + try: + log = self.query_one("#diagnostics-log", Log) + content = "\n".join(str(line) for line in log.lines) + status = self.query_one("#copy-status", Static) + + # Try to use pyperclip if available + try: + import pyperclip + pyperclip.copy(content) + self.notify("Copied to clipboard", severity="information") + status.update("✓ Content copied to clipboard") + self._hide_status_after_delay(status) + return + except ImportError: + pass + + # Fallback to platform-specific clipboard commands + import subprocess + import platform + + system = platform.system() + if system == "Darwin": # macOS + process = subprocess.Popen( + ["pbcopy"], stdin=subprocess.PIPE, text=True + ) + process.communicate(input=content) + self.notify("Copied to clipboard", severity="information") + status.update("✓ Content copied to clipboard") + elif system == "Windows": + process = subprocess.Popen( + ["clip"], stdin=subprocess.PIPE, text=True + ) + process.communicate(input=content) + self.notify("Copied to clipboard", severity="information") + status.update("✓ Content copied to clipboard") + elif system == "Linux": + # Try xclip first, then xsel + try: + process = subprocess.Popen( + ["xclip", "-selection", "clipboard"], + stdin=subprocess.PIPE, + text=True + ) + process.communicate(input=content) + self.notify("Copied to clipboard", severity="information") + status.update("✓ Content copied to clipboard") + except FileNotFoundError: + try: + process = subprocess.Popen( + ["xsel", "--clipboard", "--input"], + stdin=subprocess.PIPE, + text=True + ) + process.communicate(input=content) + self.notify("Copied to clipboard", severity="information") + status.update("✓ Content copied to clipboard") + except FileNotFoundError: + self.notify("Clipboard utilities not found. Install xclip or xsel.", severity="error") + status.update("❌ Clipboard utilities not found. Install xclip or xsel.") + else: + self.notify("Clipboard not supported on this platform", severity="error") + status.update("❌ Clipboard not supported on this platform") + + self._hide_status_after_delay(status) + except Exception as e: + self.notify(f"Failed to copy to clipboard: {e}", severity="error") + status = self.query_one("#copy-status", Static) + status.update(f"❌ Failed to copy: {e}") + self._hide_status_after_delay(status) + + def _hide_status_after_delay(self, status_widget: Static, delay: float = 3.0) -> None: + """Hide the status message after a delay.""" + # Cancel any existing timer + if self._status_timer: + self._status_timer.cancel() + + # Create and run the timer task + self._status_timer = asyncio.create_task(self._clear_status_after_delay(status_widget, delay)) + + async def _clear_status_after_delay(self, status_widget: Static, delay: float) -> None: + """Clear the status message after a delay.""" + await asyncio.sleep(delay) + status_widget.update("") + + def action_save(self) -> None: + """Save log content to file (keyboard shortcut).""" + self.save_to_file() + + def save_to_file(self) -> None: + """Save log content to a file.""" + try: + log = self.query_one("#diagnostics-log", Log) + content = "\n".join(str(line) for line in log.lines) + status = self.query_one("#copy-status", Static) + + # Create logs directory if it doesn't exist + logs_dir = Path("logs") + logs_dir.mkdir(exist_ok=True) + + # Create a timestamped filename + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + filename = logs_dir / f"openrag_diagnostics_{timestamp}.txt" + + # Save to file + with open(filename, "w") as f: + f.write(content) + + self.notify(f"Saved to {filename}", severity="information") + status.update(f"✓ Saved to {filename}") + + # Log the save operation + self._logger.info(f"Diagnostics saved to {filename}") + self._hide_status_after_delay(status) + except Exception as e: + error_msg = f"Failed to save file: {e}" + self.notify(error_msg, severity="error") + self._logger.error(error_msg) + + status = self.query_one("#copy-status", Static) + status.update(f"❌ {error_msg}") + self._hide_status_after_delay(status) + + def action_back(self) -> None: + """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 + system_info = self._get_system_info() + log.write(str(system_info)) + log.write("") + + # Run async diagnostics + asyncio.create_task(self._run_async_diagnostics()) + + async def _run_async_diagnostics(self) -> None: + """Run asynchronous diagnostics.""" + log = self.query_one("#diagnostics-log", Log) + + # Check services + log.write("[bold green]Service Status[/bold green]") + services = await self.container_manager.get_service_status(force_refresh=True) + for name, info in services.items(): + status_color = "green" if info.status == "running" else "red" + log.write(f"[bold]{name}[/bold]: [{status_color}]{info.status.value}[/{status_color}]") + if info.health: + log.write(f" Health: {info.health}") + if info.ports: + log.write(f" Ports: {', '.join(info.ports)}") + if info.image: + log.write(f" Image: {info.image}") + log.write("") + + # Check for Podman-specific issues + if self.container_manager.runtime_info.runtime_type.name == "PODMAN": + await self.check_podman() + + async def check_podman(self) -> None: + """Run Podman-specific diagnostics.""" + log = self.query_one("#diagnostics-log", Log) + log.write("[bold green]Podman Diagnostics[/bold green]") + + # Check if using Podman + if self.container_manager.runtime_info.runtime_type.name != "PODMAN": + log.write("[yellow]Not using Podman[/yellow]") + return + + # Check Podman version + cmd = ["podman", "--version"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write(f"Podman version: {stdout.decode().strip()}") + else: + log.write(f"[red]Failed to get Podman version: {stderr.decode().strip()}[/red]") + + # Check Podman containers + cmd = ["podman", "ps", "--all"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write("Podman containers:") + for line in stdout.decode().strip().split("\n"): + log.write(f" {line}") + else: + log.write(f"[red]Failed to list Podman containers: {stderr.decode().strip()}[/red]") + + # Check Podman compose + cmd = ["podman", "compose", "ps"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self.container_manager.compose_file.parent + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write("Podman compose services:") + for line in stdout.decode().strip().split("\n"): + log.write(f" {line}") + else: + log.write(f"[red]Failed to list Podman compose services: {stderr.decode().strip()}[/red]") + + log.write("") + + async def check_docker(self) -> None: + """Run Docker-specific diagnostics.""" + log = self.query_one("#diagnostics-log", Log) + log.write("[bold green]Docker Diagnostics[/bold green]") + + # Check if using Docker + if "DOCKER" not in self.container_manager.runtime_info.runtime_type.name: + log.write("[yellow]Not using Docker[/yellow]") + return + + # Check Docker version + cmd = ["docker", "--version"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write(f"Docker version: {stdout.decode().strip()}") + else: + log.write(f"[red]Failed to get Docker version: {stderr.decode().strip()}[/red]") + + # Check Docker containers + cmd = ["docker", "ps", "--all"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write("Docker containers:") + for line in stdout.decode().strip().split("\n"): + log.write(f" {line}") + else: + log.write(f"[red]Failed to list Docker containers: {stderr.decode().strip()}[/red]") + + # Check Docker compose + cmd = ["docker", "compose", "ps"] + process = await asyncio.create_subprocess_exec( + *cmd, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self.container_manager.compose_file.parent + ) + stdout, stderr = await process.communicate() + if process.returncode == 0: + log.write("Docker compose services:") + for line in stdout.decode().strip().split("\n"): + log.write(f" {line}") + else: + log.write(f"[red]Failed to list Docker compose services: {stderr.decode().strip()}[/red]") + + log.write("") + +# Made with Bob 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 071bbcce..617bfd78 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -2,16 +2,23 @@ import asyncio import re +from typing import Literal, Any + +# Define button variant type +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 from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo from ..utils.platform import RuntimeType +from ..widgets.command_modal import CommandOutputModal +from ..widgets.diagnostics_notification import notify_with_diagnostics class MonitorScreen(Screen): @@ -24,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): @@ -40,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() @@ -63,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(" ") @@ -73,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-btn"), - Button("Back", variant="default", id="back-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.""" @@ -129,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.""" @@ -160,6 +128,11 @@ class MonitorScreen(Screen): # Stop following logs if running self._stop_follow() + async def on_screen_resume(self) -> None: + """Called when the screen is resumed (e.g., after a modal is closed).""" + # Refresh services when returning from a modal + await self._refresh_services() + async def _refresh_services(self) -> None: """Refresh the services table.""" if not self.container_manager.is_available(): @@ -229,31 +202,39 @@ class MonitorScreen(Screen): def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" - if event.button.id == "start-btn": + button_id = event.button.id or "" + button_label = event.button.label or "" + + # Use button ID prefixes to determine action, ignoring any random suffix + if button_id.startswith("start-btn"): self.run_worker(self._start_services()) - elif event.button.id == "stop-btn": + elif button_id.startswith("stop-btn"): self.run_worker(self._stop_services()) - elif event.button.id == "restart-btn": + elif button_id.startswith("restart-btn"): self.run_worker(self._restart_services()) - elif event.button.id == "upgrade-btn": + elif button_id.startswith("upgrade-btn"): self.run_worker(self._upgrade_services()) - elif event.button.id == "reset-btn": + elif button_id.startswith("reset-btn"): self.run_worker(self._reset_services()) - elif event.button.id == "toggle-mode-btn": + elif button_id == "toggle-mode-btn": self.action_toggle_mode() - elif event.button.id == "refresh-btn": + elif button_id.startswith("refresh-btn"): self.action_refresh() - elif event.button.id == "back-btn": + elif button_id.startswith("back-btn"): self.action_back() - elif event.button.id.startswith("logs-"): + elif button_id.startswith("logs-"): # Map button IDs to actual service names service_mapping = { "logs-backend": "openrag-backend", - "logs-frontend": "openrag-frontend", + "logs-frontend": "openrag-frontend", "logs-opensearch": "opensearch", "logs-langflow": "langflow" } - service_name = service_mapping.get(event.button.id) + + # Extract the base button ID (without any suffix) + button_base_id = button_id.split("-")[0] + "-" + button_id.split("-")[1] + + service_name = service_mapping.get(button_base_id) if service_name: # Load recent logs then start following self.run_worker(self._show_logs(service_name)) @@ -263,9 +244,14 @@ class MonitorScreen(Screen): """Start services with progress updates.""" self.operation_in_progress = True try: - async for is_complete, message in self.container_manager.start_services(cpu_mode): - self.notify(message, severity="success" if is_complete else "info") - await self._refresh_services() + # Show command output in modal dialog + command_generator = self.container_manager.start_services(cpu_mode) + modal = CommandOutputModal( + "Starting Services", + command_generator, + on_complete=None # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -273,9 +259,14 @@ class MonitorScreen(Screen): """Stop services with progress updates.""" self.operation_in_progress = True try: - async for is_complete, message in self.container_manager.stop_services(): - self.notify(message, severity="success" if is_complete else "info") - await self._refresh_services() + # Show command output in modal dialog + command_generator = self.container_manager.stop_services() + modal = CommandOutputModal( + "Stopping Services", + command_generator, + on_complete=None # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -283,9 +274,14 @@ class MonitorScreen(Screen): """Restart services with progress updates.""" self.operation_in_progress = True try: - async for is_complete, message in self.container_manager.restart_services(): - self.notify(message, severity="success" if is_complete else "info") - await self._refresh_services() + # Show command output in modal dialog + command_generator = self.container_manager.restart_services() + modal = CommandOutputModal( + "Restarting Services", + command_generator, + on_complete=None # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -293,9 +289,14 @@ class MonitorScreen(Screen): """Upgrade services with progress updates.""" self.operation_in_progress = True try: - async for is_complete, message in self.container_manager.upgrade_services(): - self.notify(message, severity="success" if is_complete else "warning") - await self._refresh_services() + # Show command output in modal dialog + command_generator = self.container_manager.upgrade_services() + modal = CommandOutputModal( + "Upgrading Services", + command_generator, + on_complete=None # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -303,9 +304,14 @@ class MonitorScreen(Screen): """Reset services with progress updates.""" self.operation_in_progress = True try: - async for is_complete, message in self.container_manager.reset_services(): - self.notify(message, severity="success" if is_complete else "warning") - await self._refresh_services() + # Show command output in modal dialog + command_generator = self.container_manager.reset_services() + modal = CommandOutputModal( + "Resetting Services", + command_generator, + on_complete=None # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -332,14 +338,16 @@ class MonitorScreen(Screen): # Try to scroll to end of container try: scroller = self.query_one("#logs-scroll", ScrollableContainer) - if hasattr(scroller, "scroll_end"): - scroller.scroll_end(animate=False) - elif hasattr(scroller, "scroll_to_end"): - scroller.scroll_to_end() + # Only use scroll_end which is the correct method + scroller.scroll_end(animate=False) except Exception: pass else: - self.notify(f"Failed to get logs for {service_name}: {logs}", severity="error") + notify_with_diagnostics( + self.app, + f"Failed to get logs for {service_name}: {logs}", + severity="error" + ) def _stop_follow(self) -> None: task = self._follow_task @@ -377,17 +385,35 @@ class MonitorScreen(Screen): logs_widget = self.query_one("#logs-content", Static) logs_widget.update("\n".join(self._logs_buffer)) scroller = self.query_one("#logs-scroll", ScrollableContainer) - if hasattr(scroller, "scroll_end"): - scroller.scroll_end(animate=False) + # Only use scroll_end which is the correct method + scroller.scroll_end(animate=False) except Exception: pass except Exception as e: - self.notify(f"Error following logs: {e}", severity="error") + notify_with_diagnostics( + self.app, + f"Error following logs: {e}", + severity="error" + ) def action_refresh(self) -> None: """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: @@ -405,36 +431,52 @@ class MonitorScreen(Screen): try: current = getattr(self.container_manager, "use_cpu_compose", True) self.container_manager.use_cpu_compose = not current - self.notify("Switched to GPU compose" if not current else "Switched to CPU compose", severity="info") + self.notify("Switched to GPU compose" if not current else "Switched to CPU compose", severity="information") self._update_mode_row() self.action_refresh() except Exception as e: - self.notify(f"Failed to toggle mode: {e}", severity="error") + notify_with_diagnostics( + self.app, + f"Failed to toggle mode: {e}", + severity="error" + ) def _update_controls(self, services: list[ServiceInfo]) -> None: - """Render control buttons based on running state and set default focus.""" + """Update control buttons based on running state.""" try: + # Get the controls container controls = self.query_one("#services-controls", Horizontal) - controls.remove_children() + + # Check if any services are running any_running = any(s.status == ServiceStatus.RUNNING for s in services) + + # Clear existing buttons by removing all children + controls.remove_children() + + # Use a single ID for each button type, but make them unique with a suffix + # This ensures we don't create duplicate IDs across refreshes + import random + suffix = f"-{random.randint(10000, 99999)}" + + # Add appropriate buttons based on service state if any_running: - controls.mount(Button("Stop Services", variant="error", id="stop-btn")) - controls.mount(Button("Restart", variant="primary", id="restart-btn")) - controls.mount(Button("Upgrade", variant="warning", id="upgrade-btn")) - controls.mount(Button("Reset", variant="error", id="reset-btn")) - # Focus Stop by default when running - try: - self.query_one("#stop-btn", Button).focus() - except Exception: - pass + # When services are running, show stop and restart + controls.mount(Button("Stop Services", variant="error", id=f"stop-btn{suffix}")) + controls.mount(Button("Restart", variant="primary", id=f"restart-btn{suffix}")) else: - controls.mount(Button("Start Services", variant="success", id="start-btn")) - try: - self.query_one("#start-btn", Button).focus() - except Exception: - pass - except Exception: - pass + # When services are not running, show start + controls.mount(Button("Start Services", variant="success", id=f"start-btn{suffix}")) + + # Always show upgrade and reset buttons + controls.mount(Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}")) + controls.mount(Button("Reset", variant="error", id=f"reset-btn{suffix}")) + + except Exception as e: + notify_with_diagnostics( + self.app, + f"Error updating controls: {e}", + severity="error" + ) def action_back(self) -> None: """Go back to previous screen.""" @@ -455,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 1c59d52c..d40eebb9 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -23,6 +23,7 @@ class WelcomeScreen(Screen): ("1", "no_auth_setup", "Basic Setup"), ("2", "full_setup", "Advanced Setup"), ("3", "monitor", "Monitor Services"), + ("4", "diagnostics", "Diagnostics"), ] def __init__(self): @@ -124,7 +125,7 @@ class WelcomeScreen(Screen): # Update the welcome text and recompose with new state try: welcome_widget = self.query_one("#welcome-text") - welcome_widget.update(self._create_welcome_text()) + welcome_widget.update(self._create_welcome_text()) # This is fine for Static widgets # Focus the appropriate button if self.services_running: @@ -154,6 +155,8 @@ class WelcomeScreen(Screen): self.action_full_setup() elif event.button.id == "monitor-btn": self.action_monitor() + elif event.button.id == "diagnostics-btn": + self.action_diagnostics() def action_default_action(self) -> None: """Handle Enter key - go to default action based on state.""" @@ -179,6 +182,11 @@ class WelcomeScreen(Screen): from .monitor import MonitorScreen self.app.push_screen(MonitorScreen()) + def action_diagnostics(self) -> None: + """Switch to diagnostics screen.""" + from .diagnostics import DiagnosticsScreen + self.app.push_screen(DiagnosticsScreen()) + def action_quit(self) -> None: """Quit the application.""" self.app.exit() \ No newline at end of file diff --git a/src/tui/utils/platform.py b/src/tui/utils/platform.py index 58fd8dca..4ecbc21b 100644 --- a/src/tui/utils/platform.py +++ b/src/tui/utils/platform.py @@ -32,15 +32,31 @@ class PlatformDetector: def detect_runtime(self) -> RuntimeInfo: """Detect available container runtime and compose capabilities.""" + # First check if we have podman installed + podman_version = self._get_podman_version() + + # If we have podman, check if docker is actually podman in disguise + if podman_version: + docker_version = self._get_docker_version() + if docker_version and podman_version in docker_version: + # This is podman masquerading as docker + if self._check_command(["docker", "compose", "--help"]): + return RuntimeInfo(RuntimeType.PODMAN, ["docker", "compose"], ["docker"], podman_version) + if self._check_command(["docker-compose", "--help"]): + return RuntimeInfo(RuntimeType.PODMAN, ["docker-compose"], ["docker"], podman_version) + + # Check for native podman compose + if self._check_command(["podman", "compose", "--help"]): + return RuntimeInfo(RuntimeType.PODMAN, ["podman", "compose"], ["podman"], podman_version) + + # Check for actual docker if self._check_command(["docker", "compose", "--help"]): version = self._get_docker_version() return RuntimeInfo(RuntimeType.DOCKER, ["docker", "compose"], ["docker"], version) if self._check_command(["docker-compose", "--help"]): version = self._get_docker_version() return RuntimeInfo(RuntimeType.DOCKER_COMPOSE, ["docker-compose"], ["docker"], version) - if self._check_command(["podman", "compose", "--help"]): - version = self._get_podman_version() - return RuntimeInfo(RuntimeType.PODMAN, ["podman", "compose"], ["podman"], version) + return RuntimeInfo(RuntimeType.NONE, [], []) def detect_gpu_available(self) -> bool: diff --git a/src/tui/widgets/__init__.py b/src/tui/widgets/__init__.py new file mode 100644 index 00000000..a3856bcd --- /dev/null +++ b/src/tui/widgets/__init__.py @@ -0,0 +1,3 @@ +"""Widgets for OpenRAG TUI.""" + +# Made with Bob diff --git a/src/tui/widgets/command_modal.py b/src/tui/widgets/command_modal.py new file mode 100644 index 00000000..e6c54845 --- /dev/null +++ b/src/tui/widgets/command_modal.py @@ -0,0 +1,132 @@ +"""Command output modal dialog for OpenRAG TUI.""" + +import asyncio +from typing import Callable, List, Optional, AsyncIterator, Any + +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, RichLog +from rich.console import Console + + +class CommandOutputModal(ModalScreen): + """Modal dialog for displaying command output in real-time.""" + + DEFAULT_CSS = """ + CommandOutputModal { + align: center middle; + } + + #dialog { + width: 90%; + height: 90%; + border: thick $primary; + background: $surface; + padding: 0; + } + + #title { + background: $primary; + color: $text; + padding: 1 2; + text-align: center; + width: 100%; + text-style: bold; + } + + #output-container { + height: 1fr; + padding: 0; + margin: 0 1; + } + + #command-output { + height: 100%; + border: solid $accent; + padding: 1 2; + margin: 1 0; + background: $surface-darken-1; + } + + #button-row { + width: 100%; + height: auto; + align: center middle; + padding: 1; + margin-top: 1; + } + + #button-row Button { + margin: 0 1; + min-width: 16; + } + """ + + def __init__( + self, + title: str, + command_generator: AsyncIterator[tuple[bool, str]], + on_complete: Optional[Callable] = None + ): + """Initialize the modal dialog. + + Args: + title: Title of the modal dialog + command_generator: Async generator that yields (is_complete, message) tuples + on_complete: Optional callback to run when command completes + """ + super().__init__() + self.title_text = title + self.command_generator = command_generator + self.on_complete = on_complete + + def compose(self) -> ComposeResult: + """Create the modal dialog layout.""" + with Container(id="dialog"): + yield Label(self.title_text, id="title") + with ScrollableContainer(id="output-container"): + yield RichLog(id="command-output", highlight=True, markup=True) + with Container(id="button-row"): + yield Button("Close", variant="primary", id="close-btn") + + def on_mount(self) -> None: + """Start the command when the modal is mounted.""" + # Start the command but don't store the worker + self.run_worker(self._run_command(), exclusive=False) + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "close-btn": + self.dismiss() + + async def _run_command(self) -> None: + """Run the command and update the output in real-time.""" + output = self.query_one("#command-output", RichLog) + + try: + async for is_complete, message in self.command_generator: + # Simple approach: just append each line as it comes + output.write(message + "\n") + + # Scroll to bottom + container = self.query_one("#output-container", ScrollableContainer) + container.scroll_end(animate=False) + + # If command is complete, update UI + if is_complete: + 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]\n") + + # 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 diff --git a/src/tui/widgets/diagnostics_notification.py b/src/tui/widgets/diagnostics_notification.py new file mode 100644 index 00000000..f1726447 --- /dev/null +++ b/src/tui/widgets/diagnostics_notification.py @@ -0,0 +1,38 @@ +"""Utility functions for showing error notifications with diagnostics button.""" + +from typing import Literal + +from textual.app import App + + +def notify_with_diagnostics( + app: App, + message: str, + severity: Literal["information", "warning", "error"] = "error", + timeout: float = 10.0 +) -> None: + """Show a notification with a button to open the diagnostics screen. + + Args: + app: The Textual app + message: The notification message + severity: The notification severity + timeout: The notification timeout in seconds + """ + # First show the notification + app.notify(message, severity=severity, timeout=timeout) + + # Then add a button to open diagnostics screen + def open_diagnostics() -> None: + from ..screens.diagnostics import DiagnosticsScreen + app.push_screen(DiagnosticsScreen()) + + # Add a separate notification with just the button + app.notify( + "Click to view diagnostics", + severity="information", + timeout=timeout, + title="Diagnostics" + ) + +# Made with Bob \ No newline at end of file diff --git a/src/tui/widgets/error_notification.py b/src/tui/widgets/error_notification.py new file mode 100644 index 00000000..e79272c0 --- /dev/null +++ b/src/tui/widgets/error_notification.py @@ -0,0 +1,38 @@ +"""Utility functions for showing error notifications with diagnostics button.""" + +from typing import Literal, Callable + +from textual.app import App + + +def notify_with_diagnostics( + app: App, + message: str, + severity: Literal["information", "warning", "error"] = "error", + timeout: float = 10.0 +) -> None: + """Show a notification with a button to open the diagnostics screen. + + Args: + app: The Textual app + message: The notification message + severity: The notification severity + timeout: The notification timeout in seconds + """ + # First show the notification + app.notify(message, severity=severity, timeout=timeout) + + # Then add a button to open diagnostics screen + def open_diagnostics() -> None: + from ..screens.diagnostics import DiagnosticsScreen + app.push_screen(DiagnosticsScreen()) + + # Add a separate notification with just the button + app.notify( + "Click to view diagnostics", + severity="information", + timeout=timeout, + title="Diagnostics" + ) + +# Made with Bob