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(
self, args: List[str], cpu_mode: Optional[bool] = None
) -> AsyncIterator[str]:
"""Run a compose command and yield output lines in real-time."""
) -> AsyncIterator[tuple[str, bool]]:
"""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():
yield "No container runtime available"
yield ("No container runtime available", False)
return
if cpu_mode is None:
@ -179,37 +184,58 @@ class ContainerManager:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT, # Combine stderr with stdout for unified output
stderr=asyncio.subprocess.STDOUT,
cwd=Path.cwd(),
)
# Simple approach: read line by line and yield each one
if process.stdout:
buffer = ""
while True:
line = await process.stdout.readline()
if not line:
chunk = await process.stdout.read(1024)
if not chunk:
if buffer.strip():
yield (buffer.strip(), False)
break
line_text = line.decode(errors="ignore").rstrip()
if line_text:
yield line_text
buffer += chunk.decode(errors="ignore")
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()
except Exception as e:
yield f"Command execution failed: {e}"
yield (f"Command execution failed: {e}", False)
async def _stream_compose_command(
self,
args: List[str],
success_flag: Dict[str, bool],
cpu_mode: Optional[bool] = None,
) -> AsyncIterator[str]:
"""Run compose command with live output and record success/failure."""
) -> AsyncIterator[tuple[str, bool]]:
"""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():
success_flag["value"] = False
yield "No container runtime available"
yield ("No container runtime available", False)
return
if cpu_mode is None:
@ -226,32 +252,52 @@ class ContainerManager:
)
except Exception as e:
success_flag["value"] = False
yield f"Command execution failed: {e}"
yield (f"Command execution failed: {e}", False)
return
success_flag["value"] = True
if process.stdout:
# Buffer to accumulate data for progress bar handling
buffer = ""
while True:
line = await process.stdout.readline()
if not line:
chunk = await process.stdout.read(1024)
if not chunk:
# Process any remaining buffer content
if buffer.strip():
yield (buffer.strip(), False)
break
line_text = line.decode(errors="ignore")
# Compose often uses carriage returns for progress bars; normalise them
for chunk in line_text.replace("\r", "\n").split("\n"):
chunk = chunk.strip()
if not chunk:
continue
yield chunk
lowered = chunk.lower()
if "error" in lowered or "failed" in lowered:
success_flag["value"] = False
buffer += chunk.decode(errors="ignore")
# Process complete lines or carriage return updates
while "\n" in buffer or "\r" in buffer:
# Check if we have a carriage return (progress update) before newline
cr_pos = buffer.find("\r")
nl_pos = buffer.find("\n")
if cr_pos != -1 and (nl_pos == -1 or cr_pos < nl_pos):
# 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()
if returncode != 0:
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]:
"""Run a runtime command (docker/podman) and return (success, stdout, stderr)."""
@ -516,14 +562,14 @@ class ContainerManager:
if hasattr(self, '_compose_search_log'):
for line in self._compose_search_log.split('\n'):
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():
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
yield False, "Starting OpenRAG services..."
yield False, "Starting OpenRAG services...", False
missing_images: List[str] = []
try:
@ -534,24 +580,24 @@ class ContainerManager:
if 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}
async for line in self._stream_compose_command(
async for message, replace_last in self._stream_compose_command(
["pull"], pull_success, cpu_mode
):
yield False, line
yield False, message, replace_last
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}
async for line in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, line
async for message, replace_last in self._stream_compose_command(["up", "-d"], up_success, cpu_mode):
yield False, message, replace_last
if up_success["value"]:
yield True, "Services started successfully"
yield True, "Services started successfully", False
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]]:
"""Stop all services and yield progress updates."""
@ -581,35 +627,35 @@ class ContainerManager:
self, cpu_mode: bool = False
) -> AsyncIterator[tuple[bool, str]]:
"""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_success = True
async for line in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, line
async for message, replace_last in self._run_compose_command_streaming(["pull"], cpu_mode):
yield False, message, replace_last
# 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
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_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
):
yield False, line
yield False, message, replace_last
# 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
if restart_success:
yield True, "Services upgraded and restarted successfully"
yield True, "Services upgraded and restarted successfully", False
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]]:
"""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
if self.env_manager.save_env_file():
self.notify("Configuration saved successfully!", severity="information")
# Switch to monitor screen
from .monitor import MonitorScreen
self.app.push_screen(MonitorScreen())
# Go back to welcome screen
self.dismiss()
else:
self.notify("Failed to save configuration", severity="error")

View file

@ -237,6 +237,22 @@ class WelcomeScreen(Screen):
except:
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:
"""Handle button presses."""
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 textual.app import ComposeResult
from textual.containers import Container, ScrollableContainer
from textual.containers import Container
from textual.screen import ModalScreen
from textual.widgets import Button, Static, Label, TextArea
@ -38,23 +38,13 @@ class CommandOutputModal(ModalScreen):
text-style: bold;
}
#output-container {
height: 1fr;
padding: 0;
margin: 0 1;
}
#command-output {
height: 100%;
height: 1fr;
border: solid $accent;
margin: 1 0;
margin: 1;
background: $surface-darken-1;
}
#command-output > .text-area--content {
padding: 1 2;
}
#button-row {
width: 100%;
height: auto;
@ -84,27 +74,27 @@ class CommandOutputModal(ModalScreen):
Args:
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
"""
super().__init__()
self.title_text = title
self.command_generator = command_generator
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
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 TextArea(
text="",
read_only=True,
show_line_numbers=False,
id="command-output",
)
yield TextArea(
text="",
read_only=True,
show_line_numbers=False,
id="command-output",
)
with Container(id="button-row"):
yield Button("Copy Output", variant="default", id="copy-btn")
yield Button(
@ -116,11 +106,6 @@ class CommandOutputModal(ModalScreen):
"""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)
# 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:
"""Cancel any pending timers when modal closes."""
@ -138,19 +123,28 @@ 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", TextArea)
container = self.query_one("#output-container", ScrollableContainer)
try:
async for is_complete, message in self.command_generator:
self._append_output(message)
output.text = self._output_text
container.scroll_end(animate=False)
async for result in self.command_generator:
# Handle both (is_complete, message) and (is_complete, message, replace_last) tuples
if len(result) == 2:
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 is_complete:
self._append_output("Command completed successfully")
output.text = self._output_text
container.scroll_end(animate=False)
self._update_output("Command completed successfully", False)
output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0))
# Call the completion callback if provided
if self.on_complete:
await asyncio.sleep(0.5) # Small delay for better UX
@ -162,30 +156,62 @@ class CommandOutputModal(ModalScreen):
self.call_after_refresh(_invoke_callback)
except Exception as e:
self._append_output(f"Error: {e}")
output.text = self._output_text
container.scroll_end(animate=False)
self._update_output(f"Error: {e}", False)
output.text = "\n".join(self._output_lines)
output.move_cursor((len(self._output_lines), 0))
finally:
# Enable the close button and focus it
close_btn = self.query_one("#close-btn", Button)
close_btn.disabled = False
close_btn.focus()
def _append_output(self, message: str) -> None:
"""Append a message to the output buffer."""
def _update_output(self, message: str, replace_last: bool = False) -> None:
"""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:
return
message = message.rstrip("\n")
if not message:
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:
self._output_text = message
# Append as a new line
self._output_lines.append(message)
def copy_to_clipboard(self) -> None:
"""Copy the modal output to the clipboard."""
if not self._output_text:
if not self._output_lines:
message = "No output to copy yet"
self.notify(message, severity="warning")
status = self.query_one("#copy-status", Static)
@ -193,7 +219,8 @@ class CommandOutputModal(ModalScreen):
self._schedule_status_clear(status)
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")
status = self.query_one("#copy-status", Static)
style = "bold green" if success else "bold red"