back to main, progress improvements for docker pull

This commit is contained in:
phact 2025-10-08 12:23:42 -04:00
parent 682fc08c01
commit ad572a7b23
4 changed files with 189 additions and 102 deletions

View file

@ -164,10 +164,15 @@ class ContainerManager:
async def _run_compose_command_streaming( async def _run_compose_command_streaming(
self, args: List[str], cpu_mode: Optional[bool] = None self, args: List[str], cpu_mode: Optional[bool] = None
) -> AsyncIterator[str]: ) -> AsyncIterator[tuple[str, bool]]:
"""Run a compose command and yield output lines in real-time.""" """Run a compose command and yield output with progress bar support.
Yields:
Tuples of (message, replace_last) where replace_last indicates if the
message should replace the previous line (for progress updates)
"""
if not self.is_available(): if not self.is_available():
yield "No container runtime available" yield ("No container runtime available", False)
return return
if cpu_mode is None: if cpu_mode is None:
@ -179,37 +184,58 @@ class ContainerManager:
process = await asyncio.create_subprocess_exec( process = await asyncio.create_subprocess_exec(
*cmd, *cmd,
stdout=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output stderr=asyncio.subprocess.STDOUT,
cwd=Path.cwd(), cwd=Path.cwd(),
) )
# Simple approach: read line by line and yield each one
if process.stdout: if process.stdout:
buffer = ""
while True: while True:
line = await process.stdout.readline() chunk = await process.stdout.read(1024)
if not line: if not chunk:
if buffer.strip():
yield (buffer.strip(), False)
break break
line_text = line.decode(errors="ignore").rstrip() buffer += chunk.decode(errors="ignore")
if line_text:
yield line_text while "\n" in buffer or "\r" in buffer:
cr_pos = buffer.find("\r")
nl_pos = buffer.find("\n")
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
line = buffer[:cr_pos]
buffer = buffer[cr_pos + 1:]
if line.strip():
yield (line.strip(), True)
elif nl_pos != -1:
line = buffer[:nl_pos]
buffer = buffer[nl_pos + 1:]
if line.strip():
yield (line.strip(), False)
else:
break
# Wait for process to complete
await process.wait() await process.wait()
except Exception as e: except Exception as e:
yield f"Command execution failed: {e}" yield (f"Command execution failed: {e}", False)
async def _stream_compose_command( async def _stream_compose_command(
self, self,
args: List[str], args: List[str],
success_flag: Dict[str, bool], success_flag: Dict[str, bool],
cpu_mode: Optional[bool] = None, cpu_mode: Optional[bool] = None,
) -> AsyncIterator[str]: ) -> AsyncIterator[tuple[str, bool]]:
"""Run compose command with live output and record success/failure.""" """Run compose command with live output and record success/failure.
Yields:
Tuples of (message, replace_last) where replace_last indicates if the
message should replace the previous line (for progress updates)
"""
if not self.is_available(): if not self.is_available():
success_flag["value"] = False success_flag["value"] = False
yield "No container runtime available" yield ("No container runtime available", False)
return return
if cpu_mode is None: if cpu_mode is None:
@ -226,32 +252,52 @@ class ContainerManager:
) )
except Exception as e: except Exception as e:
success_flag["value"] = False success_flag["value"] = False
yield f"Command execution failed: {e}" yield (f"Command execution failed: {e}", False)
return return
success_flag["value"] = True success_flag["value"] = True
if process.stdout: if process.stdout:
# Buffer to accumulate data for progress bar handling
buffer = ""
while True: while True:
line = await process.stdout.readline() chunk = await process.stdout.read(1024)
if not line: if not chunk:
# Process any remaining buffer content
if buffer.strip():
yield (buffer.strip(), False)
break break
line_text = line.decode(errors="ignore") buffer += chunk.decode(errors="ignore")
# Compose often uses carriage returns for progress bars; normalise them
for chunk in line_text.replace("\r", "\n").split("\n"): # Process complete lines or carriage return updates
chunk = chunk.strip() while "\n" in buffer or "\r" in buffer:
if not chunk: # Check if we have a carriage return (progress update) before newline
continue cr_pos = buffer.find("\r")
yield chunk nl_pos = buffer.find("\n")
lowered = chunk.lower()
if "error" in lowered or "failed" in lowered: if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
success_flag["value"] = False # Carriage return found - extract and yield as replaceable line
line = buffer[:cr_pos]
buffer = buffer[cr_pos + 1:]
if line.strip():
yield (line.strip(), True) # replace_last=True for progress updates
elif nl_pos != -1:
# Newline found - extract and yield as new line
line = buffer[:nl_pos]
buffer = buffer[nl_pos + 1:]
if line.strip():
lowered = line.lower()
yield (line.strip(), False) # replace_last=False for new lines
if "error" in lowered or "failed" in lowered:
success_flag["value"] = False
else:
break
returncode = await process.wait() returncode = await process.wait()
if returncode != 0: if returncode != 0:
success_flag["value"] = False success_flag["value"] = False
yield f"Command exited with status {returncode}" yield (f"Command exited with status {returncode}", False)
async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]: async def _run_runtime_command(self, args: List[str]) -> tuple[bool, str, str]:
"""Run a runtime command (docker/podman) and return (success, stdout, stderr).""" """Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
@ -516,14 +562,14 @@ class ContainerManager:
if hasattr(self, '_compose_search_log'): if hasattr(self, '_compose_search_log'):
for line in self._compose_search_log.split('\n'): for line in self._compose_search_log.split('\n'):
if line.strip(): if line.strip():
yield False, line yield False, line, False
yield False, f"Final compose file: {compose_file.absolute()}" yield False, f"Final compose file: {compose_file.absolute()}", False
if not compose_file.exists(): if not compose_file.exists():
yield False, f"ERROR: Compose file not found at {compose_file.absolute()}" yield False, f"ERROR: Compose file not found at {compose_file.absolute()}", False
return return
yield False, "Starting OpenRAG services..." yield False, "Starting OpenRAG services...", False
missing_images: List[str] = [] missing_images: List[str] = []
try: try:
@ -534,24 +580,24 @@ class ContainerManager:
if missing_images: if missing_images:
images_list = ", ".join(missing_images) images_list = ", ".join(missing_images)
yield False, f"Pulling container images ({images_list})..." yield False, f"Pulling container images ({images_list})...", False
pull_success = {"value": True} pull_success = {"value": True}
async for line in self._stream_compose_command( async for message, replace_last in self._stream_compose_command(
["pull"], pull_success, cpu_mode ["pull"], pull_success, cpu_mode
): ):
yield False, line yield False, message, replace_last
if not pull_success["value"]: if not pull_success["value"]:
yield False, "Some images failed to pull; attempting to start services anyway..." yield False, "Some images failed to pull; attempting to start services anyway...", False
yield False, "Creating and starting containers..." yield False, "Creating and starting containers...", False
up_success = {"value": True} up_success = {"value": True}
async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode): async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, line yield False, message, replace_last
if up_success["value"]: if up_success["value"]:
yield True, "Services started successfully" yield True, "Services started successfully", False
else: else:
yield False, "Failed to start services. See output above for details." yield False, "Failed to start services. See output above for details.", False
async def stop_services(self) -> AsyncIterator[tuple[bool, str]]: async def stop_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Stop all services and yield progress updates.""" """Stop all services and yield progress updates."""
@ -581,35 +627,35 @@ class ContainerManager:
self, cpu_mode: bool = False self, cpu_mode: bool = False
) -> AsyncIterator[tuple[bool, str]]: ) -> AsyncIterator[tuple[bool, str]]:
"""Upgrade services (pull latest images and restart) and yield progress updates.""" """Upgrade services (pull latest images and restart) and yield progress updates."""
yield False, "Pulling latest images..." yield False, "Pulling latest images...", False
# Pull latest images with streaming output # Pull latest images with streaming output
pull_success = True pull_success = True
async for line in self._run_compose_command_streaming(["pull"], cpu_mode): async for message, replace_last in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, line yield False, message, replace_last
# Check for error patterns in the output # Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower(): if "error" in message.lower() or "failed" in message.lower():
pull_success = False pull_success = False
if not pull_success: if not pull_success:
yield False, "Failed to pull some images, but continuing with restart..." yield False, "Failed to pull some images, but continuing with restart...", False
yield False, "Images updated, restarting services..." yield False, "Images updated, restarting services...", False
# Restart with new images using streaming output # Restart with new images using streaming output
restart_success = True restart_success = True
async for line in self._run_compose_command_streaming( async for message, replace_last in self._run_compose_command_streaming(
["up", "-d", "--force-recreate"], cpu_mode ["up", "-d", "--force-recreate"], cpu_mode
): ):
yield False, line yield False, message, replace_last
# Check for error patterns in the output # Check for error patterns in the output
if "error" in line.lower() or "failed" in line.lower(): if "error" in message.lower() or "failed" in message.lower():
restart_success = False restart_success = False
if restart_success: if restart_success:
yield True, "Services upgraded and restarted successfully" yield True, "Services upgraded and restarted successfully", False
else: else:
yield False, "Some errors occurred during service restart" yield False, "Some errors occurred during service restart", False
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]: async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates.""" """Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""

View file

@ -507,10 +507,8 @@ class ConfigScreen(Screen):
# Save to file # Save to file
if self.env_manager.save_env_file(): if self.env_manager.save_env_file():
self.notify("Configuration saved successfully!", severity="information") self.notify("Configuration saved successfully!", severity="information")
# Switch to monitor screen # Go back to welcome screen
from .monitor import MonitorScreen self.dismiss()
self.app.push_screen(MonitorScreen())
else: else:
self.notify("Failed to save configuration", severity="error") self.notify("Failed to save configuration", severity="error")

View file

@ -237,6 +237,22 @@ class WelcomeScreen(Screen):
except: except:
pass # Button might not exist pass # Button might not exist
async def on_resume(self) -> None:
"""Called when returning from another screen (e.g., config screen)."""
# Reload environment variables
load_dotenv(override=True)
# Update OAuth config state
self.has_oauth_config = bool(os.getenv("GOOGLE_OAUTH_CLIENT_ID")) or bool(
os.getenv("MICROSOFT_GRAPH_OAUTH_CLIENT_ID")
)
# Re-detect service state
self._detect_services_sync()
# Refresh the welcome content and buttons
await self._refresh_welcome_content()
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 == "basic-setup-btn": if event.button.id == "basic-setup-btn":

View file

@ -6,7 +6,7 @@ from typing import Callable, Optional, AsyncIterator
from rich.text import Text from rich.text import Text
from textual.app import ComposeResult from textual.app import ComposeResult
from textual.containers import Container, ScrollableContainer from textual.containers import Container
from textual.screen import ModalScreen from textual.screen import ModalScreen
from textual.widgets import Button, Static, Label, TextArea from textual.widgets import Button, Static, Label, TextArea
@ -38,23 +38,13 @@ class CommandOutputModal(ModalScreen):
text-style: bold; text-style: bold;
} }
#output-container {
height: 1fr;
padding: 0;
margin: 0 1;
}
#command-output { #command-output {
height: 100%; height: 1fr;
border: solid $accent; border: solid $accent;
margin: 1 0; margin: 1;
background: $surface-darken-1; background: $surface-darken-1;
} }
#command-output > .text-area--content {
padding: 1 2;
}
#button-row { #button-row {
width: 100%; width: 100%;
height: auto; height: auto;
@ -84,27 +74,27 @@ class CommandOutputModal(ModalScreen):
Args: Args:
title: Title of the modal dialog title: Title of the modal dialog
command_generator: Async generator that yields (is_complete, message) tuples command_generator: Async generator that yields (is_complete, message) or (is_complete, message, replace_last) tuples
on_complete: Optional callback to run when command completes on_complete: Optional callback to run when command completes
""" """
super().__init__() super().__init__()
self.title_text = title self.title_text = title
self.command_generator = command_generator self.command_generator = command_generator
self.on_complete = on_complete self.on_complete = on_complete
self._output_text: str = "" self._output_lines: list[str] = []
self._layer_line_map: dict[str, int] = {} # Maps layer ID to line index
self._status_task: Optional[asyncio.Task] = None self._status_task: Optional[asyncio.Task] = None
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
"""Create the modal dialog layout.""" """Create the modal dialog layout."""
with Container(id="dialog"): with Container(id="dialog"):
yield Label(self.title_text, id="title") yield Label(self.title_text, id="title")
with ScrollableContainer(id="output-container"): yield TextArea(
yield TextArea( text="",
text="", read_only=True,
read_only=True, show_line_numbers=False,
show_line_numbers=False, id="command-output",
id="command-output", )
)
with Container(id="button-row"): with Container(id="button-row"):
yield Button("Copy Output", variant="default", id="copy-btn") yield Button("Copy Output", variant="default", id="copy-btn")
yield Button( yield Button(
@ -116,11 +106,6 @@ class CommandOutputModal(ModalScreen):
"""Start the command when the modal is mounted.""" """Start the command when the modal is mounted."""
# Start the command but don't store the worker # Start the command but don't store the worker
self.run_worker(self._run_command(), exclusive=False) self.run_worker(self._run_command(), exclusive=False)
# Focus the output so users can select text immediately
try:
self.query_one("#command-output", TextArea).focus()
except Exception:
pass
def on_unmount(self) -> None: def on_unmount(self) -> None:
"""Cancel any pending timers when modal closes.""" """Cancel any pending timers when modal closes."""
@ -138,19 +123,28 @@ class CommandOutputModal(ModalScreen):
async def _run_command(self) -> None: async def _run_command(self) -> None:
"""Run the command and update the output in real-time.""" """Run the command and update the output in real-time."""
output = self.query_one("#command-output", TextArea) output = self.query_one("#command-output", TextArea)
container = self.query_one("#output-container", ScrollableContainer)
try: try:
async for is_complete, message in self.command_generator: async for result in self.command_generator:
self._append_output(message) # Handle both (is_complete, message) and (is_complete, message, replace_last) tuples
output.text = self._output_text if len(result) == 2:
container.scroll_end(animate=False) is_complete, message = result
replace_last = False
else:
is_complete, message, replace_last = result
self._update_output(message, replace_last)
output.text = "\n".join(self._output_lines)
# Move cursor to end to trigger scroll
output.move_cursor((len(self._output_lines), 0))
# If command is complete, update UI # If command is complete, update UI
if is_complete: if is_complete:
self._append_output("Command completed successfully") self._update_output("Command completed successfully", False)
output.text = self._output_text output.text = "\n".join(self._output_lines)
container.scroll_end(animate=False) output.move_cursor((len(self._output_lines), 0))
# Call the completion callback if provided # Call the completion callback if provided
if self.on_complete: if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX await asyncio.sleep(0.5) # Small delay for better UX
@ -162,30 +156,62 @@ class CommandOutputModal(ModalScreen):
self.call_after_refresh(_invoke_callback) self.call_after_refresh(_invoke_callback)
except Exception as e: except Exception as e:
self._append_output(f"Error: {e}") self._update_output(f"Error: {e}", False)
output.text = self._output_text output.text = "\n".join(self._output_lines)
container.scroll_end(animate=False) output.move_cursor((len(self._output_lines), 0))
finally: finally:
# Enable the close button and focus it # Enable the close button and focus it
close_btn = self.query_one("#close-btn", Button) close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False close_btn.disabled = False
close_btn.focus() close_btn.focus()
def _append_output(self, message: str) -> None: def _update_output(self, message: str, replace_last: bool = False) -> None:
"""Append a message to the output buffer.""" """Update the output buffer by appending or replacing the last line.
Args:
message: The message to add or use as replacement
replace_last: If True, replace the last line (or layer-specific line); if False, append new line
"""
if message is None: if message is None:
return return
message = message.rstrip("\n") message = message.rstrip("\n")
if not message: if not message:
return return
if self._output_text:
self._output_text += "\n" + message # Always check if this is a layer update (regardless of replace_last flag)
parts = message.split(None, 1)
if parts:
potential_layer_id = parts[0]
# Check if this looks like a layer ID (hex string, 12 chars for Docker layers)
if len(potential_layer_id) == 12 and all(c in '0123456789abcdefABCDEF' for c in potential_layer_id):
# This is a layer message
if potential_layer_id in self._layer_line_map:
# Update the existing line for this layer
line_idx = self._layer_line_map[potential_layer_id]
if 0 <= line_idx < len(self._output_lines):
self._output_lines[line_idx] = message
return
else:
# New layer, add it and track the line index
self._layer_line_map[potential_layer_id] = len(self._output_lines)
self._output_lines.append(message)
return
# Not a layer message, handle normally
if replace_last:
# Fallback: just replace the last line
if self._output_lines:
self._output_lines[-1] = message
else:
self._output_lines.append(message)
else: else:
self._output_text = message # Append as a new line
self._output_lines.append(message)
def copy_to_clipboard(self) -> None: def copy_to_clipboard(self) -> None:
"""Copy the modal output to the clipboard.""" """Copy the modal output to the clipboard."""
if not self._output_text: if not self._output_lines:
message = "No output to copy yet" message = "No output to copy yet"
self.notify(message, severity="warning") self.notify(message, severity="warning")
status = self.query_one("#copy-status", Static) status = self.query_one("#copy-status", Static)
@ -193,7 +219,8 @@ class CommandOutputModal(ModalScreen):
self._schedule_status_clear(status) self._schedule_status_clear(status)
return return
success, message = copy_text_to_clipboard(self._output_text) output_text = "\n".join(self._output_lines)
success, message = copy_text_to_clipboard(output_text)
self.notify(message, severity="information" if success else "error") self.notify(message, severity="information" if success else "error")
status = self.query_one("#copy-status", Static) status = self.query_one("#copy-status", Static)
style = "bold green" if success else "bold red" style = "bold green" if success else "bold red"