Merge branch 'main' into issue-552-known-01-dec-25
This commit is contained in:
commit
28f09e3264
10 changed files with 730 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
175
src/tui/utils/version_check.py
Normal file
175
src/tui/utils/version_check.py
Normal 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
|
||||
|
||||
112
src/tui/widgets/upgrade_instructions_modal.py
Normal file
112
src/tui/widgets/upgrade_instructions_modal.py
Normal 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
|
||||
|
||||
114
src/tui/widgets/version_mismatch_warning_modal.py
Normal file
114
src/tui/widgets/version_mismatch_warning_modal.py
Normal 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
|
||||
|
||||
Loading…
Add table
Reference in a new issue