diff --git a/Makefile b/Makefile index b5804f77..9da47098 100644 --- a/Makefile +++ b/Makefile @@ -376,6 +376,10 @@ db-reset: curl -X DELETE "http://localhost:9200/knowledge_filters" -u admin:$${OPENSEARCH_PASSWORD} || true @echo "Indices reset. Restart backend to recreate." +clear-os-data: + @echo "๐Ÿงน Clearing OpenSearch data directory..." + @uv run python scripts/clear_opensearch_data.py + # Flow management flow-upload: @echo "๐Ÿ“ Uploading flow to Langflow..." diff --git a/scripts/clear_opensearch_data.py b/scripts/clear_opensearch_data.py new file mode 100644 index 00000000..1fa8b9c0 --- /dev/null +++ b/scripts/clear_opensearch_data.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +"""Clear OpenSearch data directory using container with proper permissions.""" + +import asyncio +import sys +from pathlib import Path + +# Add parent directory to path to import from src +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from src.tui.managers.container_manager import ContainerManager + + +async def main(): + """Clear OpenSearch data directory.""" + cm = ContainerManager() + + opensearch_data_path = Path("opensearch-data") + if not opensearch_data_path.exists(): + print("opensearch-data directory does not exist") + return 0 + + print("Clearing OpenSearch data directory...") + + async for success, message in cm.clear_opensearch_data_volume(): + print(message) + if not success and "failed" in message.lower(): + return 1 + + print("โœ… OpenSearch data cleared successfully") + return 0 + + +if __name__ == "__main__": + exit_code = asyncio.run(main()) + sys.exit(exit_code) diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index a4103d7e..666d66bb 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -1012,6 +1012,54 @@ class ContainerManager: else: yield False, "Some errors occurred during service restart", False + async def clear_opensearch_data_volume(self) -> AsyncIterator[tuple[bool, str]]: + """Clear opensearch data using a temporary container with proper permissions.""" + if not self.is_available(): + yield False, "No container runtime available" + return + + yield False, "Clearing OpenSearch data volume..." + + # Get the absolute path to opensearch-data directory + opensearch_data_path = Path("opensearch-data").absolute() + + if not opensearch_data_path.exists(): + yield True, "OpenSearch data directory does not exist, skipping" + return + + # Use the opensearch container with proper volume mount flags + # :Z flag ensures proper SELinux labeling and UID mapping for rootless containers + cmd = [ + "run", + "--rm", + "-v", f"{opensearch_data_path}:/usr/share/opensearch/data:Z", + "langflowai/openrag-opensearch:latest", + "bash", "-c", + "rm -rf /usr/share/opensearch/data/* /usr/share/opensearch/data/.[!.]* && echo 'Cleared successfully'" + ] + + success, stdout, stderr = await self._run_runtime_command(cmd) + + if success and "Cleared successfully" in stdout: + yield True, "OpenSearch data cleared successfully" + else: + # If it fails, try with the base opensearch image + yield False, "Retrying with base OpenSearch image..." + cmd = [ + "run", + "--rm", + "-v", f"{opensearch_data_path}:/usr/share/opensearch/data:Z", + "opensearchproject/opensearch:3.0.0", + "bash", "-c", + "rm -rf /usr/share/opensearch/data/* /usr/share/opensearch/data/.[!.]* && echo 'Cleared successfully'" + ] + success, stdout, stderr = await self._run_runtime_command(cmd) + + if success and "Cleared successfully" in stdout: + yield True, "OpenSearch data cleared successfully" + else: + yield False, f"Failed to clear OpenSearch data: {stderr if stderr else 'Unknown error'}" + async def reset_services(self) -> AsyncIterator[tuple[bool, str]]: """Reset all services (stop, remove containers/volumes, clear data) and yield progress updates.""" yield False, "Stopping all services..." diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index 8b3a7dbd..12a16d59 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -4,7 +4,7 @@ import asyncio import re import shutil from pathlib import Path -from typing import Literal, Any, Optional +from typing import Literal, Any, Optional, AsyncIterator # Define button variant type ButtonVariant = Literal["default", "primary", "success", "warning", "error"] @@ -469,44 +469,48 @@ class MonitorScreen(Screen): return # Check for flow backups before resetting + delete_backups = False if self._check_flow_backups(): # Show warning modal and wait for user decision - should_continue = await self.app.push_screen_wait( + should_continue, delete_backups = await self.app.push_screen_wait( FlowBackupWarningModal(operation="reset") ) if not should_continue: self.notify("Factory reset cancelled", severity="information") return - # Clear config, opensearch-data folders, and conversations.json + # Clear config, conversations.json, and optionally flow backups (before stopping containers) try: config_path = Path("config") - opensearch_data_path = Path("opensearch-data") conversations_file = Path("conversations.json") + flows_backup_path = Path("flows/backup") if config_path.exists(): shutil.rmtree(config_path) # Recreate empty config directory config_path.mkdir(parents=True, exist_ok=True) - if opensearch_data_path.exists(): - shutil.rmtree(opensearch_data_path) - # Recreate empty opensearch-data directory - opensearch_data_path.mkdir(parents=True, exist_ok=True) - if conversations_file.exists(): conversations_file.unlink() + # Delete flow backups only if user chose to + if delete_backups and flows_backup_path.exists(): + shutil.rmtree(flows_backup_path) + # Recreate empty backup directory + flows_backup_path.mkdir(parents=True, exist_ok=True) + self.notify("Flow backups deleted", severity="information") + elif flows_backup_path.exists(): + self.notify("Flow backups preserved in ./flows/backup", severity="information") + except Exception as e: self.notify( - f"Error clearing folders: {str(e)}", + f"Error clearing config: {str(e)}", severity="error", - timeout=10, ) return - # Show command output in modal dialog - command_generator = self.container_manager.reset_services() + # Show command output in modal dialog for stopping services and clearing data + command_generator = self._factory_reset_with_data_clear() modal = CommandOutputModal( "Factory Resetting Services", command_generator, @@ -516,6 +520,33 @@ class MonitorScreen(Screen): finally: self.operation_in_progress = False + async def _factory_reset_with_data_clear(self) -> AsyncIterator[tuple[bool, str]]: + """Generator that stops services and clears opensearch data.""" + # First stop all services + async for success, message in self.container_manager.reset_services(): + yield success, message + if not success and "failed" in message.lower(): + return + + # Now clear opensearch-data using container + yield False, "Clearing OpenSearch data..." + opensearch_data_path = Path("opensearch-data") + if opensearch_data_path.exists(): + async for success, message in self.container_manager.clear_opensearch_data_volume(): + yield success, message + if not success and "failed" in message.lower(): + return + + # Recreate empty opensearch-data directory + try: + opensearch_data_path.mkdir(parents=True, exist_ok=True) + yield True, "OpenSearch data directory recreated" + except Exception as e: + yield False, f"Error recreating opensearch-data directory: {e}" + return + + yield True, "Factory reset completed successfully" + def _check_flow_backups(self) -> bool: """Check if there are any flow backups in ./flows/backup directory.""" from pathlib import Path diff --git a/src/tui/widgets/diagnostics_notification.py b/src/tui/widgets/diagnostics_notification.py index 3018ffb8..6a5f8619 100644 --- a/src/tui/widgets/diagnostics_notification.py +++ b/src/tui/widgets/diagnostics_notification.py @@ -9,7 +9,7 @@ def notify_with_diagnostics( app: App, message: str, severity: Literal["information", "warning", "error"] = "error", - timeout: float = 10.0, + timeout: float | None = None, ) -> None: """Show a notification with a button to open the diagnostics screen. @@ -17,7 +17,7 @@ def notify_with_diagnostics( app: The Textual app message: The notification message severity: The notification severity - timeout: The notification timeout in seconds + timeout: The notification timeout in seconds (None for default 20s) """ # First show the notification app.notify(message, severity=severity, timeout=timeout) diff --git a/src/tui/widgets/error_notification.py b/src/tui/widgets/error_notification.py index a06fe188..a0b0e798 100644 --- a/src/tui/widgets/error_notification.py +++ b/src/tui/widgets/error_notification.py @@ -9,7 +9,7 @@ def notify_with_diagnostics( app: App, message: str, severity: Literal["information", "warning", "error"] = "error", - timeout: float = 10.0, + timeout: float | None = None, ) -> None: """Show a notification with a button to open the diagnostics screen. @@ -17,7 +17,7 @@ def notify_with_diagnostics( app: The Textual app message: The notification message severity: The notification severity - timeout: The notification timeout in seconds + timeout: The notification timeout in seconds (None for default 20s) """ # First show the notification app.notify(message, severity=severity, timeout=timeout) diff --git a/src/tui/widgets/flow_backup_warning_modal.py b/src/tui/widgets/flow_backup_warning_modal.py index b13bf69b..5cdb3516 100644 --- a/src/tui/widgets/flow_backup_warning_modal.py +++ b/src/tui/widgets/flow_backup_warning_modal.py @@ -1,13 +1,16 @@ """Flow backup warning modal for OpenRAG TUI.""" from textual.app import ComposeResult -from textual.containers import Container, Horizontal +from textual.containers import Container, Horizontal, Vertical from textual.screen import ModalScreen -from textual.widgets import Button, Static, Label +from textual.widgets import Button, Static, Label, Checkbox -class FlowBackupWarningModal(ModalScreen[bool]): - """Modal dialog to warn about flow backups before upgrade/reset.""" +class FlowBackupWarningModal(ModalScreen[tuple[bool, bool]]): + """Modal dialog to warn about flow backups before upgrade/reset. + + Returns tuple of (continue, delete_backups) + """ DEFAULT_CSS = """ FlowBackupWarningModal { @@ -37,6 +40,17 @@ class FlowBackupWarningModal(ModalScreen[bool]): text-align: center; } + #checkbox-container { + width: 100%; + height: auto; + align: center middle; + padding: 0 2; + } + + #delete-backups-checkbox { + width: auto; + } + #button-row { width: 100%; height: auto; @@ -88,11 +102,12 @@ class FlowBackupWarningModal(ModalScreen[bool]): yield Static( f"Flow backups found in ./flows/backup\n\n" f"Proceeding with {self.operation} will reset custom flows to defaults.\n" - f"Your customizations are backed up and will need to be\n" - f"manually imported and upgraded to work with the latest version.\n\n" - f"Do you want to continue?", + f"Your customizations are backed up in ./flows/backup/\n\n" + f"Choose whether to keep or delete the backup files:", id="message" ) + with Vertical(id="checkbox-container"): + yield Checkbox("Delete backup files", id="delete-backups-checkbox", value=False) with Horizontal(id="button-row"): yield Button("Cancel", id="cancel-btn") yield Button(f"Continue {self.operation.title()}", id="continue-btn") @@ -104,6 +119,7 @@ class FlowBackupWarningModal(ModalScreen[bool]): def on_button_pressed(self, event: Button.Pressed) -> None: """Handle button presses.""" if event.button.id == "continue-btn": - self.dismiss(True) # User wants to continue + delete_backups = self.query_one("#delete-backups-checkbox", Checkbox).value + self.dismiss((True, delete_backups)) # User wants to continue, with delete preference else: - self.dismiss(False) # User cancelled + self.dismiss((False, False)) # User cancelled