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');