Merge pull request #594 from langflow-ai/factory-reset-fixes
Factory reset fixes
This commit is contained in:
commit
05f5afc4b8
7 changed files with 161 additions and 26 deletions
4
Makefile
4
Makefile
|
|
@ -376,6 +376,10 @@ db-reset:
|
||||||
curl -X DELETE "http://localhost:9200/knowledge_filters" -u admin:$${OPENSEARCH_PASSWORD} || true
|
curl -X DELETE "http://localhost:9200/knowledge_filters" -u admin:$${OPENSEARCH_PASSWORD} || true
|
||||||
@echo "Indices reset. Restart backend to recreate."
|
@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 management
|
||||||
flow-upload:
|
flow-upload:
|
||||||
@echo "📁 Uploading flow to Langflow..."
|
@echo "📁 Uploading flow to Langflow..."
|
||||||
|
|
|
||||||
36
scripts/clear_opensearch_data.py
Normal file
36
scripts/clear_opensearch_data.py
Normal 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)
|
||||||
|
|
@ -1012,6 +1012,54 @@ class ContainerManager:
|
||||||
else:
|
else:
|
||||||
yield False, "Some errors occurred during service restart", False
|
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]]:
|
async def reset_services(self) -> AsyncIterator[tuple[bool, str]]:
|
||||||
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""
|
"""Reset all services (stop, remove containers/volumes, clear data) and yield progress updates."""
|
||||||
yield False, "Stopping all services..."
|
yield False, "Stopping all services..."
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import asyncio
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Literal, Any, Optional
|
from typing import Literal, Any, Optional, AsyncIterator
|
||||||
|
|
||||||
# Define button variant type
|
# Define button variant type
|
||||||
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
|
ButtonVariant = Literal["default", "primary", "success", "warning", "error"]
|
||||||
|
|
@ -469,44 +469,48 @@ class MonitorScreen(Screen):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Check for flow backups before resetting
|
# Check for flow backups before resetting
|
||||||
|
delete_backups = False
|
||||||
if self._check_flow_backups():
|
if self._check_flow_backups():
|
||||||
# Show warning modal and wait for user decision
|
# 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")
|
FlowBackupWarningModal(operation="reset")
|
||||||
)
|
)
|
||||||
if not should_continue:
|
if not should_continue:
|
||||||
self.notify("Factory reset cancelled", severity="information")
|
self.notify("Factory reset cancelled", severity="information")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Clear config, opensearch-data folders, and conversations.json
|
# Clear config, conversations.json, and optionally flow backups (before stopping containers)
|
||||||
try:
|
try:
|
||||||
config_path = Path("config")
|
config_path = Path("config")
|
||||||
opensearch_data_path = Path("opensearch-data")
|
|
||||||
conversations_file = Path("conversations.json")
|
conversations_file = Path("conversations.json")
|
||||||
|
flows_backup_path = Path("flows/backup")
|
||||||
|
|
||||||
if config_path.exists():
|
if config_path.exists():
|
||||||
shutil.rmtree(config_path)
|
shutil.rmtree(config_path)
|
||||||
# Recreate empty config directory
|
# Recreate empty config directory
|
||||||
config_path.mkdir(parents=True, exist_ok=True)
|
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():
|
if conversations_file.exists():
|
||||||
conversations_file.unlink()
|
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:
|
except Exception as e:
|
||||||
self.notify(
|
self.notify(
|
||||||
f"Error clearing folders: {str(e)}",
|
f"Error clearing config: {str(e)}",
|
||||||
severity="error",
|
severity="error",
|
||||||
timeout=10,
|
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Show command output in modal dialog
|
# Show command output in modal dialog for stopping services and clearing data
|
||||||
command_generator = self.container_manager.reset_services()
|
command_generator = self._factory_reset_with_data_clear()
|
||||||
modal = CommandOutputModal(
|
modal = CommandOutputModal(
|
||||||
"Factory Resetting Services",
|
"Factory Resetting Services",
|
||||||
command_generator,
|
command_generator,
|
||||||
|
|
@ -516,6 +520,33 @@ class MonitorScreen(Screen):
|
||||||
finally:
|
finally:
|
||||||
self.operation_in_progress = False
|
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:
|
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
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ def notify_with_diagnostics(
|
||||||
app: App,
|
app: App,
|
||||||
message: str,
|
message: str,
|
||||||
severity: Literal["information", "warning", "error"] = "error",
|
severity: Literal["information", "warning", "error"] = "error",
|
||||||
timeout: float = 10.0,
|
timeout: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show a notification with a button to open the diagnostics screen.
|
"""Show a notification with a button to open the diagnostics screen.
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ def notify_with_diagnostics(
|
||||||
app: The Textual app
|
app: The Textual app
|
||||||
message: The notification message
|
message: The notification message
|
||||||
severity: The notification severity
|
severity: The notification severity
|
||||||
timeout: The notification timeout in seconds
|
timeout: The notification timeout in seconds (None for default 20s)
|
||||||
"""
|
"""
|
||||||
# First show the notification
|
# First show the notification
|
||||||
app.notify(message, severity=severity, timeout=timeout)
|
app.notify(message, severity=severity, timeout=timeout)
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ def notify_with_diagnostics(
|
||||||
app: App,
|
app: App,
|
||||||
message: str,
|
message: str,
|
||||||
severity: Literal["information", "warning", "error"] = "error",
|
severity: Literal["information", "warning", "error"] = "error",
|
||||||
timeout: float = 10.0,
|
timeout: float | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show a notification with a button to open the diagnostics screen.
|
"""Show a notification with a button to open the diagnostics screen.
|
||||||
|
|
||||||
|
|
@ -17,7 +17,7 @@ def notify_with_diagnostics(
|
||||||
app: The Textual app
|
app: The Textual app
|
||||||
message: The notification message
|
message: The notification message
|
||||||
severity: The notification severity
|
severity: The notification severity
|
||||||
timeout: The notification timeout in seconds
|
timeout: The notification timeout in seconds (None for default 20s)
|
||||||
"""
|
"""
|
||||||
# First show the notification
|
# First show the notification
|
||||||
app.notify(message, severity=severity, timeout=timeout)
|
app.notify(message, severity=severity, timeout=timeout)
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
"""Flow backup warning modal for OpenRAG TUI."""
|
"""Flow backup warning modal for OpenRAG TUI."""
|
||||||
|
|
||||||
from textual.app import ComposeResult
|
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.screen import ModalScreen
|
||||||
from textual.widgets import Button, Static, Label
|
from textual.widgets import Button, Static, Label, Checkbox
|
||||||
|
|
||||||
|
|
||||||
class FlowBackupWarningModal(ModalScreen[bool]):
|
class FlowBackupWarningModal(ModalScreen[tuple[bool, bool]]):
|
||||||
"""Modal dialog to warn about flow backups before upgrade/reset."""
|
"""Modal dialog to warn about flow backups before upgrade/reset.
|
||||||
|
|
||||||
|
Returns tuple of (continue, delete_backups)
|
||||||
|
"""
|
||||||
|
|
||||||
DEFAULT_CSS = """
|
DEFAULT_CSS = """
|
||||||
FlowBackupWarningModal {
|
FlowBackupWarningModal {
|
||||||
|
|
@ -37,6 +40,17 @@ class FlowBackupWarningModal(ModalScreen[bool]):
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#checkbox-container {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
align: center middle;
|
||||||
|
padding: 0 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
#delete-backups-checkbox {
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
#button-row {
|
#button-row {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
@ -88,11 +102,12 @@ class FlowBackupWarningModal(ModalScreen[bool]):
|
||||||
yield Static(
|
yield Static(
|
||||||
f"Flow backups found in ./flows/backup\n\n"
|
f"Flow backups found in ./flows/backup\n\n"
|
||||||
f"Proceeding with {self.operation} will reset custom flows to defaults.\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"Your customizations are backed up in ./flows/backup/\n\n"
|
||||||
f"manually imported and upgraded to work with the latest version.\n\n"
|
f"Choose whether to keep or delete the backup files:",
|
||||||
f"Do you want to continue?",
|
|
||||||
id="message"
|
id="message"
|
||||||
)
|
)
|
||||||
|
with Vertical(id="checkbox-container"):
|
||||||
|
yield Checkbox("Delete backup files", id="delete-backups-checkbox", value=False)
|
||||||
with Horizontal(id="button-row"):
|
with Horizontal(id="button-row"):
|
||||||
yield Button("Cancel", id="cancel-btn")
|
yield Button("Cancel", id="cancel-btn")
|
||||||
yield Button(f"Continue {self.operation.title()}", id="continue-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:
|
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||||
"""Handle button presses."""
|
"""Handle button presses."""
|
||||||
if event.button.id == "continue-btn":
|
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:
|
else:
|
||||||
self.dismiss(False) # User cancelled
|
self.dismiss((False, False)) # User cancelled
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue