From 0ea86871d72fa3f8c22ea404ffdcb147acec4f38 Mon Sep 17 00:00:00 2001 From: Edwin Jose Date: Mon, 8 Dec 2025 15:54:10 -0500 Subject: [PATCH] Add image pruning options to monitor screen Introduces two async methods to ContainerManager for pruning OpenRAG images: one for removing old/unused images and another for aggressively removing all images after stopping services. Adds a modal dialog for users to select prune options, integrates the prune workflow into the monitor screen, and updates UI controls to include a 'Prune Images' button. --- src/tui/managers/container_manager.py | 276 +++++++++++++++++++++++++ src/tui/screens/monitor.py | 40 +++- src/tui/widgets/prune_options_modal.py | 106 ++++++++++ 3 files changed, 421 insertions(+), 1 deletion(-) create mode 100644 src/tui/widgets/prune_options_modal.py diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index 666d66bb..75d16ed0 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -1208,3 +1208,279 @@ class ContainerManager: self.platform_detector.check_podman_macos_memory() ) return is_sufficient, message + + async def prune_old_images(self) -> AsyncIterator[tuple[bool, str]]: + """Prune old OpenRAG images and dependencies, keeping only the latest versions. + + This method: + 1. Lists all images + 2. Identifies OpenRAG-related images (openrag-backend, openrag-frontend, langflow, opensearch, dashboards) + 3. For each repository, keeps only the latest/currently used image + 4. Removes old images + 5. Prunes dangling images + + Yields: + Tuples of (success, message) for progress updates + """ + if not self.is_available(): + yield False, "No container runtime available" + return + + yield False, "Scanning for OpenRAG images..." + + # Get list of all images + success, stdout, stderr = await self._run_runtime_command( + ["images", "--format", "{{.Repository}}:{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}"] + ) + + if not success: + yield False, f"Failed to list images: {stderr}" + return + + # Parse images and group by repository + openrag_repos = { + "langflowai/openrag-backend", + "langflowai/openrag-frontend", + "langflowai/openrag-langflow", + "langflowai/openrag-opensearch", + "langflowai/openrag-dashboards", + "langflow/langflow", # Also include base langflow images + "opensearchproject/opensearch", + "opensearchproject/opensearch-dashboards", + } + + images_by_repo = {} + for line in stdout.strip().split("\n"): + if not line.strip(): + continue + + parts = line.split("\t") + if len(parts) < 3: + continue + + image_tag, image_id, created_at = parts[0], parts[1], parts[2] + + # Skip tags (dangling images will be handled separately) + if "" in image_tag: + continue + + # Extract repository name (without tag) + if ":" in image_tag: + repo = image_tag.rsplit(":", 1)[0] + else: + repo = image_tag + + # Check if this is an OpenRAG-related image + if any(openrag_repo in repo for openrag_repo in openrag_repos): + if repo not in images_by_repo: + images_by_repo[repo] = [] + images_by_repo[repo].append({ + "full_tag": image_tag, + "id": image_id, + "created": created_at, + }) + + if not images_by_repo: + yield True, "No OpenRAG images found to prune" + # Still run dangling image cleanup + yield False, "Cleaning up dangling images..." + success, stdout, stderr = await self._run_runtime_command( + ["image", "prune", "-f"] + ) + if success: + yield True, "Dangling images cleaned up" + else: + yield False, f"Failed to prune dangling images: {stderr}" + return + + # Get currently used images (from running/stopped containers) + services = await self.get_service_status(force_refresh=True) + current_images = set() + for service_info in services.values(): + if service_info.image and service_info.image != "N/A": + current_images.add(service_info.image) + + yield False, f"Found {len(images_by_repo)} OpenRAG image repositories" + + # For each repository, remove old images (keep latest and currently used) + total_removed = 0 + for repo, images in images_by_repo.items(): + if len(images) <= 1: + # Only one image for this repo, skip + continue + + # Sort by creation date (newest first) + # Note: This is a simple string comparison which works for ISO dates + images.sort(key=lambda x: x["created"], reverse=True) + + # Keep the newest image and any currently used images + images_to_remove = [] + for i, img in enumerate(images): + # Keep the first (newest) image + if i == 0: + continue + # Keep currently used images + if img["full_tag"] in current_images: + continue + # Mark for removal + images_to_remove.append(img) + + if not images_to_remove: + yield False, f"No old images to remove for {repo}" + continue + + # Remove old images + for img in images_to_remove: + yield False, f"Removing old image: {img['full_tag']}" + success, stdout, stderr = await self._run_runtime_command( + ["rmi", img["id"]] + ) + if success: + total_removed += 1 + yield False, f" ✓ Removed {img['full_tag']}" + else: + # Don't fail the whole operation if one image fails + # (might be in use by another container) + yield False, f" ⚠ Could not remove {img['full_tag']}: {stderr.strip()}" + + if total_removed > 0: + yield True, f"Removed {total_removed} old image(s)" + else: + yield True, "No old images were removed" + + # Clean up dangling images (untagged images) + yield False, "Cleaning up dangling images..." + success, stdout, stderr = await self._run_runtime_command( + ["image", "prune", "-f"] + ) + + if success: + # Parse output to see if anything was removed + if stdout.strip(): + yield True, f"Dangling images cleaned: {stdout.strip()}" + else: + yield True, "No dangling images to clean" + else: + yield False, f"Failed to prune dangling images: {stderr}" + + yield True, "Image pruning completed" + + async def prune_all_images(self) -> AsyncIterator[tuple[bool, str]]: + """Stop services and prune ALL OpenRAG images and dependencies. + + This is a more aggressive pruning that: + 1. Stops all running services + 2. Removes ALL OpenRAG-related images (not just old versions) + 3. Prunes dangling images + + This frees up maximum disk space but requires re-downloading images on next start. + + Yields: + Tuples of (success, message) for progress updates + """ + if not self.is_available(): + yield False, "No container runtime available" + return + + # Step 1: Stop all services first + yield False, "Stopping all services..." + async for success, message in self.stop_services(): + yield success, message + if not success and "failed" in message.lower(): + yield False, "Failed to stop services, aborting prune" + return + + # Give services time to fully stop + import asyncio + await asyncio.sleep(2) + + yield False, "Scanning for OpenRAG images..." + + # Get list of all images + success, stdout, stderr = await self._run_runtime_command( + ["images", "--format", "{{.Repository}}:{{.Tag}}\t{{.ID}}"] + ) + + if not success: + yield False, f"Failed to list images: {stderr}" + return + + # Parse images and identify ALL OpenRAG-related images + openrag_repos = { + "langflowai/openrag-backend", + "langflowai/openrag-frontend", + "langflowai/openrag-langflow", + "langflowai/openrag-opensearch", + "langflowai/openrag-dashboards", + "langflow/langflow", + "opensearchproject/opensearch", + "opensearchproject/opensearch-dashboards", + } + + images_to_remove = [] + for line in stdout.strip().split("\n"): + if not line.strip(): + continue + + parts = line.split("\t") + if len(parts) < 2: + continue + + image_tag, image_id = parts[0], parts[1] + + # Skip tags (will be handled by prune) + if "" in image_tag: + continue + + # Extract repository name (without tag) + if ":" in image_tag: + repo = image_tag.rsplit(":", 1)[0] + else: + repo = image_tag + + # Check if this is an OpenRAG-related image + if any(openrag_repo in repo for openrag_repo in openrag_repos): + images_to_remove.append({ + "full_tag": image_tag, + "id": image_id, + }) + + if not images_to_remove: + yield True, "No OpenRAG images found to remove" + else: + yield False, f"Found {len(images_to_remove)} OpenRAG image(s) to remove" + + # Remove all OpenRAG images + total_removed = 0 + for img in images_to_remove: + yield False, f"Removing image: {img['full_tag']}" + success, stdout, stderr = await self._run_runtime_command( + ["rmi", "-f", img["id"]] # Force remove + ) + if success: + total_removed += 1 + yield False, f" ✓ Removed {img['full_tag']}" + else: + yield False, f" ⚠ Could not remove {img['full_tag']}: {stderr.strip()}" + + if total_removed > 0: + yield True, f"Removed {total_removed} OpenRAG image(s)" + else: + yield False, "No images were removed" + + # Clean up dangling images + yield False, "Cleaning up dangling images..." + success, stdout, stderr = await self._run_runtime_command( + ["image", "prune", "-f"] + ) + + if success: + if stdout.strip(): + yield True, f"Dangling images cleaned: {stdout.strip()}" + else: + yield True, "No dangling images to clean" + else: + yield False, f"Failed to prune dangling images: {stderr}" + + yield True, "All OpenRAG images removed successfully" + diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 7c5e0203..3525e898 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -296,6 +296,8 @@ class MonitorScreen(Screen): self.run_worker(self._upgrade_services()) elif button_id.startswith("reset-btn"): self.run_worker(self._reset_services()) + elif button_id.startswith("prune-btn"): + self.run_worker(self._prune_images()) elif button_id.startswith("docling-start-btn"): self.run_worker(self._start_docling_serve()) elif button_id.startswith("docling-stop-btn"): @@ -548,6 +550,39 @@ class MonitorScreen(Screen): yield True, "Factory reset completed successfully" + async def _prune_images(self) -> None: + """Prune old OpenRAG images with progress updates.""" + self.operation_in_progress = True + try: + # Show prune options modal + from tui.widgets.prune_options_modal import PruneOptionsModal + + prune_choice = await self.app.push_screen_wait(PruneOptionsModal()) + + if prune_choice == "cancel": + self.notify("Prune cancelled", severity="information") + return + + # Choose the appropriate pruning method based on user choice + if prune_choice == "all": + # Stop services and prune all images + command_generator = self.container_manager.prune_all_images() + modal_title = "Stopping Services & Pruning All Images" + else: + # Prune only unused images (default) + command_generator = self.container_manager.prune_old_images() + modal_title = "Pruning Unused Images" + + # Show command output in modal dialog + modal = CommandOutputModal( + modal_title, + command_generator, + on_complete=None, # We'll refresh in on_screen_resume instead + ) + self.app.push_screen(modal) + finally: + self.operation_in_progress = False + def _check_flow_backups(self) -> bool: """Check if there are any flow backups in ./flows/backup directory.""" from pathlib import Path @@ -839,10 +874,13 @@ class MonitorScreen(Screen): Button("Start Services", variant="success", id=f"start-btn{suffix}") ) - # Always show upgrade and reset buttons + # Always show upgrade, prune, and reset buttons controls.mount( Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}") ) + controls.mount( + Button("Prune Images", variant="default", id=f"prune-btn{suffix}") + ) controls.mount(Button("Factory Reset", variant="error", id=f"reset-btn{suffix}")) except Exception as e: diff --git a/src/tui/widgets/prune_options_modal.py b/src/tui/widgets/prune_options_modal.py new file mode 100644 index 00000000..ef8839f4 --- /dev/null +++ b/src/tui/widgets/prune_options_modal.py @@ -0,0 +1,106 @@ +"""Prune options modal for OpenRAG TUI.""" + +from textual.app import ComposeResult +from textual.containers import Container, Horizontal +from textual.screen import ModalScreen +from textual.widgets import Button, Static, Label + + +class PruneOptionsModal(ModalScreen[str]): + """Modal dialog to choose prune options.""" + + DEFAULT_CSS = """ + PruneOptionsModal { + align: center middle; + } + + #dialog { + width: 70; + height: auto; + border: solid #3f3f46; + background: #27272a; + padding: 0; + } + + #title { + background: #ec4899; + color: #fafafa; + padding: 1 2; + text-align: center; + width: 100%; + text-style: bold; + } + + #message { + padding: 2; + color: #fafafa; + text-align: left; + } + + #button-row { + width: 100%; + height: auto; + align: center middle; + padding: 1; + margin-top: 1; + } + + #button-row Button { + margin: 0 1; + min-width: 20; + background: #27272a; + color: #fafafa; + border: round #52525b; + text-style: none; + tint: transparent 0%; + } + + #button-row Button:hover { + background: #27272a !important; + color: #fafafa !important; + border: round #52525b; + tint: transparent 0%; + text-style: none; + } + + #button-row Button:focus { + background: #27272a !important; + color: #fafafa !important; + border: round #ec4899; + tint: transparent 0%; + text-style: none; + } + """ + + def compose(self) -> ComposeResult: + """Create the modal dialog layout.""" + with Container(id="dialog"): + yield Label("🗑️ Prune Images", id="title") + yield Static( + "Choose how to prune OpenRAG images:\n\n" + "• Prune Unused Only\n" + " Remove old versions, keep latest and currently used images\n" + " (Services will continue running)\n\n" + "• Stop & Prune All\n" + " Stop all services and remove ALL OpenRAG images\n" + " (Frees maximum disk space, images will be re-downloaded on next start)\n\n" + "What would you like to do?", + id="message", + ) + with Horizontal(id="button-row"): + yield Button("Cancel", id="cancel-btn") + yield Button("Prune Unused Only", id="prune-unused-btn", variant="primary") + yield Button("Stop & Prune All", id="prune-all-btn", variant="warning") + + def on_mount(self) -> None: + """Focus the prune unused button by default.""" + self.query_one("#prune-unused-btn", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + if event.button.id == "prune-unused-btn": + self.dismiss("unused") # Prune only unused images + elif event.button.id == "prune-all-btn": + self.dismiss("all") # Stop services and prune all + else: + self.dismiss("cancel") # User cancelled