Merge pull request #633 from langflow-ai/prune-image-tui
feat: Add image pruning options to TUI
This commit is contained in:
commit
6dcfa4f660
3 changed files with 421 additions and 1 deletions
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
106
src/tui/widgets/prune_options_modal.py
Normal file
106
src/tui/widgets/prune_options_modal.py
Normal 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
|
||||
Loading…
Add table
Reference in a new issue