tui podman fixes, etc.

This commit is contained in:
phact 2025-09-04 11:45:39 -04:00
parent cad271350b
commit 5f84acb996
10 changed files with 905 additions and 113 deletions

View file

@ -8,9 +8,11 @@ from .screens.welcome import WelcomeScreen
from .screens.config import ConfigScreen from .screens.config import ConfigScreen
from .screens.monitor import MonitorScreen from .screens.monitor import MonitorScreen
from .screens.logs import LogsScreen from .screens.logs import LogsScreen
from .screens.diagnostics import DiagnosticsScreen
from .managers.env_manager import EnvManager from .managers.env_manager import EnvManager
from .managers.container_manager import ContainerManager from .managers.container_manager import ContainerManager
from .utils.platform import PlatformDetector from .utils.platform import PlatformDetector
from .widgets.diagnostics_notification import notify_with_diagnostics
class OpenRAGTUI(App): class OpenRAGTUI(App):
@ -181,7 +183,8 @@ class OpenRAGTUI(App):
"""Initialize the application.""" """Initialize the application."""
# Check for runtime availability and show appropriate screen # Check for runtime availability and show appropriate screen
if not self.container_manager.is_available(): if not self.container_manager.is_available():
self.notify( notify_with_diagnostics(
self,
"No container runtime found. Please install Docker or Podman.", "No container runtime found. Please install Docker or Podman.",
severity="warning", severity="warning",
timeout=10 timeout=10
@ -193,7 +196,7 @@ class OpenRAGTUI(App):
# Start with welcome screen # Start with welcome screen
self.push_screen(WelcomeScreen()) self.push_screen(WelcomeScreen())
def action_quit(self) -> None: async def action_quit(self) -> None:
"""Quit the application.""" """Quit the application."""
self.exit() self.exit()

View file

@ -4,7 +4,7 @@ import asyncio
import json import json
import subprocess import subprocess
import time import time
from dataclasses import dataclass from dataclasses import dataclass, field
from enum import Enum from enum import Enum
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, AsyncIterator from typing import Dict, List, Optional, AsyncIterator
@ -30,7 +30,7 @@ class ServiceInfo:
name: str name: str
status: ServiceStatus status: ServiceStatus
health: Optional[str] = None health: Optional[str] = None
ports: List[str] = None ports: List[str] = field(default_factory=list)
image: Optional[str] = None image: Optional[str] = None
image_digest: Optional[str] = None image_digest: Optional[str] = None
created: Optional[str] = None created: Optional[str] = None
@ -139,6 +139,48 @@ class ContainerManager:
except Exception as e: except Exception as e:
return False, "", f"Command execution failed: {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]: async def get_service_status(self, force_refresh: bool = False) -> Dict[str, ServiceInfo]:
"""Get current status of all services.""" """Get current status of all services."""
current_time = time.time() current_time = time.time()
@ -149,75 +191,100 @@ class ContainerManager:
services = {} services = {}
# Get compose service status # Different approach for Podman vs Docker
success, stdout, stderr = await self._run_compose_command(["ps", "--format", "json"]) 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(): if success and stdout.strip():
try: try:
# Parse JSON output - each line is a separate JSON object containers = json.loads(stdout.strip())
for line in stdout.strip().split('\n'): for container in containers:
if line.strip() and line.startswith('{'): # Get container name and map to service name
service = json.loads(line) names = container.get("Names", [])
container_name = service.get("Name", "") if not names:
continue
# Map container name to service name container_name = names[0]
service_name = self.container_name_map.get(container_name) service_name = self.container_name_map.get(container_name)
if not service_name: if not service_name:
continue continue
state = service.get("State", "").lower() # Get container state
state = container.get("State", "").lower()
# Map compose states to our status enum
if "running" in state: if "running" in state:
status = ServiceStatus.RUNNING status = ServiceStatus.RUNNING
elif "exited" in state or "stopped" in state: elif "exited" in state or "stopped" in state:
status = ServiceStatus.STOPPED status = ServiceStatus.STOPPED
elif "starting" in state: elif "created" in state:
status = ServiceStatus.STARTING status = ServiceStatus.STARTING
else: else:
status = ServiceStatus.UNKNOWN status = ServiceStatus.UNKNOWN
# Extract health - use Status if Health is empty # Get other container info
health = service.get("Health", "") or service.get("Status", "N/A") image = container.get("Image", "N/A")
ports = []
# Extract ports # Handle case where Ports might be None instead of an empty list
ports_str = service.get("Ports", "") container_ports = container.get("Ports") or []
ports = [p.strip() for p in ports_str.split(",") if p.strip()] if ports_str else [] if isinstance(container_ports, list):
for port in container_ports:
# Extract image host_port = port.get("host_port")
image = service.get("Image", "N/A") container_port = port.get("container_port")
if host_port and container_port:
ports.append(f"{host_port}:{container_port}")
services[service_name] = ServiceInfo( services[service_name] = ServiceInfo(
name=service_name, name=service_name,
status=status, status=status,
health=health, health=state,
ports=ports, ports=ports,
image=image, image=image,
) )
except json.JSONDecodeError:
pass
else:
# For Docker, use compose ps command
success, stdout, stderr = await self._run_compose_command(["ps", "--format", "json"])
except json.JSONDecodeError: if success and stdout.strip():
# Fallback to parsing text output try:
lines = stdout.strip().split('\n') # Handle both single JSON object (Podman) and multiple JSON objects (Docker)
for line in lines[1:]: # Skip header if stdout.strip().startswith('[') and stdout.strip().endswith(']'):
if line.strip(): # JSON array format
parts = line.split() service_list = json.loads(stdout.strip())
if len(parts) >= 3: for service in service_list:
name = parts[0] 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 # Only include our expected services
if name not in self.expected_services: if name not in self.expected_services:
continue continue
state = parts[2].lower() state = parts[2].lower()
if "up" in state: if "up" in state:
status = ServiceStatus.RUNNING status = ServiceStatus.RUNNING
elif "exit" in state: elif "exit" in state:
status = ServiceStatus.STOPPED status = ServiceStatus.STOPPED
else: else:
status = ServiceStatus.UNKNOWN status = ServiceStatus.UNKNOWN
services[name] = ServiceInfo(name=name, status=status) services[name] = ServiceInfo(name=name, status=status)
# Add expected services that weren't found # Add expected services that weren't found
for expected in self.expected_services: for expected in self.expected_services:
@ -386,12 +453,15 @@ class ContainerManager:
cwd=Path.cwd() cwd=Path.cwd()
) )
while True: if process.stdout:
line = await process.stdout.readline() while True:
if line: line = await process.stdout.readline()
yield line.decode().rstrip() if line:
else: yield line.decode().rstrip()
break else:
break
else:
yield "Error: Unable to read process output"
except Exception as e: except Exception as e:
yield f"Error following logs: {e}" yield f"Error following logs: {e}"
@ -422,9 +492,51 @@ class ContainerManager:
return stats 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]: def check_podman_macos_memory(self) -> tuple[bool, str]:
"""Check if Podman VM has sufficient memory on macOS.""" """Check if Podman VM has sufficient memory on macOS."""
if self.runtime_info.runtime_type != RuntimeType.PODMAN: if self.runtime_info.runtime_type != RuntimeType.PODMAN:
return True, "Not using 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

View file

@ -0,0 +1,381 @@
"""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 ..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()
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 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}")
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

View file

@ -2,6 +2,11 @@
import asyncio import asyncio
import re 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.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen from textual.screen import Screen
@ -12,6 +17,8 @@ from rich.table import Table
from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo from ..managers.container_manager import ContainerManager, ServiceStatus, ServiceInfo
from ..utils.platform import RuntimeType from ..utils.platform import RuntimeType
from ..widgets.command_modal import CommandOutputModal
from ..widgets.diagnostics_notification import notify_with_diagnostics
class MonitorScreen(Screen): class MonitorScreen(Screen):
@ -160,6 +167,11 @@ class MonitorScreen(Screen):
# Stop following logs if running # Stop following logs if running
self._stop_follow() 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: async def _refresh_services(self) -> None:
"""Refresh the services table.""" """Refresh the services table."""
if not self.container_manager.is_available(): if not self.container_manager.is_available():
@ -229,23 +241,25 @@ class MonitorScreen(Screen):
def on_button_pressed(self, event: Button.Pressed) -> None: def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses.""" """Handle button presses."""
if event.button.id == "start-btn": button_id = event.button.id or ""
if button_id.startswith("start-btn"):
self.run_worker(self._start_services()) self.run_worker(self._start_services())
elif event.button.id == "stop-btn": elif button_id.startswith("stop-btn"):
self.run_worker(self._stop_services()) self.run_worker(self._stop_services())
elif event.button.id == "restart-btn": elif button_id.startswith("restart-btn"):
self.run_worker(self._restart_services()) self.run_worker(self._restart_services())
elif event.button.id == "upgrade-btn": elif button_id.startswith("upgrade-btn"):
self.run_worker(self._upgrade_services()) self.run_worker(self._upgrade_services())
elif event.button.id == "reset-btn": elif button_id.startswith("reset-btn"):
self.run_worker(self._reset_services()) self.run_worker(self._reset_services())
elif event.button.id == "toggle-mode-btn": elif button_id == "toggle-mode-btn":
self.action_toggle_mode() self.action_toggle_mode()
elif event.button.id == "refresh-btn": elif button_id == "refresh-btn":
self.action_refresh() self.action_refresh()
elif event.button.id == "back-btn": elif button_id == "back-btn":
self.action_back() self.action_back()
elif event.button.id.startswith("logs-"): elif button_id.startswith("logs-"):
# Map button IDs to actual service names # Map button IDs to actual service names
service_mapping = { service_mapping = {
"logs-backend": "openrag-backend", "logs-backend": "openrag-backend",
@ -253,7 +267,11 @@ class MonitorScreen(Screen):
"logs-opensearch": "opensearch", "logs-opensearch": "opensearch",
"logs-langflow": "langflow" "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: if service_name:
# Load recent logs then start following # Load recent logs then start following
self.run_worker(self._show_logs(service_name)) self.run_worker(self._show_logs(service_name))
@ -263,9 +281,14 @@ class MonitorScreen(Screen):
"""Start services with progress updates.""" """Start services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
async for is_complete, message in self.container_manager.start_services(cpu_mode): # Show command output in modal dialog
self.notify(message, severity="success" if is_complete else "info") command_generator = self.container_manager.start_services(cpu_mode)
await self._refresh_services() modal = CommandOutputModal(
"Starting Services",
command_generator,
on_complete=None # We'll refresh in on_screen_resume instead
)
self.app.push_screen(modal)
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
@ -273,9 +296,14 @@ class MonitorScreen(Screen):
"""Stop services with progress updates.""" """Stop services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
async for is_complete, message in self.container_manager.stop_services(): # Show command output in modal dialog
self.notify(message, severity="success" if is_complete else "info") command_generator = self.container_manager.stop_services()
await self._refresh_services() modal = CommandOutputModal(
"Stopping Services",
command_generator,
on_complete=None # We'll refresh in on_screen_resume instead
)
self.app.push_screen(modal)
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
@ -283,9 +311,14 @@ class MonitorScreen(Screen):
"""Restart services with progress updates.""" """Restart services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
async for is_complete, message in self.container_manager.restart_services(): # Show command output in modal dialog
self.notify(message, severity="success" if is_complete else "info") command_generator = self.container_manager.restart_services()
await self._refresh_services() modal = CommandOutputModal(
"Restarting Services",
command_generator,
on_complete=None # We'll refresh in on_screen_resume instead
)
self.app.push_screen(modal)
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
@ -293,9 +326,14 @@ class MonitorScreen(Screen):
"""Upgrade services with progress updates.""" """Upgrade services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
async for is_complete, message in self.container_manager.upgrade_services(): # Show command output in modal dialog
self.notify(message, severity="success" if is_complete else "warning") command_generator = self.container_manager.upgrade_services()
await self._refresh_services() modal = CommandOutputModal(
"Upgrading Services",
command_generator,
on_complete=None # We'll refresh in on_screen_resume instead
)
self.app.push_screen(modal)
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
@ -303,9 +341,14 @@ class MonitorScreen(Screen):
"""Reset services with progress updates.""" """Reset services with progress updates."""
self.operation_in_progress = True self.operation_in_progress = True
try: try:
async for is_complete, message in self.container_manager.reset_services(): # Show command output in modal dialog
self.notify(message, severity="success" if is_complete else "warning") command_generator = self.container_manager.reset_services()
await self._refresh_services() modal = CommandOutputModal(
"Resetting Services",
command_generator,
on_complete=None # We'll refresh in on_screen_resume instead
)
self.app.push_screen(modal)
finally: finally:
self.operation_in_progress = False self.operation_in_progress = False
@ -332,14 +375,16 @@ class MonitorScreen(Screen):
# Try to scroll to end of container # Try to scroll to end of container
try: try:
scroller = self.query_one("#logs-scroll", ScrollableContainer) scroller = self.query_one("#logs-scroll", ScrollableContainer)
if hasattr(scroller, "scroll_end"): # Only use scroll_end which is the correct method
scroller.scroll_end(animate=False) scroller.scroll_end(animate=False)
elif hasattr(scroller, "scroll_to_end"):
scroller.scroll_to_end()
except Exception: except Exception:
pass pass
else: 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: def _stop_follow(self) -> None:
task = self._follow_task task = self._follow_task
@ -377,12 +422,16 @@ class MonitorScreen(Screen):
logs_widget = self.query_one("#logs-content", Static) logs_widget = self.query_one("#logs-content", Static)
logs_widget.update("\n".join(self._logs_buffer)) logs_widget.update("\n".join(self._logs_buffer))
scroller = self.query_one("#logs-scroll", ScrollableContainer) scroller = self.query_one("#logs-scroll", ScrollableContainer)
if hasattr(scroller, "scroll_end"): # Only use scroll_end which is the correct method
scroller.scroll_end(animate=False) scroller.scroll_end(animate=False)
except Exception: except Exception:
pass pass
except Exception as e: 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: def action_refresh(self) -> None:
"""Refresh services manually.""" """Refresh services manually."""
@ -405,36 +454,47 @@ class MonitorScreen(Screen):
try: try:
current = getattr(self.container_manager, "use_cpu_compose", True) current = getattr(self.container_manager, "use_cpu_compose", True)
self.container_manager.use_cpu_compose = not current 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._update_mode_row()
self.action_refresh() self.action_refresh()
except Exception as e: 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: def _update_controls(self, services: list[ServiceInfo]) -> None:
"""Render control buttons based on running state and set default focus.""" """Render control buttons based on running state and set default focus."""
try: try:
# Get the controls container
controls = self.query_one("#services-controls", Horizontal) controls = self.query_one("#services-controls", Horizontal)
# Remove all existing children
controls.remove_children() controls.remove_children()
# Check if any services are running
any_running = any(s.status == ServiceStatus.RUNNING for s in services) any_running = any(s.status == ServiceStatus.RUNNING for s in services)
# Add appropriate buttons based on service state
if any_running: if any_running:
# When services are running, show stop and restart
controls.mount(Button("Stop Services", variant="error", id="stop-btn")) controls.mount(Button("Stop Services", variant="error", id="stop-btn"))
controls.mount(Button("Restart", variant="primary", id="restart-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
else: else:
# When services are not running, show start
controls.mount(Button("Start Services", variant="success", id="start-btn")) controls.mount(Button("Start Services", variant="success", id="start-btn"))
try:
self.query_one("#start-btn", Button).focus() # Always show upgrade and reset buttons
except Exception: controls.mount(Button("Upgrade", variant="warning", id="upgrade-btn"))
pass controls.mount(Button("Reset", variant="error", id="reset-btn"))
except Exception:
pass except Exception as e:
notify_with_diagnostics(
self.app,
f"Error updating controls: {e}",
severity="error"
)
def action_back(self) -> None: def action_back(self) -> None:
"""Go back to previous screen.""" """Go back to previous screen."""

View file

@ -23,6 +23,7 @@ class WelcomeScreen(Screen):
("1", "no_auth_setup", "Basic Setup"), ("1", "no_auth_setup", "Basic Setup"),
("2", "full_setup", "Advanced Setup"), ("2", "full_setup", "Advanced Setup"),
("3", "monitor", "Monitor Services"), ("3", "monitor", "Monitor Services"),
("4", "diagnostics", "Diagnostics"),
] ]
def __init__(self): def __init__(self):
@ -96,6 +97,9 @@ class WelcomeScreen(Screen):
# Always show monitor option # Always show monitor option
buttons.append(Button("Monitor Services", variant="default", id="monitor-btn")) 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") return Horizontal(*buttons, classes="button-row")
async def on_mount(self) -> None: async def on_mount(self) -> None:
@ -124,7 +128,7 @@ class WelcomeScreen(Screen):
# Update the welcome text and recompose with new state # Update the welcome text and recompose with new state
try: try:
welcome_widget = self.query_one("#welcome-text") 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 # Focus the appropriate button
if self.services_running: if self.services_running:
@ -154,6 +158,8 @@ class WelcomeScreen(Screen):
self.action_full_setup() self.action_full_setup()
elif event.button.id == "monitor-btn": elif event.button.id == "monitor-btn":
self.action_monitor() self.action_monitor()
elif event.button.id == "diagnostics-btn":
self.action_diagnostics()
def action_default_action(self) -> None: def action_default_action(self) -> None:
"""Handle Enter key - go to default action based on state.""" """Handle Enter key - go to default action based on state."""
@ -179,6 +185,11 @@ class WelcomeScreen(Screen):
from .monitor import MonitorScreen from .monitor import MonitorScreen
self.app.push_screen(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: def action_quit(self) -> None:
"""Quit the application.""" """Quit the application."""
self.app.exit() self.app.exit()

View file

@ -32,15 +32,31 @@ class PlatformDetector:
def detect_runtime(self) -> RuntimeInfo: def detect_runtime(self) -> RuntimeInfo:
"""Detect available container runtime and compose capabilities.""" """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"]): if self._check_command(["docker", "compose", "--help"]):
version = self._get_docker_version() version = self._get_docker_version()
return RuntimeInfo(RuntimeType.DOCKER, ["docker", "compose"], ["docker"], version) return RuntimeInfo(RuntimeType.DOCKER, ["docker", "compose"], ["docker"], version)
if self._check_command(["docker-compose", "--help"]): if self._check_command(["docker-compose", "--help"]):
version = self._get_docker_version() version = self._get_docker_version()
return RuntimeInfo(RuntimeType.DOCKER_COMPOSE, ["docker-compose"], ["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, [], []) return RuntimeInfo(RuntimeType.NONE, [], [])
def detect_gpu_available(self) -> bool: def detect_gpu_available(self) -> bool:

View file

@ -0,0 +1,3 @@
"""Widgets for OpenRAG TUI."""
# Made with Bob

View file

@ -0,0 +1,130 @@
"""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, Log
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 Log(id="command-output", highlight=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", Log)
try:
async for is_complete, message in self.command_generator:
# Add message to output
output.write(message)
# 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]")
# 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]")
# Enable the close button
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
# Made with Bob

View file

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

View file

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