Merge pull request #594 from langflow-ai/factory-reset-fixes

Factory reset fixes
This commit is contained in:
Sebastián Estévez 2025-12-03 13:35:06 -05:00 committed by GitHub
commit 05f5afc4b8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 161 additions and 26 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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