back to main, progress improvements for docker pull
This commit is contained in:
parent
682fc08c01
commit
ad572a7b23
4 changed files with 189 additions and 102 deletions
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue