diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a40b2fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +bin/ +logs/ +gpu_config.json +__pycache__/ \ No newline at end of file diff --git a/distributed.py b/distributed.py index fe1cb84..2786246 100644 --- a/distributed.py +++ b/distributed.py @@ -36,6 +36,7 @@ from .utils.image import tensor_to_pil, pil_to_tensor, ensure_contiguous from .utils.process import is_process_alive, terminate_process, get_python_executable from .utils.network import handle_api_error, get_server_port, get_server_loop, get_client_session, cleanup_client_session from .utils.async_helpers import run_async_in_server_loop +from .utils.cloudflare import cloudflare_tunnel_manager from .utils.constants import ( WORKER_JOB_TIMEOUT, PROCESS_TERMINATION_TIMEOUT, WORKER_CHECK_INTERVAL, STATUS_CHECK_INTERVAL, CHUNK_SIZE, LOG_TAIL_BYTES, WORKER_LOG_PATTERN, @@ -269,6 +270,49 @@ async def get_network_info_endpoint(request): "recommended_ip": None }) +@server.PromptServer.instance.routes.get("/distributed/tunnel/status") +async def tunnel_status_endpoint(request): + """Return Cloudflare tunnel status and last known details.""" + try: + status = cloudflare_tunnel_manager.get_status() + config = load_config() + master_host = (config.get("master") or {}).get("host") + return web.json_response({ + "status": "success", + "tunnel": status, + "master_host": master_host + }) + except Exception as e: + return await handle_api_error(request, e, 500) + +@server.PromptServer.instance.routes.post("/distributed/tunnel/start") +async def tunnel_start_endpoint(request): + """Start a Cloudflare tunnel pointing at the current ComfyUI server.""" + try: + result = await cloudflare_tunnel_manager.start_tunnel() + config = load_config() + return web.json_response({ + "status": "success", + "tunnel": result, + "master_host": (config.get("master") or {}).get("host") + }) + except Exception as e: + return await handle_api_error(request, e, 500) + +@server.PromptServer.instance.routes.post("/distributed/tunnel/stop") +async def tunnel_stop_endpoint(request): + """Stop the managed Cloudflare tunnel if running.""" + try: + result = await cloudflare_tunnel_manager.stop_tunnel() + config = load_config() + return web.json_response({ + "status": "success", + "tunnel": result, + "master_host": (config.get("master") or {}).get("host") + }) + except Exception as e: + return await handle_api_error(request, e, 500) + @server.PromptServer.instance.routes.get("/distributed/system_info") async def get_system_info_endpoint(request): """Get system information including machine ID for local worker detection.""" @@ -1445,6 +1489,10 @@ async def async_cleanup_and_exit(signum=None): else: print("\n[Distributed] Master shutting down, workers will continue running") worker_manager.save_processes() + try: + await cloudflare_tunnel_manager.stop_tunnel() + except Exception as tunnel_error: + debug_log(f"Error stopping Cloudflare tunnel during shutdown: {tunnel_error}") except Exception as e: print(f"[Distributed] Error during cleanup: {e}") @@ -1503,6 +1551,17 @@ def sync_cleanup(): else: print("\n[Distributed] Master shutting down, workers will continue running") worker_manager.save_processes() + try: + loop = asyncio.get_event_loop() + if loop.is_running(): + loop.create_task(cloudflare_tunnel_manager.stop_tunnel()) + else: + loop.run_until_complete(cloudflare_tunnel_manager.stop_tunnel()) + except RuntimeError: + # No running loop; create a temporary one + asyncio.run(cloudflare_tunnel_manager.stop_tunnel()) + except Exception as tunnel_error: + debug_log(f"Error stopping Cloudflare tunnel during sync cleanup: {tunnel_error}") except Exception as e: print(f"[Distributed] Error during cleanup: {e}") @@ -1525,6 +1584,10 @@ if not os.environ.get('COMFYUI_MASTER_PID'): worker_manager.cleanup_all() else: worker_manager.save_processes() + try: + asyncio.run(cloudflare_tunnel_manager.stop_tunnel()) + except Exception as tunnel_error: + print(f"[Distributed] Error stopping Cloudflare tunnel: {tunnel_error}") except Exception as cleanup_error: print(f"[Distributed] Error during cleanup: {cleanup_error}") sys.exit(0) diff --git a/distributed_queue_api.py b/distributed_queue_api.py index b98612e..43063ae 100644 --- a/distributed_queue_api.py +++ b/distributed_queue_api.py @@ -229,6 +229,8 @@ def _resolve_master_url(): ".ngrok-free.app", ".ngrok-free.dev", ".ngrok.io", + ".trycloudflare.com", + ".cloudflare.dev", ) return any(hostname.endswith(suffix) for suffix in https_domains) diff --git a/utils/cloudflare.py b/utils/cloudflare.py new file mode 100644 index 0000000..b62dfb6 --- /dev/null +++ b/utils/cloudflare.py @@ -0,0 +1,385 @@ +""" +Lightweight Cloudflare Tunnel manager. + +Responsibilities: +- Locate or download the cloudflared binary for the current platform. +- Start/stop a quick tunnel pointing to the local ComfyUI server. +- Track status and persist minimal state in the shared config. +""" +import asyncio +import os +import platform +import re +import shutil +import signal +import stat +import subprocess +import threading +import time +from urllib import request, error as urlerror + +from .config import load_config, save_config +from .logging import debug_log, log +from .network import get_server_port +from .process import is_process_alive, terminate_process + +# Regex to capture the generated public URL from cloudflared output +PUBLIC_URL_PATTERN = re.compile(r"(https?://[\w.-]+\.(?:trycloudflare\.com|cloudflare\.dev))", re.IGNORECASE) + +# Default timeout (seconds) to wait for a tunnel URL before giving up +TUNNEL_START_TIMEOUT = 25 + + +class CloudflareTunnelManager: + def __init__(self): + self.process = None + self.pid = None + self.public_url = None + self.last_error = None + self.log_file = None + self.status = "stopped" + self.previous_master_host = None + + self._lock = asyncio.Lock() + self._url_event = None + self._reader_thread = None + self._loop = None + self._recent_logs = [] + + self.binary_path = None + self._restore_state() + + # --- Binary discovery & download --- + @property + def base_dir(self): + return os.path.dirname(os.path.dirname(__file__)) + + @property + def bin_dir(self): + return os.path.join(self.base_dir, "bin") + + def _get_asset_name(self): + system = platform.system().lower() + machine = platform.machine().lower() + + if system == "windows": + if "arm" in machine: + return "cloudflared-windows-arm64.exe" + return "cloudflared-windows-amd64.exe" + if system == "darwin": + if machine in ("arm64", "aarch64"): + return "cloudflared-darwin-arm64" + return "cloudflared-darwin-amd64" + if system == "linux": + if machine in ("arm64", "aarch64"): + return "cloudflared-linux-arm64" + return "cloudflared-linux-amd64" + + raise RuntimeError(f"Unsupported platform for cloudflared: {system}/{machine}") + + def _download_cloudflared(self): + asset = self._get_asset_name() + url = f"https://github.com/cloudflare/cloudflared/releases/latest/download/{asset}" + os.makedirs(self.bin_dir, exist_ok=True) + target_path = os.path.join(self.bin_dir, "cloudflared.exe" if asset.endswith(".exe") else "cloudflared") + + debug_log(f"Downloading cloudflared from {url}") + try: + with request.urlopen(url, timeout=30) as resp: + with open(target_path, "wb") as f: + shutil.copyfileobj(resp, f) + except urlerror.URLError as exc: + raise RuntimeError(f"Failed to download cloudflared: {exc}") from exc + + # Make executable + st = os.stat(target_path) + os.chmod(target_path, st.st_mode | stat.S_IEXEC) + debug_log(f"Downloaded cloudflared to {target_path}") + return target_path + + def ensure_binary(self): + # Allow explicit override + env_path = os.environ.get("CLOUDFLARED_PATH") + if env_path and os.path.exists(env_path): + self.binary_path = env_path + return env_path + + # Check cached path + cached = self.binary_path + if cached and os.path.exists(cached): + return cached + + # Local bin directory + candidate = os.path.join(self.bin_dir, "cloudflared.exe" if platform.system().lower() == "windows" else "cloudflared") + if os.path.exists(candidate): + self.binary_path = candidate + return candidate + + # System PATH + path_binary = shutil.which("cloudflared") + if path_binary: + self.binary_path = path_binary + return path_binary + + # Download on demand + self.binary_path = self._download_cloudflared() + return self.binary_path + + # --- State helpers --- + def _persist_state(self, status=None, public_url=None, pid=None, log_file=None, previous_host=None, master_host=None): + cfg = load_config() + tunnel_cfg = cfg.get("tunnel", {}) if isinstance(cfg.get("tunnel", {}), dict) else {} + + if status is not None: + tunnel_cfg["status"] = status + if public_url is not None: + tunnel_cfg["public_url"] = public_url + if pid is not None: + tunnel_cfg["pid"] = pid + if log_file is not None: + tunnel_cfg["log_file"] = log_file + if previous_host is not None: + tunnel_cfg["previous_master_host"] = previous_host + if master_host is not None: + cfg.setdefault("master", {})["host"] = master_host + + cfg["tunnel"] = tunnel_cfg + save_config(cfg) + + def _restore_state(self): + cfg = load_config() + tunnel_cfg = cfg.get("tunnel", {}) if isinstance(cfg.get("tunnel", {}), dict) else {} + + self.public_url = tunnel_cfg.get("public_url") or None + self.previous_master_host = tunnel_cfg.get("previous_master_host") + self.log_file = tunnel_cfg.get("log_file") + pid = tunnel_cfg.get("pid") + + if pid and is_process_alive(pid): + self.pid = pid + self.status = tunnel_cfg.get("status", "running") + debug_log(f"Detected existing cloudflared process (pid={pid})") + else: + # Clear stale info + self._persist_state(status="stopped", public_url="", pid=None, log_file=None) + self.status = "stopped" + self.pid = None + + # --- Process output handling --- + def _append_log(self, line): + if self.log_file: + try: + with open(self.log_file, "a", encoding="utf-8", errors="replace") as f: + f.write(line + "\n") + except Exception as exc: # pragma: no cover - best effort logging + debug_log(f"Failed to write tunnel log: {exc}") + + self._recent_logs.append(line) + # Keep last ~200 lines to avoid unbounded growth + if len(self._recent_logs) > 200: + self._recent_logs = self._recent_logs[-200:] + + def _reader(self): + assert self.process is not None + loop = self._loop + for raw_line in iter(self.process.stdout.readline, ""): + line = raw_line.strip() + if not line: + continue + + self._append_log(line) + match = PUBLIC_URL_PATTERN.search(line) + if match and not self.public_url: + self.public_url = match.group(1).rstrip("/") + self.status = "running" + if self._url_event and loop: + loop.call_soon_threadsafe(self._url_event.set) + + # Capture obvious error strings + if "error" in line.lower() and not self.last_error: + self.last_error = line + + # Process exited + if self.status == "starting" and not self.public_url: + self.status = "error" + if not self.last_error: + self.last_error = "Cloudflare tunnel exited before becoming ready" + if self._url_event and loop: + loop.call_soon_threadsafe(self._url_event.set) + elif self.status == "running": + # Treat unexpected exit as stopped so UI can retry + self.status = "stopped" + elif self.status == "stopped": + # Normal stop path, nothing to do + pass + + # --- Public API --- + async def start_tunnel(self): + async with self._lock: + if self.process and self.process.poll() is None: + return { + "status": self.status, + "public_url": self.public_url, + "pid": self.process.pid, + "log_file": self.log_file, + } + + # If we had a stale pid, ensure it's not running + if self.pid and is_process_alive(self.pid): + debug_log(f"Stopping stale cloudflared pid {self.pid} before starting a new one") + await self.stop_tunnel() + + binary = await asyncio.to_thread(self.ensure_binary) + port = get_server_port() + self.status = "starting" + self.last_error = None + self.public_url = None + self._recent_logs = [] + + # Remember current master host so we can restore it if the tunnel stops + config = load_config() + master_host = (config.get("master") or {}).get("host") or "" + tunnel_cfg = config.get("tunnel") or {} + if tunnel_cfg.get("previous_master_host"): + self.previous_master_host = tunnel_cfg.get("previous_master_host") + else: + self.previous_master_host = master_host + + os.makedirs(os.path.join(self.base_dir, "logs"), exist_ok=True) + timestamp = time.strftime("%Y%m%d-%H%M%S") + self.log_file = os.path.join(self.base_dir, "logs", f"cloudflare-{timestamp}.log") + + cmd = [ + binary, + "tunnel", + "--no-autoupdate", + "--url", + f"http://127.0.0.1:{port}", + ] + + debug_log(f"Starting cloudflared: {' '.join(cmd)}") + try: + self.process = subprocess.Popen( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + except FileNotFoundError: + self.status = "error" + raise RuntimeError("cloudflared binary not found") + except Exception as exc: + self.status = "error" + raise RuntimeError(f"Failed to start cloudflared: {exc}") from exc + + self.pid = self.process.pid + self._persist_state( + status="starting", + pid=self.pid, + log_file=self.log_file, + previous_host=self.previous_master_host + ) + + # Kick off background reader + self._loop = asyncio.get_running_loop() + self._url_event = asyncio.Event() + self._reader_thread = threading.Thread(target=self._reader, daemon=True) + self._reader_thread.start() + + try: + await asyncio.wait_for(self._url_event.wait(), timeout=TUNNEL_START_TIMEOUT) + except asyncio.TimeoutError: + self.last_error = "Timed out waiting for Cloudflare to assign a URL" + await self.stop_tunnel() + raise RuntimeError(self.last_error) + + if self.status != "running" or not self.public_url: + error_msg = self.last_error or "Cloudflare tunnel failed to start" + await self.stop_tunnel() + raise RuntimeError(error_msg) + + debug_log(f"Cloudflare tunnel ready at {self.public_url}") + self._persist_state( + status="running", + public_url=self.public_url, + pid=self.pid, + log_file=self.log_file, + previous_host=self.previous_master_host or "", + master_host=self.public_url + ) + return { + "status": self.status, + "public_url": self.public_url, + "pid": self.pid, + "log_file": self.log_file, + } + + async def stop_tunnel(self): + async with self._lock: + pid = self.process.pid if self.process else self.pid + if not pid: + self._persist_state(status="stopped", public_url=None, pid=None, log_file=None) + self.status = "stopped" + return {"status": "stopped"} + + debug_log(f"Stopping cloudflared (pid={pid})") + if self.process: + terminate_process(self.process, timeout=5) + else: + try: + os.kill(pid, signal.SIGTERM) + time.sleep(0.5) + except Exception as exc: # pragma: no cover - best effort + debug_log(f"Error stopping cloudflared pid {pid}: {exc}") + + config = load_config() + tunnel_cfg = config.get("tunnel", {}) if isinstance(config.get("tunnel", {}), dict) else {} + self.previous_master_host = tunnel_cfg.get("previous_master_host", self.previous_master_host) + active_url = tunnel_cfg.get("public_url") + current_master_host = (config.get("master") or {}).get("host") + restore_host = None + if active_url and current_master_host == active_url: + restore_host = self.previous_master_host or "" + + self.status = "stopped" + self.public_url = None + self.pid = None + self.process = None + self.last_error = None + self._url_event = None + if self._reader_thread and self._reader_thread.is_alive(): + self._reader_thread.join(timeout=1) + self._reader_thread = None + self._persist_state( + status="stopped", + public_url="", + pid=None, + log_file=self.log_file, + previous_host=self.previous_master_host, + master_host=restore_host + ) + return {"status": "stopped"} + + def get_status(self): + alive = False + pid = self.process.pid if self.process else self.pid + if pid: + alive = is_process_alive(pid) + if not alive and self.status == "running": + self.status = "stopped" + + return { + "status": self.status, + "public_url": self.public_url, + "pid": pid, + "log_file": self.log_file, + "last_error": self.last_error, + "binary_path": self.binary_path or shutil.which("cloudflared"), + "recent_logs": self._recent_logs[-20:], + "previous_master_host": self.previous_master_host, + } + + +# Singleton tunnel manager +cloudflare_tunnel_manager = CloudflareTunnelManager() diff --git a/utils/config.py b/utils/config.py index 8c9aa49..9b3689a 100644 --- a/utils/config.py +++ b/utils/config.py @@ -20,6 +20,13 @@ def get_default_config(): "auto_launch_workers": False, "stop_workers_on_master_exit": True, "master_delegate_only": False + }, + "tunnel": { + "status": "stopped", + "public_url": "", + "pid": None, + "log_file": "", + "previous_master_host": "" } } @@ -28,7 +35,12 @@ def load_config(): if os.path.exists(CONFIG_FILE): try: with open(CONFIG_FILE, 'r') as f: - return json.load(f) + data = json.load(f) + defaults = get_default_config() + for key, value in defaults.items(): + if key not in data: + data[key] = value + return data except Exception as e: log(f"Error loading config, using defaults: {e}") return get_default_config() diff --git a/web/apiClient.js b/web/apiClient.js index b4fceaa..dbce641 100644 --- a/web/apiClient.js +++ b/web/apiClient.js @@ -141,6 +141,25 @@ export function createApiClient(baseUrl) { return Promise.allSettled( urls.map(url => this.checkStatus(url)) ); + }, + + // Cloudflare tunnel management + async startTunnel() { + return request('/distributed/tunnel/start', { + method: 'POST', + body: JSON.stringify({}) + }); + }, + + async stopTunnel() { + return request('/distributed/tunnel/stop', { + method: 'POST', + body: JSON.stringify({}) + }); + }, + + async getTunnelStatus() { + return request('/distributed/tunnel/status'); } }; -} \ No newline at end of file +} diff --git a/web/constants.js b/web/constants.js index df0d251..8d2aa60 100644 --- a/web/constants.js +++ b/web/constants.js @@ -91,6 +91,23 @@ export const PULSE_ANIMATION_CSS = ` opacity: 1; padding: 12px 0; } + + /* Cloudflare tunnel spinner */ + @keyframes tunnel-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + .tunnel-spinner { + width: 14px; + height: 14px; + border: 2px solid rgba(255, 255, 255, 0.35); + border-top-color: #fff; + border-radius: 50%; + display: inline-block; + animation: tunnel-spin 0.9s linear infinite; + margin-right: 8px; + vertical-align: middle; + } `; export const UI_STYLES = { @@ -150,4 +167,4 @@ export const TIMEOUTS = { // Background tasks LOG_REFRESH: 2000, // log auto-refresh interval IMAGE_CACHE_CLEAR: 30000 // delay before clearing image cache -}; \ No newline at end of file +}; diff --git a/web/executionUtils.js b/web/executionUtils.js index c53a016..b8a4bc1 100644 --- a/web/executionUtils.js +++ b/web/executionUtils.js @@ -96,13 +96,14 @@ export async function executeParallelDistributed(extension, promptWrapper) { extension.log(`Pre-flight check: ${activeWorkers.length} of ${enabledWorkers.length} workers are active`, "debug"); // Check if master host might be unreachable by workers (cloudflare tunnel down) - const masterHost = extension.config?.master?.host || ''; - const isCloudflareHost = /\.(trycloudflare\.com|cloudflare\.dev)$/i.test(masterHost); - + const masterUrl = extension.getMasterUrl(); + const masterHostForCheck = masterUrl.replace(/^https?:\/\//i, "").split("/")[0]; + const isCloudflareHost = /\.(trycloudflare\.com|cloudflare\.dev)$/i.test(masterHostForCheck); + if (isCloudflareHost && activeWorkers.length > 0) { // Try to verify if the cloudflare tunnel is actually up try { - const testUrl = `${window.location.protocol}//${masterHost}/prompt`; + const testUrl = masterUrl.endsWith("/prompt") ? masterUrl : `${masterUrl.replace(/\/$/, '')}/prompt`; const response = await fetch(testUrl, { method: 'GET', mode: 'cors', @@ -115,10 +116,10 @@ export async function executeParallelDistributed(extension, promptWrapper) { } } catch (error) { // Cloudflare tunnel appears to be down - extension.log(`Master host ${masterHost} is not reachable - cloudflare tunnel may be down`, "error"); + extension.log(`Master host ${masterUrl} is not reachable - cloudflare tunnel may be down`, "error"); if (extension.ui?.showCloudflareWarning) { - extension.ui.showCloudflareWarning(extension, masterHost); + extension.ui.showCloudflareWarning(extension, masterUrl); } // Stop execution - workers won't be able to send results back diff --git a/web/main.js b/web/main.js index f2c89ae..f8d4abe 100644 --- a/web/main.js +++ b/web/main.js @@ -17,6 +17,8 @@ class DistributedExtension { this.logAutoRefreshInterval = null; this.masterSettingsExpanded = false; this.app = app; // Store app reference for toast notifications + this.tunnelStatus = { status: "unknown" }; + this.tunnelElements = {}; // Initialize centralized state this.state = createStateManager(); @@ -146,6 +148,144 @@ class DistributedExtension { } } + _applyMasterHost(host) { + if (!host || !this.config) return; + if (!this.config.master) this.config.master = {}; + this.config.master.host = host; + const hostInput = document.getElementById('master-host'); + if (hostInput) { + hostInput.value = host; + } + } + + updateTunnelUIElements() { + const elements = this.tunnelElements || {}; + const status = (this.tunnelStatus?.status || "stopped").toLowerCase(); + const enableColor = "#665533"; // requested yellow/brown tone + const disableColor = "#7c4a4a"; // match worker delete button + const colors = { + running: disableColor, + starting: enableColor, + stopped: enableColor, + error: disableColor, + unknown: enableColor, + stopping: disableColor + }; + + if (elements.button) { + elements.button.disabled = status === "starting" || status === "stopping"; + if (status === "starting") { + elements.button.innerHTML = ` Starting...`; + elements.button.style.backgroundColor = enableColor; + } else if (status === "running") { + elements.button.textContent = "Disable Cloudflare Tunnel"; + elements.button.style.backgroundColor = disableColor; + } else if (status === "error") { + elements.button.textContent = "Retry Cloudflare Tunnel"; + elements.button.style.backgroundColor = disableColor; + } else { + elements.button.textContent = "Enable Cloudflare Tunnel"; + elements.button.style.backgroundColor = enableColor; + } + } + + if (elements.status) { + elements.status.textContent = status.toUpperCase(); + elements.status.style.backgroundColor = colors[status] || colors.stopped; + } + + if (elements.url) { + const url = this.tunnelStatus?.public_url; + if (url) { + elements.url.innerHTML = `${url}`; + } else { + elements.url.textContent = status === "starting" ? "Requesting public URL..." : "No tunnel active"; + } + } + + if (elements.copyBtn) { + const hasUrl = Boolean(this.tunnelStatus?.public_url); + elements.copyBtn.disabled = !hasUrl; + elements.copyBtn.style.opacity = hasUrl ? "1" : "0.5"; + } + } + + async refreshTunnelStatus() { + try { + const data = await this.api.getTunnelStatus(); + this.tunnelStatus = data.tunnel || { status: "stopped" }; + if (data.master_host !== undefined) { + this._applyMasterHost(data.master_host); + } + return this.tunnelStatus; + } catch (error) { + this.tunnelStatus = { status: "error", last_error: error.message }; + this.log("Failed to fetch tunnel status: " + error.message, "error"); + return this.tunnelStatus; + } finally { + this.updateTunnelUIElements(); + } + } + + async handleTunnelToggle(button) { + const currentStatus = (this.tunnelStatus?.status || "stopped").toLowerCase(); + if (currentStatus === "starting" || currentStatus === "stopping") { + return; + } + + const setStatus = (status) => { + this.tunnelStatus = { ...(this.tunnelStatus || {}), status }; + this.updateTunnelUIElements(); + }; + + if (currentStatus === "running") { + setStatus("stopping"); + try { + if (button) { + button.innerHTML = ` Stopping...`; + button.disabled = true; + } + const data = await this.api.stopTunnel(); + this.tunnelStatus = data.tunnel || { status: "stopped" }; + if (data.master_host !== undefined) { + this._applyMasterHost(data.master_host); + } + this.updateTunnelUIElements(); + this.ui.showToast(this.app, "info", "Cloudflare Tunnel Disabled", "Master address restored", 4000); + } catch (error) { + this.tunnelStatus = { status: "error", last_error: error.message }; + this.updateTunnelUIElements(); + this.ui.showToast(this.app, "error", "Failed to stop tunnel", error.message, 5000); + } finally { + if (button) button.disabled = false; + } + return; + } + + // Start tunnel + setStatus("starting"); + if (button) { + button.innerHTML = ` Starting...`; + button.disabled = true; + } + try { + const data = await this.api.startTunnel(); + this.tunnelStatus = data.tunnel || { status: "running" }; + if (data.master_host !== undefined) { + this._applyMasterHost(data.master_host); + } + this.updateTunnelUIElements(); + const url = data.tunnel?.public_url || data.master_host; + this.ui.showToast(this.app, "success", "Cloudflare Tunnel Ready", url || "Public URL created", 4500); + } catch (error) { + this.tunnelStatus = { status: "error", last_error: error.message }; + this.updateTunnelUIElements(); + this.ui.showToast(this.app, "error", "Failed to start tunnel", error.message, 5000); + } finally { + if (button) button.disabled = false; + } + } + async updateWorkerEnabled(workerId, enabled) { const worker = this.config.workers.find(w => w.id === workerId); if (worker) { diff --git a/web/sidebarRenderer.js b/web/sidebarRenderer.js index a8b42a3..1b3dde0 100644 --- a/web/sidebarRenderer.js +++ b/web/sidebarRenderer.js @@ -43,6 +43,8 @@ export async function renderSidebarContent(extension, el) { // Preload data outside render await extension.loadConfig(); await extension.loadManagedWorkers(); + await extension.refreshTunnelStatus(); + extension.tunnelElements = {}; el.innerHTML = ''; diff --git a/web/ui.js b/web/ui.js index 04d5a8d..f2c87b6 100644 --- a/web/ui.js +++ b/web/ui.js @@ -1084,6 +1084,14 @@ export class DistributedUI { const hostResult = this.createFormGroup("Host:", extension.config?.master?.host || "", "master-host", "text", "Auto-detect if empty"); settingsForm.appendChild(hostResult.group); + + // Cloudflare tunnel toggle (simple button inside master settings) + const tunnelBtn = this.createButton("Enable Cloudflare Tunnel", (e) => extension.handleTunnelToggle(e.target), "background-color: #665533;"); + tunnelBtn.id = "cloudflare-tunnel-button"; + tunnelBtn.style.cssText = BUTTON_STYLES.base + " background-color: #665533; margin: 4px 0 -5px 0;"; + settingsForm.appendChild(tunnelBtn); + extension.tunnelElements = { button: tunnelBtn }; + extension.updateTunnelUIElements(); const saveBtn = this.createButton("Save", async () => { const nameInput = document.getElementById('master-name');