tui fixes

This commit is contained in:
phact 2025-09-04 13:40:22 -04:00
parent 214877e9ff
commit 956d7582eb
8 changed files with 253 additions and 130 deletions

View file

@ -115,6 +115,41 @@ class ContainerManager:
except Exception as e:
return False, "", f"Command execution failed: {e}"
async def _run_compose_command_streaming(self, args: List[str], cpu_mode: Optional[bool] = None) -> AsyncIterator[str]:
"""Run a compose command and yield output lines in real-time."""
if not self.is_available():
yield "No container runtime available"
return
if cpu_mode is None:
cpu_mode = self.use_cpu_compose
compose_file = self.cpu_compose_file if cpu_mode else self.compose_file
cmd = self.runtime_info.compose_command + ["-f", str(compose_file)] + args
try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output
cwd=Path.cwd()
)
# Simple approach: read line by line and yield each one
while True:
line = await process.stdout.readline()
if not line:
break
line_text = line.decode().rstrip()
if line_text:
yield line_text
# Wait for process to complete
await process.wait()
except Exception as e:
yield f"Command execution failed: {e}"
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
"""Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
if not self.is_available():
@ -386,22 +421,31 @@ class ContainerManager:
"""Upgrade services (pull latest images and restart) and yield progress updates."""
yield False, "Pulling latest images..."
# Pull latest images
success, stdout, stderr = await self._run_compose_command(["pull"], cpu_mode)
# Pull latest images with streaming output
pull_success = True
async for line in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, line
# Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower():
pull_success = False
if not success:
yield False, f"Failed to pull images: {stderr}"
return
if not pull_success:
yield False, "Failed to pull some images, but continuing with restart..."
yield False, "Images updated, restarting services..."
# Restart with new images
success, stdout, stderr = await self._run_compose_command(["up", "-d", "--force-recreate"], cpu_mode)
# Restart with new images using streaming output
restart_success = True
async for line in self._run_compose_command_streaming(["up", "-d", "--force-recreate"], cpu_mode):
yield False, line
# Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower():
restart_success = False
if success:
if restart_success:
yield True, "Services upgraded and restarted successfully"
else:
yield False, f"Failed to restart services after upgrade: {stderr}"
yield False, "Some errors occurred during service restart"
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""

View file

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

View file

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

View file

@ -11,6 +11,7 @@ from textual.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, Label, Log
from rich.text import Text
from ..managers.container_manager import ContainerManager
@ -76,6 +77,12 @@ class DiagnosticsScreen(Screen):
def on_mount(self) -> None:
"""Initialize the screen."""
self.run_diagnostics()
# Focus the first button (refresh-btn)
try:
self.query_one("#refresh-btn").focus()
except Exception:
pass
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
@ -228,17 +235,31 @@ class DiagnosticsScreen(Screen):
"""Go back to previous screen."""
self.app.pop_screen()
def _get_system_info(self) -> Text:
"""Get system information text."""
info_text = Text()
runtime_info = self.container_manager.get_runtime_info()
info_text.append("Container Runtime Information\n", style="bold")
info_text.append("=" * 30 + "\n")
info_text.append(f"Type: {runtime_info.runtime_type.value}\n")
info_text.append(f"Compose Command: {' '.join(runtime_info.compose_command)}\n")
info_text.append(f"Runtime Command: {' '.join(runtime_info.runtime_command)}\n")
if runtime_info.version:
info_text.append(f"Version: {runtime_info.version}\n")
return info_text
def run_diagnostics(self) -> None:
"""Run all diagnostics."""
log = self.query_one("#diagnostics-log", Log)
log.clear()
# System information
log.write("[bold green]System Information[/bold green]")
log.write(f"Runtime: {self.container_manager.runtime_info.runtime_type.value}")
log.write(f"Version: {self.container_manager.runtime_info.version or 'Unknown'}")
log.write(f"Compose file: {self.container_manager.compose_file}")
log.write(f"CPU mode: {self.container_manager.use_cpu_compose}")
system_info = self._get_system_info()
log.write(str(system_info))
log.write("")
# Run async diagnostics

View file

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

View file

@ -10,7 +10,7 @@ ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
from textual.app import ComposeResult
from textual.containers import Container, Vertical, Horizontal, ScrollableContainer
from textual.screen import Screen
from textual.widgets import Header, Footer, Static, Button, DataTable, TabbedContent, TabPane
from textual.widgets import Header, Footer, Static, Button, DataTable
from textual.timer import Timer
from rich.text import Text
from rich.table import Table
@ -31,6 +31,9 @@ class MonitorScreen(Screen):
("t", "stop", "Stop Services"),
("u", "upgrade", "Upgrade"),
("x", "reset", "Reset"),
("l", "logs", "View Logs"),
("j", "cursor_down", "Move Down"),
("k", "cursor_up", "Move Up"),
]
def __init__(self):
@ -47,15 +50,8 @@ class MonitorScreen(Screen):
def compose(self) -> ComposeResult:
"""Create the monitoring screen layout."""
yield Header()
with TabbedContent(id="monitor-tabs"):
with TabPane("Services", id="services-tab"):
yield from self._create_services_tab()
with TabPane("Logs", id="logs-tab"):
yield from self._create_logs_tab()
with TabPane("System", id="system-tab"):
yield from self._create_system_tab()
# Just show the services content directly (no header, no tabs)
yield from self._create_services_tab()
yield Footer()
@ -70,7 +66,8 @@ class MonitorScreen(Screen):
)
# Images summary table (above services)
yield Static("Container Images", classes="tab-header")
self.images_table = DataTable(id="images-table")
self.images_table = DataTable(id="images-table", show_cursor=False)
self.images_table.can_focus = False
self.images_table.add_columns("Image", "Digest")
yield self.images_table
yield Static(" ")
@ -80,32 +77,7 @@ class MonitorScreen(Screen):
self.services_table = DataTable(id="services-table")
self.services_table.add_columns("Service", "Status", "Health", "Ports", "Image", "Digest")
yield self.services_table
yield Horizontal(
Button("Refresh", variant="default", id="refresh-services-btn"),
Button("Back", variant="default", id="back-services-btn"),
classes="button-row"
)
def _create_logs_tab(self) -> ComposeResult:
"""Create the logs viewing tab."""
logs_content = Static("Select a service to view logs", id="logs-content", markup=False)
yield Static("Service Logs", id="logs-header")
yield Horizontal(
Button("Backend", variant="default", id="logs-backend"),
Button("Frontend", variant="default", id="logs-frontend"),
Button("OpenSearch", variant="default", id="logs-opensearch"),
Button("Langflow", variant="default", id="logs-langflow"),
classes="button-row"
)
yield ScrollableContainer(logs_content, id="logs-scroll")
def _create_system_tab(self) -> ComposeResult:
"""Create the system information tab."""
system_info = Static(self._get_system_info(), id="system-info")
yield Static("System Information", id="system-header")
yield system_info
def _get_runtime_status(self) -> Text:
"""Get container runtime status text."""
@ -136,29 +108,18 @@ class MonitorScreen(Screen):
return status_text
def _get_system_info(self) -> Text:
"""Get system information text."""
info_text = Text()
runtime_info = self.container_manager.get_runtime_info()
info_text.append("Container Runtime Information\n", style="bold")
info_text.append("=" * 30 + "\n")
info_text.append(f"Type: {runtime_info.runtime_type.value}\n")
info_text.append(f"Compose Command: {' '.join(runtime_info.compose_command)}\n")
info_text.append(f"Runtime Command: {' '.join(runtime_info.runtime_command)}\n")
if runtime_info.version:
info_text.append(f"Version: {runtime_info.version}\n")
# Removed compose files section for cleaner display
return info_text
async def on_mount(self) -> None:
"""Initialize the screen when mounted."""
await self._refresh_services()
# Set up auto-refresh every 5 seconds
self.refresh_timer = self.set_interval(5.0, self._auto_refresh)
# Focus the services table
try:
self.services_table.focus()
except Exception:
pass
def on_unmount(self) -> None:
"""Clean up when unmounting."""
@ -257,9 +218,9 @@ class MonitorScreen(Screen):
self.run_worker(self._reset_services())
elif button_id == "toggle-mode-btn":
self.action_toggle_mode()
elif button_id == "refresh-services-btn" or button_id == "refresh-btn":
elif button_id.startswith("refresh-btn"):
self.action_refresh()
elif button_id == "back-services-btn" or button_id == "back-btn":
elif button_id.startswith("back-btn"):
self.action_back()
elif button_id.startswith("logs-"):
# Map button IDs to actual service names
@ -439,6 +400,20 @@ class MonitorScreen(Screen):
"""Refresh services manually."""
self.run_worker(self._refresh_services())
def action_cursor_down(self) -> None:
"""Move cursor down in services table."""
try:
self.services_table.action_cursor_down()
except Exception:
pass
def action_cursor_up(self) -> None:
"""Move cursor up in services table."""
try:
self.services_table.action_cursor_up()
except Exception:
pass
def _update_mode_row(self) -> None:
"""Update the mode indicator and toggle button label."""
try:
@ -522,3 +497,37 @@ class MonitorScreen(Screen):
def action_reset(self) -> None:
"""Reset services."""
self.run_worker(self._reset_services())
def action_logs(self) -> None:
"""View logs for the selected service."""
try:
# Get the currently focused row in the services table
table = self.query_one("#services-table", DataTable)
if table.cursor_row is not None and table.cursor_row >= 0:
# Get the service name from the first column of the selected row
row_data = table.get_row_at(table.cursor_row)
if row_data:
service_name = str(row_data[0]) # First column is service name
# Map display names to actual service names
service_mapping = {
"openrag-backend": "openrag-backend",
"openrag-frontend": "openrag-frontend",
"opensearch": "opensearch",
"langflow": "langflow",
"dashboards": "dashboards"
}
actual_service_name = service_mapping.get(service_name, service_name)
# Push the logs screen with the selected service
from .logs import LogsScreen
logs_screen = LogsScreen(initial_service=actual_service_name)
self.app.push_screen(logs_screen)
else:
self.notify("No service selected", severity="warning")
else:
self.notify("No service selected", severity="warning")
except Exception as e:
self.notify(f"Error opening logs: {e}", severity="error")

View file

@ -97,9 +97,6 @@ class WelcomeScreen(Screen):
# Always show monitor option
buttons.append(Button("Monitor Services", variant="default", id="monitor-btn"))
# Always show diagnostics button
buttons.append(Button("Diagnostics", variant="default", id="diagnostics-btn"))
return Horizontal(*buttons, classes="button-row")
async def on_mount(self) -> None:

View file

@ -7,7 +7,8 @@ from textual.app import ComposeResult
from textual.worker import Worker
from textual.containers import Container, ScrollableContainer
from textual.screen import ModalScreen
from textual.widgets import Button, Static, Label, Log
from textual.widgets import Button, Static, Label, RichLog
from rich.console import Console
class CommandOutputModal(ModalScreen):
@ -86,7 +87,7 @@ class CommandOutputModal(ModalScreen):
with Container(id="dialog"):
yield Label(self.title_text, id="title")
with ScrollableContainer(id="output-container"):
yield Log(id="command-output", highlight=True)
yield RichLog(id="command-output", highlight=True, markup=True)
with Container(id="button-row"):
yield Button("Close", variant="primary", id="close-btn")
@ -102,12 +103,12 @@ class CommandOutputModal(ModalScreen):
async def _run_command(self) -> None:
"""Run the command and update the output in real-time."""
output = self.query_one("#command-output", Log)
output = self.query_one("#command-output", RichLog)
try:
async for is_complete, message in self.command_generator:
# Add message to output
output.write(message)
# Simple approach: just append each line as it comes
output.write(message + "\n")
# Scroll to bottom
container = self.query_one("#output-container", ScrollableContainer)
@ -115,16 +116,17 @@ class CommandOutputModal(ModalScreen):
# If command is complete, update UI
if is_complete:
output.write("[bold green]Command completed successfully[/bold green]")
output.write("[bold green]Command completed successfully[/bold green]\n")
# Call the completion callback if provided
if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX
self.on_complete()
except Exception as e:
output.write(f"[bold red]Error: {e}[/bold red]")
output.write(f"[bold red]Error: {e}[/bold red]\n")
# Enable the close button
# Enable the close button and focus it
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
close_btn.focus()
# Made with Bob