diff --git a/frontend/src/app/api/queries/useDoclingHealthQuery.ts b/frontend/src/app/api/queries/useDoclingHealthQuery.ts index 16ffc6c5..8db560d0 100644 --- a/frontend/src/app/api/queries/useDoclingHealthQuery.ts +++ b/frontend/src/app/api/queries/useDoclingHealthQuery.ts @@ -16,7 +16,8 @@ export const useDoclingHealthQuery = ( async function checkDoclingHealth(): Promise { try { - const response = await fetch("http://127.0.0.1:5001/health", { + // Call backend proxy endpoint instead of direct localhost + const response = await fetch("/api/docling/health", { method: "GET", headers: { "Content-Type": "application/json", diff --git a/src/main.py b/src/main.py index bf6da342..a09d2488 100644 --- a/src/main.py +++ b/src/main.py @@ -31,6 +31,7 @@ from api import ( auth, chat, connectors, + docling, documents, flows, knowledge_filter, @@ -1111,6 +1112,12 @@ async def create_app(): ), methods=["POST"], ), + # Docling service proxy + Route( + "/docling/health", + partial(docling.health), + methods=["GET"], + ), ] app = Starlette(debug=True, routes=routes) diff --git a/src/tui/managers/docling_manager.py b/src/tui/managers/docling_manager.py index 6fecfff9..7cb5d1e8 100644 --- a/src/tui/managers/docling_manager.py +++ b/src/tui/managers/docling_manager.py @@ -8,6 +8,7 @@ import threading import time from typing import Optional, Tuple, Dict, Any, List, AsyncIterator from utils.logging_config import get_logger +from utils.container_utils import guess_host_ip_for_containers logger = get_logger(__name__) @@ -31,7 +32,7 @@ class DoclingManager: self._process: Optional[subprocess.Popen] = None self._port = 5001 - self._host = self._get_host_for_containers() # Get appropriate host IP based on runtime + self._host = guess_host_ip_for_containers(logger=logger) # Get appropriate host IP based on runtime self._running = False self._external_process = False @@ -49,136 +50,6 @@ class DoclingManager: # Try to recover existing process from PID file self._recover_from_pid_file() - def _get_host_for_containers(self) -> str: - """ - Return a host IP that containers can reach (a bridge/CNI gateway). - Prefers Docker/Podman network gateways; falls back to bridge interfaces. - """ - import subprocess, json, shutil, re, logging - logger = logging.getLogger(__name__) - - def run(cmd, timeout=2, text=True): - return subprocess.run(cmd, capture_output=True, text=text, timeout=timeout) - - gateways = [] - compose_gateways = [] # Highest priority - compose project networks - active_gateways = [] # Medium priority - networks with containers - - # ---- Docker: enumerate networks and collect gateways - if shutil.which("docker"): - try: - ls = run(["docker", "network", "ls", "--format", "{{.Name}}"]) - if ls.returncode == 0: - for name in filter(None, ls.stdout.splitlines()): - try: - insp = run(["docker", "network", "inspect", name, "--format", "{{json .}}"]) - if insp.returncode == 0 and insp.stdout.strip(): - nw = json.loads(insp.stdout)[0] if insp.stdout.strip().startswith("[") else json.loads(insp.stdout) - ipam = nw.get("IPAM", {}) - containers = nw.get("Containers", {}) - for cfg in ipam.get("Config", []) or []: - gw = cfg.get("Gateway") - if gw: - # Highest priority: compose networks (ending in _default) - if name.endswith("_default"): - compose_gateways.append(gw) - # Medium priority: networks with active containers - elif len(containers) > 0: - active_gateways.append(gw) - # Low priority: empty networks - else: - gateways.append(gw) - except Exception: - pass - except Exception: - pass - - # ---- Podman: enumerate networks and collect gateways (netavark) - if shutil.which("podman"): - try: - # modern podman supports JSON format - ls = run(["podman", "network", "ls", "--format", "json"]) - if ls.returncode == 0 and ls.stdout.strip(): - for net in json.loads(ls.stdout): - name = net.get("name") or net.get("Name") - if not name: - continue - try: - insp = run(["podman", "network", "inspect", name, "--format", "json"]) - if insp.returncode == 0 and insp.stdout.strip(): - arr = json.loads(insp.stdout) - for item in (arr if isinstance(arr, list) else [arr]): - for sn in item.get("subnets", []) or []: - gw = sn.get("gateway") - if gw: - # Prioritize compose/project networks - if name.endswith("_default") or "_" in name: - compose_gateways.append(gw) - else: - gateways.append(gw) - except Exception: - pass - except Exception: - pass - - # ---- Fallback: parse host interfaces for common bridges - if not gateways: - try: - if shutil.which("ip"): - show = run(["ip", "-o", "-4", "addr", "show"]) - if show.returncode == 0: - for line in show.stdout.splitlines(): - # e.g. "12: br-3f0f... inet 172.18.0.1/16 ..." - m = re.search(r"^\d+:\s+([a-zA-Z0-9_.:-]+)\s+.*\binet\s+(\d+\.\d+\.\d+\.\d+)/", line) - if not m: - continue - ifname, ip = m.group(1), m.group(2) - if ifname == "docker0" or ifname.startswith(("br-", "cni")): - gateways.append(ip) - else: - # As a last resort, try net-tools ifconfig output - if shutil.which("ifconfig"): - show = run(["ifconfig"]) - for block in show.stdout.split("\n\n"): - if any(block.strip().startswith(n) for n in ("docker0", "cni", "br-")): - m = re.search(r"inet (?:addr:)?(\d+\.\d+\.\d+\.\d+)", block) - if m: - gateways.append(m.group(1)) - except Exception: - pass - - # Dedup, prioritizing: 1) compose networks, 2) active networks, 3) all others - seen, uniq = set(), [] - # First: compose project networks (_default suffix) - for ip in compose_gateways: - if ip not in seen: - uniq.append(ip) - seen.add(ip) - # Second: networks with active containers - for ip in active_gateways: - if ip not in seen: - uniq.append(ip) - seen.add(ip) - # Third: all other gateways - for ip in gateways: - if ip not in seen: - uniq.append(ip) - seen.add(ip) - - if uniq: - if len(uniq) > 1: - logger.info("Container-reachable host IP candidates: %s", ", ".join(uniq)) - else: - logger.info("Container-reachable host IP: %s", uniq[0]) - return uniq[0] - - # Nothing found: warn clearly - logger.warning( - "No container bridge IP found. If using rootless Podman (slirp4netns), there is no host bridge; publish ports or use 10.0.2.2 from the container." - ) - # Returning localhost is honest only for same-namespace; keep it explicit: - return "127.0.0.1" - def cleanup(self): """Cleanup resources but keep docling-serve running across sessions.""" # Don't stop the process on exit - let it persist diff --git a/src/utils/container_utils.py b/src/utils/container_utils.py index a18d9f1c..14222c84 100644 --- a/src/utils/container_utils.py +++ b/src/utils/container_utils.py @@ -136,3 +136,138 @@ def transform_localhost_url(url: str) -> str: return url.replace(pattern, container_host) return url + + +def guess_host_ip_for_containers(logger=None) -> str: + """Best-effort detection of a host IP reachable from container networks. + + The logic mirrors what the TUI uses when launching docling-serve so that + both CLI and API use consistent addresses. Preference order: + 1. Docker/Podman compose networks (ended with ``_default``) + 2. Networks with active containers + 3. Any discovered bridge or CNI gateway interfaces + + Args: + logger: Optional logger to emit diagnostics; falls back to module logger. + + Returns: + The most appropriate host IP address if discovered, otherwise ``"127.0.0.1"``. + """ + import json + import logging + import re + import shutil + import subprocess + + log = logger or logging.getLogger(__name__) + + def run(cmd, timeout=2, text=True): + return subprocess.run(cmd, capture_output=True, text=text, timeout=timeout) + + gateways: list[str] = [] + compose_gateways: list[str] = [] + active_gateways: list[str] = [] + + # ---- Docker networks + if shutil.which("docker"): + try: + ls = run(["docker", "network", "ls", "--format", "{{.Name}}"]) + if ls.returncode == 0: + for name in filter(None, ls.stdout.splitlines()): + try: + insp = run(["docker", "network", "inspect", name, "--format", "{{json .}}"]) + if insp.returncode == 0 and insp.stdout.strip(): + payload = insp.stdout.strip() + nw = json.loads(payload)[0] if payload.startswith("[") else json.loads(payload) + ipam = nw.get("IPAM", {}) + containers = nw.get("Containers", {}) + for cfg in ipam.get("Config", []) or []: + gw = cfg.get("Gateway") + if not gw: + continue + if name.endswith("_default"): + compose_gateways.append(gw) + elif len(containers) > 0: + active_gateways.append(gw) + else: + gateways.append(gw) + except Exception: + continue + except Exception: + pass + + # ---- Podman networks + if shutil.which("podman"): + try: + ls = run(["podman", "network", "ls", "--format", "json"]) + if ls.returncode == 0 and ls.stdout.strip(): + for net in json.loads(ls.stdout): + name = net.get("name") or net.get("Name") + if not name: + continue + try: + insp = run(["podman", "network", "inspect", name, "--format", "json"]) + if insp.returncode == 0 and insp.stdout.strip(): + arr = json.loads(insp.stdout) + for item in (arr if isinstance(arr, list) else [arr]): + for sn in item.get("subnets", []) or []: + gw = sn.get("gateway") + if not gw: + continue + if name.endswith("_default") or "_" in name: + compose_gateways.append(gw) + else: + gateways.append(gw) + except Exception: + continue + except Exception: + pass + + # ---- Host bridge interfaces + if not gateways and not compose_gateways and not active_gateways: + try: + if shutil.which("ip"): + show = run(["ip", "-o", "-4", "addr", "show"]) + if show.returncode == 0: + for line in show.stdout.splitlines(): + match = re.search(r"^\d+:\s+([\w_.:-]+)\s+.*\binet\s+(\d+\.\d+\.\d+\.\d+)/", line) + if not match: + continue + ifname, ip_addr = match.group(1), match.group(2) + if ifname == "docker0" or ifname.startswith(("br-", "cni")): + gateways.append(ip_addr) + elif shutil.which("ifconfig"): + show = run(["ifconfig"]) + for block in show.stdout.split("\n\n"): + if any(block.strip().startswith(n) for n in ("docker0", "cni", "br-")): + match = re.search(r"inet (?:addr:)?(\d+\.\d+\.\d+\.\d+)", block) + if match: + gateways.append(match.group(1)) + except Exception: + pass + + seen: set[str] = set() + ordered_candidates: list[str] = [] + + for collection in (compose_gateways, active_gateways, gateways): + for ip_addr in collection: + if ip_addr not in seen: + ordered_candidates.append(ip_addr) + seen.add(ip_addr) + + if ordered_candidates: + if len(ordered_candidates) > 1: + log.info( + "Container-reachable host IP candidates: %s", + ", ".join(ordered_candidates), + ) + else: + log.info("Container-reachable host IP: %s", ordered_candidates[0]) + + return ordered_candidates[0] + + log.warning( + "No container bridge IP found. For rootless Podman (slirp4netns) there may be no host bridge; publish ports or use 10.0.2.2 from the container." + ) + + return "127.0.0.1"