diff --git a/docker-compose.yml b/docker-compose.yml index 0a284871..a0b1ca2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -101,7 +101,7 @@ services: langflow: volumes: - ./flows:/app/flows:U,z - image: langflowai/openrag-langflow:${LANGFLOW_VERSION:-latest} + image: langflowai/openrag-langflow:${OPENRAG_VERSION:-latest} build: context: . dockerfile: Dockerfile.langflow diff --git a/frontend/components/layout-wrapper.tsx b/frontend/components/layout-wrapper.tsx index e142c918..08eea73d 100644 --- a/frontend/components/layout-wrapper.tsx +++ b/frontend/components/layout-wrapper.tsx @@ -1,6 +1,7 @@ "use client"; -import { usePathname } from "next/navigation"; +import { usePathname, useRouter } from "next/navigation"; +import { useEffect } from "react"; import { useGetSettingsQuery } from "@/app/api/queries/useGetSettingsQuery"; import { DoclingHealthBanner, @@ -22,6 +23,7 @@ import { ChatRenderer } from "./chat-renderer"; export function LayoutWrapper({ children }: { children: React.ReactNode }) { const pathname = usePathname(); + const router = useRouter(); const { isMenuOpen } = useTask(); const { isPanelOpen } = useKnowledgeFilter(); const { isLoading, isAuthenticated, isNoAuthMode } = useAuth(); @@ -30,6 +32,14 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { const authPaths = ["/login", "/auth/callback"]; const isAuthPage = authPaths.includes(pathname); + // Redirect to login when not authenticated (and not in no-auth mode) + useEffect(() => { + if (!isLoading && !isAuthenticated && !isNoAuthMode && !isAuthPage) { + const redirectUrl = `/login?redirect=${encodeURIComponent(pathname)}`; + router.push(redirectUrl); + } + }, [isLoading, isAuthenticated, isNoAuthMode, isAuthPage, pathname, router]); + // Call all hooks unconditionally (React rules) // But disable queries for auth pages to prevent unnecessary requests const { data: settings, isLoading: isSettingsLoading } = useGetSettingsQuery({ @@ -49,9 +59,10 @@ export function LayoutWrapper({ children }: { children: React.ReactNode }) { const isSettingsLoadingOrError = isSettingsLoading || !settings; - // Show loading state when backend isn't ready + // Show loading state when backend isn't ready or when not authenticated (redirect will happen) if ( isLoading || + (!isAuthenticated && !isNoAuthMode) || (isSettingsLoadingOrError && (isNoAuthMode || isAuthenticated)) ) { return ( diff --git a/src/tui/__init__.py b/src/tui/__init__.py index 0437803a..89fb9665 100644 --- a/src/tui/__init__.py +++ b/src/tui/__init__.py @@ -1,8 +1,5 @@ """OpenRAG Terminal User Interface package.""" -from importlib.metadata import version +from .utils.version_check import get_current_version -try: - __version__ = version("openrag") -except Exception: - __version__ = "unknown" +__version__ = get_current_version() diff --git a/src/tui/managers/container_manager.py b/src/tui/managers/container_manager.py index 2da92322..a4103d7e 100644 --- a/src/tui/managers/container_manager.py +++ b/src/tui/managers/container_manager.py @@ -479,6 +479,100 @@ class ContainerManager: image=image, ) + async def get_container_version(self) -> Optional[str]: + """ + Get the version tag from existing containers. + Checks the backend container image tag to determine version. + + Returns: + Version string if found, None if no containers exist or version can't be determined + """ + try: + # Check for backend container first (most reliable) + success, stdout, _ = await self._run_runtime_command( + ["ps", "--all", "--filter", "name=openrag-backend", "--format", "{{.Image}}"] + ) + + if success and stdout.strip(): + image_tag = stdout.strip().splitlines()[0].strip() + if not image_tag or image_tag == "N/A": + return None + + # Extract version from image tag (e.g., langflowai/openrag-backend:0.1.47) + if ":" in image_tag: + version = image_tag.split(":")[-1] + # If version is "latest", check .env file for OPENRAG_VERSION + if version == "latest": + # Try to get version from .env file + try: + from pathlib import Path + env_file = Path(".env") + if env_file.exists(): + env_content = env_file.read_text() + for line in env_content.splitlines(): + line = line.strip() + if line.startswith("OPENRAG_VERSION"): + env_version = line.split("=", 1)[1].strip() + # Remove quotes if present + env_version = env_version.strip("'\"") + if env_version and env_version != "latest": + return env_version + except Exception: + pass + # If still "latest", we can't determine version - return None + return None + # Return version if it looks like a version number (not "latest") + if version and version != "latest": + return version + + # Fallback: check all containers for version tags + success, stdout, _ = await self._run_runtime_command( + ["ps", "--all", "--format", "{{.Image}}"] + ) + + if success and stdout.strip(): + images = stdout.strip().splitlines() + for image in images: + image = image.strip() + if "openrag" in image.lower() and ":" in image: + version = image.split(":")[-1] + if version and version != "latest": + return version + except Exception as e: + logger.debug(f"Error getting container version: {e}") + + return None + + async def check_version_mismatch(self) -> tuple[bool, Optional[str], str]: + """ + Check if existing containers have a different version than the current TUI. + + Returns: + Tuple of (has_mismatch, container_version, tui_version) + """ + try: + from ..utils.version_check import get_current_version + + tui_version = get_current_version() + if tui_version == "unknown": + return False, None, tui_version + + container_version = await self.get_container_version() + + if container_version is None: + # No containers exist, no mismatch + return False, None, tui_version + + # Compare versions + from ..utils.version_check import compare_versions + comparison = compare_versions(container_version, tui_version) + has_mismatch = comparison != 0 + + return has_mismatch, container_version, tui_version + except Exception as e: + logger.debug(f"Error checking version mismatch: {e}") + return False, None, "unknown" + async def get_service_status( self, force_refresh: bool = False ) -> Dict[str, ServiceInfo]: @@ -764,6 +858,14 @@ class ContainerManager: yield False, "No container runtime available" return + # Ensure OPENRAG_VERSION is set in .env file + try: + from ..managers.env_manager import EnvManager + env_manager = EnvManager() + env_manager.ensure_openrag_version() + except Exception: + pass # Continue even if version setting fails + # Determine GPU mode if cpu_mode is None: use_gpu = self.use_gpu_compose diff --git a/src/tui/managers/env_manager.py b/src/tui/managers/env_manager.py index c42aaef4..a3d3ea6f 100644 --- a/src/tui/managers/env_manager.py +++ b/src/tui/managers/env_manager.py @@ -68,6 +68,9 @@ class EnvConfig: # OpenSearch data path opensearch_data_path: str = "./opensearch-data" + + # Container version (linked to TUI version) + openrag_version: str = "" # Validation errors validation_errors: Dict[str, str] = field(default_factory=dict) @@ -149,6 +152,7 @@ class EnvManager: "LANGFLOW_NEW_USER_IS_ACTIVE": "langflow_new_user_is_active", "LANGFLOW_ENABLE_SUPERUSER_CLI": "langflow_enable_superuser_cli", "DISABLE_INGEST_WITH_LANGFLOW": "disable_ingest_with_langflow", + "OPENRAG_VERSION": "openrag_version", } loaded_from_file = False @@ -193,6 +197,17 @@ class EnvManager: if not self.config.langflow_secret_key: self.config.langflow_secret_key = self.generate_langflow_secret_key() + + # Set OPENRAG_VERSION to TUI version if not already set + if not self.config.openrag_version: + try: + from ..utils.version_check import get_current_version + current_version = get_current_version() + if current_version != "unknown": + self.config.openrag_version = current_version + except Exception: + # If we can't get version, leave it empty (will use 'latest' from compose) + pass # Configure autologin based on whether password is set if not self.config.langflow_superuser_password: @@ -344,6 +359,18 @@ class EnvManager: f.write( f"OPENSEARCH_DATA_PATH={self._quote_env_value(self.config.opensearch_data_path)}\n" ) + # Set OPENRAG_VERSION to TUI version + if self.config.openrag_version: + f.write(f"OPENRAG_VERSION={self._quote_env_value(self.config.openrag_version)}\n") + else: + # Fallback: try to get current version + try: + from ..utils.version_check import get_current_version + current_version = get_current_version() + if current_version != "unknown": + f.write(f"OPENRAG_VERSION={self._quote_env_value(current_version)}\n") + except Exception: + pass f.write("\n") # Provider API keys and endpoints (optional - can be set during onboarding) @@ -514,6 +541,73 @@ class EnvManager: return base_fields + oauth_fields + flow_fields + optional_fields + def ensure_openrag_version(self) -> None: + """Ensure OPENRAG_VERSION is set in .env file to match TUI version.""" + try: + from ..utils.version_check import get_current_version + current_version = get_current_version() + if current_version == "unknown": + return + + # Check if OPENRAG_VERSION is already set in .env + if self.env_file.exists(): + env_content = self.env_file.read_text() + if "OPENRAG_VERSION" in env_content: + # Already set, check if it needs updating + for line in env_content.splitlines(): + if line.strip().startswith("OPENRAG_VERSION"): + existing_value = line.split("=", 1)[1].strip() + existing_value = sanitize_env_value(existing_value) + if existing_value == current_version: + # Already correct, no update needed + return + break + + # Set or update OPENRAG_VERSION + self.config.openrag_version = current_version + + # Update .env file + if self.env_file.exists(): + # Read existing content + lines = self.env_file.read_text().splitlines() + updated = False + new_lines = [] + + for line in lines: + if line.strip().startswith("OPENRAG_VERSION"): + # Replace existing line + new_lines.append(f"OPENRAG_VERSION={self._quote_env_value(current_version)}") + updated = True + else: + new_lines.append(line) + + # If not found, add it after OPENSEARCH_DATA_PATH or at the end + if not updated: + insert_pos = len(new_lines) + for i, line in enumerate(new_lines): + if "OPENSEARCH_DATA_PATH" in line: + insert_pos = i + 1 + break + new_lines.insert(insert_pos, f"OPENRAG_VERSION={self._quote_env_value(current_version)}") + + with open(self.env_file, 'w') as f: + f.write("\n".join(new_lines) + "\n") + f.flush() + os.fsync(f.fileno()) + else: + # Create new .env file with just OPENRAG_VERSION + with open(self.env_file, 'w') as f: + content = ( + f"# OpenRAG Environment Configuration\n" + f"# Generated by OpenRAG TUI\n\n" + f"OPENRAG_VERSION={self._quote_env_value(current_version)}\n" + ) + f.write(content) + f.flush() + os.fsync(f.fileno()) + except Exception as e: + logger.debug(f"Error ensuring OPENRAG_VERSION: {e}") + def generate_compose_volume_mounts(self) -> List[str]: """Generate Docker Compose volume mount strings from documents paths.""" is_valid, _, validated_paths = validate_documents_paths( diff --git a/src/tui/screens/monitor.py b/src/tui/screens/monitor.py index fbc546a6..e0607dde 100644 --- a/src/tui/screens/monitor.py +++ b/src/tui/screens/monitor.py @@ -20,6 +20,8 @@ from ..managers.docling_manager import DoclingManager from ..utils.platform import RuntimeType from ..widgets.command_modal import CommandOutputModal from ..widgets.flow_backup_warning_modal import FlowBackupWarningModal +from ..widgets.version_mismatch_warning_modal import VersionMismatchWarningModal +from ..widgets.upgrade_instructions_modal import UpgradeInstructionsModal from ..widgets.diagnostics_notification import notify_with_diagnostics @@ -63,7 +65,7 @@ class MonitorScreen(Screen): def on_unmount(self) -> None: """Clean up when the screen is unmounted.""" - if hasattr(self, 'docling_manager'): + if hasattr(self, "docling_manager"): self.docling_manager.cleanup() super().on_unmount() self._follow_service = None @@ -218,7 +220,7 @@ class MonitorScreen(Screen): docling_status_value = docling_status["status"] docling_running = docling_status_value == "running" docling_starting = docling_status_value == "starting" - + if docling_running: docling_status_text = "running" docling_style = "bold green" @@ -228,9 +230,15 @@ class MonitorScreen(Screen): else: docling_status_text = "stopped" docling_style = "bold red" - - docling_port = f"{docling_status['host']}:{docling_status['port']}" if (docling_running or docling_starting) else "N/A" - docling_pid = str(docling_status.get("pid")) if docling_status.get("pid") else "N/A" + + docling_port = ( + f"{docling_status['host']}:{docling_status['port']}" + if (docling_running or docling_starting) + else "N/A" + ) + docling_pid = ( + str(docling_status.get("pid")) if docling_status.get("pid") else "N/A" + ) if self.docling_table: self.docling_table.add_row( @@ -238,7 +246,7 @@ class MonitorScreen(Screen): Text(docling_status_text, style=docling_style), docling_port, docling_pid, - "Start/Stop/Logs" + "Start/Stop/Logs", ) # Restore docling selection when it was the last active table if self._last_selected_table == "docling": @@ -321,27 +329,55 @@ class MonitorScreen(Screen): self.operation_in_progress = True try: # Check for port conflicts before attempting to start - ports_available, conflicts = await self.container_manager.check_ports_available() + ( + ports_available, + conflicts, + ) = await self.container_manager.check_ports_available() if not ports_available: # Show error notification instead of modal conflict_msgs = [] for service_name, port, error_msg in conflicts[:3]: # Show first 3 conflict_msgs.append(f"{service_name} (port {port})") - + conflict_str = ", ".join(conflict_msgs) if len(conflicts) > 3: conflict_str += f" and {len(conflicts) - 3} more" - + self.notify( f"Cannot start services: Port conflicts detected for {conflict_str}. " f"Please stop the conflicting services first.", severity="error", - timeout=10 + timeout=10, ) # Refresh to show current state await self._refresh_services() return - + + # Check for version mismatch + ( + has_mismatch, + container_version, + tui_version, + ) = await self.container_manager.check_version_mismatch() + if has_mismatch and container_version: + # Show warning modal and wait for user decision + should_continue = await self.app.push_screen_wait( + VersionMismatchWarningModal(container_version, tui_version) + ) + if not should_continue: + self.notify("Start cancelled", severity="information") + return + # Ensure OPENRAG_VERSION is set in .env BEFORE starting services + # This ensures docker compose reads the correct version + try: + from ..managers.env_manager import EnvManager + env_manager = EnvManager() + env_manager.ensure_openrag_version() + # Small delay to ensure .env file is written and flushed + await asyncio.sleep(0.5) + except Exception: + pass # Continue even if version setting fails + # Show command output in modal dialog command_generator = self.container_manager.start_services(cpu_mode) modal = CommandOutputModal( @@ -391,27 +427,30 @@ class MonitorScreen(Screen): self.operation_in_progress = False async def _upgrade_services(self) -> None: - """Upgrade services with progress updates.""" + """Check TUI version and show upgrade instructions.""" self.operation_in_progress = True try: - # Check for flow backups before upgrading - if self._check_flow_backups(): - # Show warning modal and wait for user decision - should_continue = await self.app.push_screen_wait( - FlowBackupWarningModal(operation="upgrade") + from ..utils.version_check import check_if_latest + + # Check if current version is latest + is_latest, latest_version, current_version = await check_if_latest() + + if is_latest: + # Show "this is the latest version" toast + self.notify( + f"You are running the latest version ({current_version}).", + severity="success", + timeout=5, ) - if not should_continue: - self.notify("Upgrade cancelled", severity="information") - return - - # Show command output in modal dialog - command_generator = self.container_manager.upgrade_services() - modal = CommandOutputModal( - "Upgrading Services", - command_generator, - on_complete=None, # We'll refresh in on_screen_resume instead + else: + # Show upgrade instructions in a modal dialog + await self.app.push_screen_wait( + UpgradeInstructionsModal(current_version, latest_version) + ) + except Exception as e: + self.notify( + f"Error checking version: {str(e)}", severity="error", timeout=10 ) - self.app.push_screen(modal) finally: self.operation_in_progress = False @@ -428,7 +467,7 @@ class MonitorScreen(Screen): if not should_continue: self.notify("Reset cancelled", severity="information") return - + # Show command output in modal dialog command_generator = self.container_manager.reset_services() modal = CommandOutputModal( @@ -443,10 +482,11 @@ class MonitorScreen(Screen): def _check_flow_backups(self) -> bool: """Check if there are any flow backups in ./flows/backup directory.""" from pathlib import Path + backup_dir = Path("flows/backup") if not backup_dir.exists(): return False - + try: # Check if there are any .json files in the backup directory backup_files = list(backup_dir.glob("*.json")) @@ -465,7 +505,7 @@ class MonitorScreen(Screen): f"Cannot start docling serve: {error_msg}. " f"Please stop the conflicting service first.", severity="error", - timeout=10 + timeout=10, ) # Refresh to show current state await self._refresh_services() @@ -483,7 +523,9 @@ class MonitorScreen(Screen): if success: self.notify(message, severity="information") else: - self.notify(f"Failed to start docling serve: {message}", severity="error") + self.notify( + f"Failed to start docling serve: {message}", severity="error" + ) # Refresh again to show final status (running or stopped) await self._refresh_services() except Exception as e: @@ -501,7 +543,9 @@ class MonitorScreen(Screen): if success: self.notify(message, severity="information") else: - self.notify(f"Failed to stop docling serve: {message}", severity="error") + self.notify( + f"Failed to stop docling serve: {message}", severity="error" + ) # Refresh the services table to show updated status await self._refresh_services() except Exception as e: @@ -517,7 +561,9 @@ class MonitorScreen(Screen): if success: self.notify(message, severity="information") else: - self.notify(f"Failed to restart docling serve: {message}", severity="error") + self.notify( + f"Failed to restart docling serve: {message}", severity="error" + ) # Refresh the services table to show updated status await self._refresh_services() except Exception as e: @@ -528,6 +574,7 @@ class MonitorScreen(Screen): def _view_docling_logs(self) -> None: """View docling serve logs.""" from .logs import LogsScreen + self.app.push_screen(LogsScreen(initial_service="docling-serve")) def _strip_ansi_codes(self, text: str) -> str: @@ -728,7 +775,7 @@ class MonitorScreen(Screen): Button("Upgrade", variant="warning", id=f"upgrade-btn{suffix}") ) controls.mount(Button("Reset", variant="error", id=f"reset-btn{suffix}")) - + except Exception as e: notify_with_diagnostics( self.app, f"Error updating controls: {e}", severity="error" @@ -748,6 +795,7 @@ class MonitorScreen(Screen): # Use a random suffix for unique IDs import random + suffix = f"-{random.randint(10000, 99999)}" # Add docling serve controls @@ -755,17 +803,21 @@ class MonitorScreen(Screen): docling_status_value = docling_status["status"] docling_running = docling_status_value == "running" docling_starting = docling_status_value == "starting" - + if docling_running: docling_controls.mount( Button("Stop", variant="error", id=f"docling-stop-btn{suffix}") ) docling_controls.mount( - Button("Restart", variant="primary", id=f"docling-restart-btn{suffix}") + Button( + "Restart", variant="primary", id=f"docling-restart-btn{suffix}" + ) ) elif docling_starting: # Show disabled button or no button when starting - start_btn = Button("Starting...", variant="warning", id=f"docling-start-btn{suffix}") + start_btn = Button( + "Starting...", variant="warning", id=f"docling-start-btn{suffix}" + ) start_btn.disabled = True docling_controls.mount(start_btn) else: @@ -805,6 +857,7 @@ class MonitorScreen(Screen): if selected_service: # Push the logs screen with the selected service from .logs import LogsScreen + logs_screen = LogsScreen(initial_service=selected_service) self.app.push_screen(logs_screen) else: @@ -927,7 +980,9 @@ class MonitorScreen(Screen): except Exception: pass - def _focus_services_table(self, row: str | None = None, set_last: bool = True) -> None: + def _focus_services_table( + self, row: str | None = None, set_last: bool = True + ) -> None: """Focus the services table and update selection.""" if not self.services_table: return diff --git a/src/tui/screens/welcome.py b/src/tui/screens/welcome.py index d83e0c38..71cb1057 100644 --- a/src/tui/screens/welcome.py +++ b/src/tui/screens/welcome.py @@ -15,6 +15,7 @@ from ..managers.container_manager import ContainerManager, ServiceStatus from ..managers.env_manager import EnvManager from ..managers.docling_manager import DoclingManager from ..widgets.command_modal import CommandOutputModal +from ..widgets.version_mismatch_warning_modal import VersionMismatchWarningModal class WelcomeScreen(Screen): @@ -450,6 +451,28 @@ class WelcomeScreen(Screen): # Step 1: Start container services first (to create the network) if self.container_manager.is_available(): + # Check for version mismatch before starting + has_mismatch, container_version, tui_version = await self.container_manager.check_version_mismatch() + if has_mismatch and container_version: + # Show warning modal and wait for user decision + should_continue = await self.app.push_screen_wait( + VersionMismatchWarningModal(container_version, tui_version) + ) + if not should_continue: + self.notify("Start cancelled", severity="information") + return + # Ensure OPENRAG_VERSION is set in .env BEFORE starting services + # This ensures docker compose reads the correct version + try: + from ..managers.env_manager import EnvManager + env_manager = EnvManager() + env_manager.ensure_openrag_version() + # Small delay to ensure .env file is written and flushed + import asyncio + await asyncio.sleep(0.5) + except Exception: + pass # Continue even if version setting fails + command_generator = self.container_manager.start_services() modal = CommandOutputModal( "Starting Container Services", diff --git a/src/tui/utils/version_check.py b/src/tui/utils/version_check.py new file mode 100644 index 00000000..16c5d884 --- /dev/null +++ b/src/tui/utils/version_check.py @@ -0,0 +1,175 @@ +"""Version checking utilities for OpenRAG TUI.""" + +from typing import Optional, Tuple +from utils.logging_config import get_logger + +logger = get_logger(__name__) + + +async def get_latest_docker_version(image_name: str = "langflowai/openrag-backend") -> Optional[str]: + """ + Get the latest version tag from Docker Hub for OpenRAG containers. + + Args: + image_name: Name of the Docker image to check (default: "langflowai/openrag-backend") + + Returns: + Latest version string if found, None otherwise + """ + try: + import httpx + + async with httpx.AsyncClient(timeout=10.0) as client: + # Docker Hub API v2 endpoint for tags + url = f"https://hub.docker.com/v2/repositories/{image_name}/tags/" + params = {"page_size": 100, "ordering": "-last_updated"} + + response = await client.get(url, params=params) + if response.status_code == 200: + data = response.json() + tags = data.get("results", []) + + # Filter out non-version tags and find the latest version + version_tags = [] + for tag in tags: + tag_name = tag.get("name", "") + # Skip architecture-specific tags (amd64, arm64) and "latest" + if tag_name in ["latest", "amd64", "arm64"]: + continue + # Skip tags that don't look like version numbers + # Version format: X.Y.Z (e.g., 0.1.47) + # Check if it starts with a digit and contains only digits, dots, and hyphens + if tag_name and tag_name[0].isdigit(): + # Remove dots and hyphens, check if rest is digits + cleaned = tag_name.replace(".", "").replace("-", "") + if cleaned.isdigit(): + version_tags.append(tag_name) + + if not version_tags: + return None + + # Sort versions properly and return the latest + # Use a tuple-based sort key for proper version comparison + def version_sort_key(v: str) -> tuple: + """Convert version string to tuple for sorting.""" + try: + parts = [] + for part in v.split('.'): + # Extract numeric part + numeric_part = '' + for char in part: + if char.isdigit(): + numeric_part += char + else: + break + parts.append(int(numeric_part) if numeric_part else 0) + # Pad to at least 3 parts for consistent comparison + while len(parts) < 3: + parts.append(0) + return tuple(parts) + except Exception: + # Fallback: return tuple of zeros if parsing fails + return (0, 0, 0) + + version_tags.sort(key=version_sort_key) + return version_tags[-1] + else: + logger.warning(f"Docker Hub API returned status {response.status_code}") + return None + except Exception as e: + logger.debug(f"Error checking Docker Hub for latest version: {e}") + return None + + +def get_current_version() -> str: + """ + Get the current installed version of OpenRAG. + + Returns: + Version string or "unknown" if not available + """ + try: + from importlib.metadata import version + return version("openrag") + except Exception: + try: + from tui import __version__ + return __version__ + except Exception: + return "unknown" + + +def compare_versions(version1: str, version2: str) -> int: + """ + Compare two version strings. + + Args: + version1: First version string + version2: Second version string + + Returns: + -1 if version1 < version2, 0 if equal, 1 if version1 > version2 + """ + try: + # Simple version comparison by splitting on dots and comparing parts + def normalize_version(v: str) -> list: + parts = [] + for part in v.split('.'): + # Split on non-numeric characters and take the first numeric part + numeric_part = '' + for char in part: + if char.isdigit(): + numeric_part += char + else: + break + parts.append(int(numeric_part) if numeric_part else 0) + return parts + + v1_parts = normalize_version(version1) + v2_parts = normalize_version(version2) + + # Pad shorter version with zeros + max_len = max(len(v1_parts), len(v2_parts)) + v1_parts.extend([0] * (max_len - len(v1_parts))) + v2_parts.extend([0] * (max_len - len(v2_parts))) + + for i in range(max_len): + if v1_parts[i] < v2_parts[i]: + return -1 + elif v1_parts[i] > v2_parts[i]: + return 1 + return 0 + except Exception as e: + logger.debug(f"Error comparing versions: {e}") + # Fallback: string comparison + if version1 < version2: + return -1 + elif version1 > version2: + return 1 + else: + return 0 + + +async def check_if_latest() -> Tuple[bool, Optional[str], str]: + """ + Check if the current version is the latest available on Docker Hub. + + Returns: + Tuple of (is_latest, latest_version, current_version) + """ + current = get_current_version() + latest = await get_latest_docker_version() + + if latest is None: + # If we can't check, assume current is latest + return True, None, current + + if current == "unknown": + # If we can't determine current version, assume it's not latest + return False, latest, current + + comparison = compare_versions(current, latest) + is_latest = comparison >= 0 + + return is_latest, latest, current + diff --git a/src/tui/widgets/upgrade_instructions_modal.py b/src/tui/widgets/upgrade_instructions_modal.py new file mode 100644 index 00000000..9705c146 --- /dev/null +++ b/src/tui/widgets/upgrade_instructions_modal.py @@ -0,0 +1,112 @@ +"""Upgrade instructions 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 UpgradeInstructionsModal(ModalScreen[bool]): + """Modal dialog showing upgrade instructions when not on latest version.""" + + DEFAULT_CSS = """ + UpgradeInstructionsModal { + align: center middle; + } + + #dialog { + width: 75; + height: auto; + border: solid #3f3f46; + background: #27272a; + padding: 0; + } + + #title { + background: #3f3f46; + color: #fafafa; + padding: 1 2; + text-align: center; + width: 100%; + text-style: bold; + } + + #message { + padding: 2; + color: #fafafa; + } + + #button-row { + width: 100%; + height: auto; + align: center middle; + padding: 1; + margin-top: 1; + } + + #button-row Button { + margin: 0 1; + min-width: 16; + 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 __init__(self, current_version: str, latest_version: str): + """Initialize the upgrade instructions modal. + + Args: + current_version: Current TUI version + latest_version: Latest available version + """ + super().__init__() + self.current_version = current_version + self.latest_version = latest_version + + def compose(self) -> ComposeResult: + """Create the modal dialog layout.""" + with Container(id="dialog"): + yield Label("📦 Upgrade Available", id="title") + yield Static( + f"Current version: {self.current_version}\n" + f"Latest version: {self.latest_version}\n\n" + "To upgrade the TUI:\n" + "1. Exit TUI (press 'q')\n" + "2. Run one of:\n" + " • pip install --upgrade openrag\n" + " • uv pip install --upgrade openrag\n" + " • uvx --from openrag openrag\n" + "3. Restart: openrag\n\n" + "After upgrading, containers will automatically use the new version.", + id="message" + ) + with Horizontal(id="button-row"): + yield Button("Close", id="close-btn") + + def on_mount(self) -> None: + """Focus the close button.""" + self.query_one("#close-btn", Button).focus() + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button presses.""" + self.dismiss(True) # Just close the modal + diff --git a/src/tui/widgets/version_mismatch_warning_modal.py b/src/tui/widgets/version_mismatch_warning_modal.py new file mode 100644 index 00000000..22d94e4e --- /dev/null +++ b/src/tui/widgets/version_mismatch_warning_modal.py @@ -0,0 +1,114 @@ +"""Version mismatch warning 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 VersionMismatchWarningModal(ModalScreen[bool]): + """Modal dialog to warn about version mismatch before starting services.""" + + DEFAULT_CSS = """ + VersionMismatchWarningModal { + align: center middle; + } + + #dialog { + width: 70; + height: auto; + border: solid #3f3f46; + background: #27272a; + padding: 0; + } + + #title { + background: #3f3f46; + color: #fafafa; + padding: 1 2; + text-align: center; + width: 100%; + text-style: bold; + } + + #message { + padding: 2; + color: #fafafa; + text-align: center; + } + + #button-row { + width: 100%; + height: auto; + align: center middle; + padding: 1; + margin-top: 1; + } + + #button-row Button { + margin: 0 1; + min-width: 16; + 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 __init__(self, container_version: str, tui_version: str): + """Initialize the warning modal. + + Args: + container_version: Version of existing containers + tui_version: Current TUI version + """ + super().__init__() + self.container_version = container_version + self.tui_version = tui_version + + def compose(self) -> ComposeResult: + """Create the modal dialog layout.""" + with Container(id="dialog"): + yield Label("⚠ Version Mismatch Detected", id="title") + yield Static( + f"Existing containers are running version {self.container_version}\n" + f"Current TUI version is {self.tui_version}\n\n" + f"Starting services will update containers to version {self.tui_version}.\n" + f"This may cause compatibility issues with your flows.\n\n" + f"⚠️ Please backup your flows before continuing:\n" + f" Your flows are in ./flows/ directory\n\n" + f"Do you want to continue?", + id="message" + ) + with Horizontal(id="button-row"): + yield Button("Cancel", id="cancel-btn") + yield Button("Continue", id="continue-btn") + + def on_mount(self) -> None: + """Focus the cancel button by default for safety.""" + self.query_one("#cancel-btn", Button).focus() + + 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 + else: + self.dismiss(False) # User cancelled +