Merge branch 'main' into issue-552-known-01-dec-25

This commit is contained in:
April I. Murphy 2025-12-02 07:37:15 -08:00 committed by GitHub
commit 28f09e3264
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 730 additions and 47 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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