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.
This commit is contained in:
parent
f3ab58853f
commit
0ea86871d7
3 changed files with 421 additions and 1 deletions
|
|
@ -1208,3 +1208,279 @@ class ContainerManager:
|
||||||
self.platform_detector.check_podman_macos_memory()
|
self.platform_detector.check_podman_macos_memory()
|
||||||
)
|
)
|
||||||
return is_sufficient, message
|
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())
|
self.run_worker(self._upgrade_services())
|
||||||
elif button_id.startswith("reset-btn"):
|
elif button_id.startswith("reset-btn"):
|
||||||
self.run_worker(self._reset_services())
|
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"):
|
elif button_id.startswith("docling-start-btn"):
|
||||||
self.run_worker(self._start_docling_serve())
|
self.run_worker(self._start_docling_serve())
|
||||||
elif button_id.startswith("docling-stop-btn"):
|
elif button_id.startswith("docling-stop-btn"):
|
||||||
|
|
@ -548,6 +550,39 @@ class MonitorScreen(Screen):
|
||||||
|
|
||||||
yield True, "Factory reset completed successfully"
|
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:
|
def _check_flow_backups(self) -> bool:
|
||||||
"""Check if there are any flow backups in ./flows/backup directory."""
|
"""Check if there are any flow backups in ./flows/backup directory."""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
@ -839,10 +874,13 @@ class MonitorScreen(Screen):
|
||||||
Button("Start Services", variant="success", id=f"start-btn{suffix}")
|
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(
|
controls.mount(
|
||||||
Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}")
|
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}"))
|
controls.mount(Button("Factory Reset", variant="error", id=f"reset-btn{suffix}"))
|
||||||
|
|
||||||
except Exception as e:
|
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