Merge pull request #633 from langflow-ai/prune-image-tui

feat: Add image pruning options to TUI
This commit is contained in:
Sebastián Estévez 2025-12-19 17:09:03 -05:00 committed by GitHub
commit 6dcfa4f660
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 421 additions and 1 deletions

View file

@ -1338,3 +1338,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 <none> tags (dangling images will be handled separately)
if "<none>" 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 <none> tags (will be handled by prune)
if "<none>" 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"

View file

@ -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"):
@ -575,6 +577,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
@ -871,10 +906,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:

View file

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