From c1010dcd3c4deb624a67a2fe14e9b8da2960267b Mon Sep 17 00:00:00 2001 From: Robert Wojciechowski Date: Mon, 23 Feb 2026 04:38:04 +0000 Subject: [PATCH] refactor(web): split main.js into focused modules and shared constants --- web/constants.js | 46 ++ web/executionUtils.js | 39 +- web/main.js | 942 ++--------------------------------------- web/sidebarRenderer.js | 199 +++------ web/tunnelManager.js | 134 ++++++ web/workerLifecycle.js | 561 ++++++++++++++++++++++++ web/workerSettings.js | 283 +++++++++++++ web/workerUtils.js | 14 +- 8 files changed, 1142 insertions(+), 1076 deletions(-) create mode 100644 web/tunnelManager.js create mode 100644 web/workerLifecycle.js create mode 100644 web/workerSettings.js diff --git a/web/constants.js b/web/constants.js index 8d2aa60..95f87c5 100644 --- a/web/constants.js +++ b/web/constants.js @@ -168,3 +168,49 @@ export const TIMEOUTS = { LOG_REFRESH: 2000, // log auto-refresh interval IMAGE_CACHE_CLEAR: 30000 // delay before clearing image cache }; + +export const ENDPOINTS = { + // ComfyUI core + PROMPT: '/prompt', + INTERRUPT: '/interrupt', + UPLOAD_IMAGE: '/upload/image', + SYSTEM_INFO: '/system_stats', + + // Distributed API + CONFIG: '/distributed/config', + UPDATE_WORKER: '/distributed/config/update_worker', + DELETE_WORKER: '/distributed/config/delete_worker', + UPDATE_SETTING: '/distributed/config/update_setting', + UPDATE_MASTER: '/distributed/config/update_master', + LAUNCH_WORKER: '/distributed/launch_worker', + STOP_WORKER: '/distributed/stop_worker', + MANAGED_WORKERS: '/distributed/managed_workers', + WORKER_LOG: '/distributed/worker_log', + CLEAR_LAUNCHING: '/distributed/worker/clear_launching', + PREPARE_JOB: '/distributed/prepare_job', + LOAD_IMAGE: '/distributed/load_image', + NETWORK_INFO: '/distributed/network_info', + CHECK_FILE: '/distributed/check_file', + CLEAR_MEMORY: '/distributed/clear_memory', + SYSTEM_INFO_DIST: '/distributed/system_info', + TUNNEL_START: '/distributed/tunnel/start', + TUNNEL_STOP: '/distributed/tunnel/stop', + TUNNEL_STATUS: '/distributed/tunnel/status', +}; + +export const NODE_CLASSES = { + DISTRIBUTED_COLLECTOR: 'DistributedCollector', + DISTRIBUTED_QUEUE: 'DistributedQueue', + DISTRIBUTED_SEED: 'DistributedSeed', + DISTRIBUTED_EMPTY_IMAGE: 'DistributedEmptyImage', + UPSCALE_DISTRIBUTED: 'UltimateSDUpscaleDistributed', + PREVIEW_IMAGE: 'PreviewImage', +}; + +export function generateUUID() { + if (crypto.randomUUID) return crypto.randomUUID(); + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => { + const r = Math.random() * 16 | 0; + return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); + }); +} diff --git a/web/executionUtils.js b/web/executionUtils.js index 483126b..4846454 100644 --- a/web/executionUtils.js +++ b/web/executionUtils.js @@ -1,6 +1,6 @@ import { api } from "../../scripts/api.js"; import { findNodesByClass, findImageReferences, hasUpstreamNode, pruneWorkflowForWorker, getCachedWorkerSystemInfo, findCollectorDownstreamNodes } from './workerUtils.js'; -import { TIMEOUTS } from './constants.js'; +import { TIMEOUTS, NODE_CLASSES, generateUUID } from './constants.js'; /** * Convert paths in the API prompt to match the target platform's separator @@ -58,9 +58,9 @@ function convertPathsForPlatform(apiPrompt, targetSeparator) { export function setupInterceptor(extension) { api.queuePrompt = async (number, prompt) => { if (extension.isEnabled) { - const hasCollector = findNodesByClass(prompt.output, "DistributedCollector").length > 0; - const hasDistUpscale = findNodesByClass(prompt.output, "UltimateSDUpscaleDistributed").length > 0; - const hasDistQueue = findNodesByClass(prompt.output, "DistributedQueue").length > 0; + const hasCollector = findNodesByClass(prompt.output, NODE_CLASSES.DISTRIBUTED_COLLECTOR).length > 0; + const hasDistUpscale = findNodesByClass(prompt.output, NODE_CLASSES.UPSCALE_DISTRIBUTED).length > 0; + const hasDistQueue = findNodesByClass(prompt.output, NODE_CLASSES.DISTRIBUTED_QUEUE).length > 0; if (hasCollector || hasDistUpscale) { const result = await executeParallelDistributed(extension, prompt); @@ -137,8 +137,8 @@ export async function executeParallelDistributed(extension, promptWrapper) { } // Find all distributed nodes in the workflow - const collectorNodes = findNodesByClass(promptWrapper.output, "DistributedCollector"); - const upscaleNodes = findNodesByClass(promptWrapper.output, "UltimateSDUpscaleDistributed"); + const collectorNodes = findNodesByClass(promptWrapper.output, NODE_CLASSES.DISTRIBUTED_COLLECTOR); + const upscaleNodes = findNodesByClass(promptWrapper.output, NODE_CLASSES.UPSCALE_DISTRIBUTED); const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; const wantsDelegate = Boolean(extension.config?.settings?.master_delegate_only); @@ -322,7 +322,7 @@ function selectLeastBusyWorker(extension, statuses) { function markSkipDispatch(promptObj) { for (const node of Object.values(promptObj)) { - if (node && typeof node === 'object' && node.class_type === 'DistributedQueue') { + if (node && typeof node === 'object' && node.class_type === NODE_CLASSES.DISTRIBUTED_QUEUE) { node.inputs = node.inputs || {}; node.inputs.skip_dispatch = true; } @@ -335,8 +335,8 @@ export async function prepareApiPromptForParticipant(extension, baseApiPrompt, p const delegateMaster = Boolean(options.delegate_master); // Find all distributed nodes once (before pruning) - let collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); - const upscaleNodes = findNodesByClass(jobApiPrompt, "UltimateSDUpscaleDistributed"); + let collectorNodes = findNodesByClass(jobApiPrompt, NODE_CLASSES.DISTRIBUTED_COLLECTOR); + const upscaleNodes = findNodesByClass(jobApiPrompt, NODE_CLASSES.UPSCALE_DISTRIBUTED); let allDistributedNodes = [...collectorNodes, ...upscaleNodes]; if (isMaster && delegateMaster) { @@ -346,7 +346,7 @@ export async function prepareApiPromptForParticipant(extension, baseApiPrompt, p extension.log("Delegate-only master mode requested but no collectors found in master prompt. Using full prompt.", "debug"); } else { jobApiPrompt = prepareMasterDelegatePrompt(extension, jobApiPrompt, collectorNodes); - collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); + collectorNodes = findNodesByClass(jobApiPrompt, NODE_CLASSES.DISTRIBUTED_COLLECTOR); allDistributedNodes = [...collectorNodes, ...upscaleNodes]; } } @@ -400,7 +400,7 @@ export async function prepareApiPromptForParticipant(extension, baseApiPrompt, p } // Handle Distributed seed nodes - const distributorNodes = findNodesByClass(jobApiPrompt, "DistributedSeed"); + const distributorNodes = findNodesByClass(jobApiPrompt, NODE_CLASSES.DISTRIBUTED_SEED); if (distributorNodes.length > 0) { extension.log(`Found ${distributorNodes.length} seed node(s)`, "debug"); } @@ -423,7 +423,7 @@ export async function prepareApiPromptForParticipant(extension, baseApiPrompt, p const hasUpstreamDistributedUpscaler = hasUpstreamNode( jobApiPrompt, collector.id, - 'UltimateSDUpscaleDistributed' + NODE_CLASSES.UPSCALE_DISTRIBUTED ); if (hasUpstreamDistributedUpscaler) { @@ -536,7 +536,7 @@ function prepareMasterDelegatePrompt(extension, apiPrompt, collectorNodes) { const placeholderId = nextId(); prunedPrompt[placeholderId] = { - class_type: "DistributedEmptyImage", + class_type: NODE_CLASSES.DISTRIBUTED_EMPTY_IMAGE, inputs: { height: 64, width: 64, @@ -660,19 +660,8 @@ function buildWorkerWebSocketUrl(workerUrl) { return url.toString(); } -function generateRequestId() { - if (crypto.randomUUID) { - return crypto.randomUUID(); - } - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); -} - async function dispatchPromptViaWebSocket(extension, wsUrl, payload) { - const requestId = generateRequestId(); + const requestId = generateUUID(); const message = { type: 'dispatch_prompt', request_id: requestId, diff --git a/web/main.js b/web/main.js index 47566ec..8281342 100644 --- a/web/main.js +++ b/web/main.js @@ -5,9 +5,12 @@ import { DistributedUI } from './ui.js'; import { createStateManager } from './stateManager.js'; import { createApiClient } from './apiClient.js'; import { renderSidebarContent } from './sidebarRenderer.js'; -import { handleWorkerOperation, handleInterruptWorkers, handleClearMemory } from './workerUtils.js'; +import { handleInterruptWorkers, handleClearMemory } from './workerUtils.js'; import { setupInterceptor, executeParallelDistributed } from './executionUtils.js'; -import { BUTTON_STYLES, PULSE_ANIMATION_CSS, TIMEOUTS, STATUS_COLORS } from './constants.js'; +import { BUTTON_STYLES, PULSE_ANIMATION_CSS, TIMEOUTS, STATUS_COLORS, generateUUID } from './constants.js'; +import { updateTunnelUIElements, refreshTunnelStatus, handleTunnelToggle } from './tunnelManager.js'; +import { checkAllWorkerStatuses, checkMasterStatus, getWorkerUrl, checkWorkerStatus, launchWorker, stopWorker, clearLaunchingFlag, loadManagedWorkers, updateWorkerControls, viewWorkerLog, refreshLog, startLogAutoRefresh, stopLogAutoRefresh, toggleWorkerExpanded } from './workerLifecycle.js'; +import { isRemoteWorker, isCloudWorker, saveWorkerSettings, cancelWorkerSettings, deleteWorker, addNewWorker } from './workerSettings.js'; class DistributedExtension { constructor() { @@ -59,19 +62,6 @@ class DistributedExtension { } } - // Generate UUID with fallback for non-secure contexts - generateUUID() { - if (crypto.randomUUID) { - return crypto.randomUUID(); - } - // Fallback for non-secure contexts - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c == 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); - } - injectStyles() { const styleId = 'distributed-styles'; if (!document.getElementById(styleId)) { @@ -176,132 +166,16 @@ class DistributedExtension { } } - 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"; - } + updateTunnelUIElements(isRunning, isStarting) { + return updateTunnelUIElements(this, isRunning, isStarting); } 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(); - } + return refreshTunnelStatus(this); } 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; - } + return handleTunnelToggle(this, button); } async updateWorkerEnabled(workerId, enabled) { @@ -435,342 +309,32 @@ class DistributedExtension { } async checkAllWorkerStatuses() { - // Don't continue if panel is closed - if (!this.panelElement) return; - - // Create new abort controller for this round of checks - this.statusCheckAbortController = new AbortController(); - - - // Check master status - this.checkMasterStatus(); - - if (!this.config || !this.config.workers) return; - - for (const worker of this.config.workers) { - // Check status for enabled workers OR workers that are launching - if (worker.enabled || this.state.isWorkerLaunching(worker.id)) { - this.checkWorkerStatus(worker); - } - } - - // Determine next interval based on current state - let isActive = this.state.getMasterStatus() === 'processing'; // Master is busy - - // Check workers for activity - this.config.workers.forEach(worker => { - const ws = this.state.getWorker(worker.id); // Get worker state - if (ws.launching || ws.status?.processing) { // Launching or processing - isActive = true; - } - }); - - // Set next delay: 1s if active, 5s if idle - const nextInterval = isActive ? 1000 : 5000; - - // Schedule the next check - this.statusCheckTimeout = setTimeout(() => this.checkAllWorkerStatuses(), nextInterval); + return checkAllWorkerStatuses(this); } async checkMasterStatus() { - try { - const response = await fetch(`${window.location.origin}/prompt`, { - method: 'GET', - signal: AbortSignal.timeout(TIMEOUTS.STATUS_CHECK) - }); - - if (response.ok) { - const data = await response.json(); - const queueRemaining = data.exec_info?.queue_remaining || 0; - const isProcessing = queueRemaining > 0; - - // Update master status in state - this.state.setMasterStatus(isProcessing ? 'processing' : 'online'); - - // Update master status dot - const statusDot = document.getElementById('master-status'); - if (statusDot) { - if (!this.isMasterParticipating()) { - if (isProcessing) { - statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; - statusDot.title = `Orchestrating (${queueRemaining} in queue)`; - } else { - statusDot.style.backgroundColor = STATUS_COLORS.DISABLED_GRAY; - statusDot.title = "Master orchestrator only"; - } - } else if (isProcessing) { - statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; - statusDot.title = `Processing (${queueRemaining} in queue)`; - } else { - statusDot.style.backgroundColor = STATUS_COLORS.ONLINE_GREEN; - statusDot.title = "Online"; - } - } - } - } catch (error) { - // Master is always online (we're running on it), so keep it green - const statusDot = document.getElementById('master-status'); - if (statusDot) { - statusDot.style.backgroundColor = this.isMasterParticipating() ? STATUS_COLORS.ONLINE_GREEN : STATUS_COLORS.DISABLED_GRAY; - statusDot.title = this.isMasterParticipating() ? "Online" : "Master orchestrator only"; - } - } + return checkMasterStatus(this); } // Helper to build worker URL getWorkerUrl(worker, endpoint = '') { - const parsed = this._parseHostInput(worker.host || window.location.hostname); - const host = parsed.host || window.location.hostname; - const resolvedPort = parsed.port || worker.port; - - // Cloud workers always use HTTPS - const isCloud = worker.type === 'cloud'; - - // Detect if we're running on Runpod (for local workers on Runpod infrastructure) - const isRunpodProxy = host.endsWith('.proxy.runpod.net'); - - // For local workers on Runpod, construct the port-specific proxy URL - let finalHost = host; - if (!worker.host && isRunpodProxy) { - const match = host.match(/^(.*)\.proxy\.runpod\.net$/); - if (match) { - const podId = match[1]; - const domain = 'proxy.runpod.net'; - finalHost = `${podId}-${worker.port}.${domain}`; - } else { - // Fallback or log error if no match (shouldn't happen) - console.error(`[Distributed] Failed to parse Runpod proxy host: ${host}`); - } - } - - // Determine protocol: HTTPS for cloud, Runpod proxies, or port 443 - const useHttps = isCloud || isRunpodProxy || resolvedPort === 443; - const protocol = useHttps ? 'https' : 'http'; - - // Only add port if non-standard - const defaultPort = useHttps ? 443 : 80; - const needsPort = !isRunpodProxy && resolvedPort !== defaultPort; - const portStr = needsPort ? `:${resolvedPort}` : ''; - - return `${protocol}://${finalHost}${portStr}${endpoint}`; + return getWorkerUrl(this, worker, endpoint); } async checkWorkerStatus(worker) { - // Assume caller ensured enabled; proceed with check - const url = this.getWorkerUrl(worker, '/prompt'); - const statusDot = document.getElementById(`status-${worker.id}`); - - try { - // Combine timeout with abort controller signal - const timeoutSignal = AbortSignal.timeout(TIMEOUTS.STATUS_CHECK); - const signal = this.statusCheckAbortController - ? AbortSignal.any([timeoutSignal, this.statusCheckAbortController.signal]) - : timeoutSignal; - - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - signal: signal - }); - - if (response.ok) { - const data = await response.json(); - const queueRemaining = data.exec_info?.queue_remaining || 0; - const isProcessing = queueRemaining > 0; - - // Update status - this.state.setWorkerStatus(worker.id, { - online: true, - processing: isProcessing, - queueCount: queueRemaining - }); - - // Update status dot based on processing state - if (isProcessing) { - this.ui.updateStatusDot( - worker.id, - "#f0ad4e", - `Online - Processing (${queueRemaining} in queue)`, - false - ); - } else { - this.ui.updateStatusDot(worker.id, "#3ca03c", "Online - Idle", false); - } - - // Clear launching state since worker is now online - if (this.state.isWorkerLaunching(worker.id)) { - this.state.setWorkerLaunching(worker.id, false); - this.clearLaunchingFlag(worker.id); - } - } else { - throw new Error(`HTTP ${response.status}`); - } - } catch (error) { - // Don't process aborted requests - if (error.name === 'AbortError') { - return; - } - - // Worker is offline or unreachable - this.state.setWorkerStatus(worker.id, { - online: false, - processing: false, - queueCount: 0 - }); - - // Check if worker is launching - if (this.state.isWorkerLaunching(worker.id)) { - this.ui.updateStatusDot(worker.id, "#f0ad4e", "Launching...", true); - } else if (worker.enabled) { - // Only update to red if not currently launching AND still enabled - this.ui.updateStatusDot(worker.id, "#c04c4c", "Offline - Cannot connect", false); - } - // If disabled, don't update the dot (leave it gray) - - this.log(`Worker ${worker.id} status check failed: ${error.message}`, "debug"); - } - - // Update control buttons based on new status - this.updateWorkerControls(worker.id); + return checkWorkerStatus(this, worker); } async launchWorker(workerId) { - const worker = this.config.workers.find(w => w.id === workerId); - - // If worker is disabled, enable it first - if (!worker.enabled) { - await this.updateWorkerEnabled(workerId, true); - - // Update the checkbox UI - const checkbox = document.getElementById(`gpu-${workerId}`); - if (checkbox) { - checkbox.checked = true; - } - } - - // Re-query button AFTER updateWorkerEnabled (which may re-render sidebar) - const launchBtn = document.querySelector(`#controls-${workerId} button`); - - this.ui.updateStatusDot(workerId, "#f0ad4e", "Launching...", true); - this.state.setWorkerLaunching(workerId, true); - - // Allow 90 seconds for worker to launch (model loading can take time) - setTimeout(() => { - this.state.setWorkerLaunching(workerId, false); - }, TIMEOUTS.LAUNCH); - - if (!launchBtn) return; - - try { - // Disable button immediately - launchBtn.disabled = true; - - const result = await this.api.launchWorker(workerId); - if (result) { - this.log(`Launched ${worker.name} (PID: ${result.pid})`, "info"); - if (result.log_file) { - this.log(`Log file: ${result.log_file}`, "debug"); - } - - this.state.setWorkerManaged(workerId, { - pid: result.pid, - log_file: result.log_file, - started_at: Date.now() - }); - - // Update controls immediately to hide launch button and show stop/log buttons - this.updateWorkerControls(workerId); - setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK); - } - } catch (error) { - // Check if worker was already running - if (error.message && error.message.includes("already running")) { - this.log(`Worker ${worker.name} is already running`, "info"); - this.updateWorkerControls(workerId); - setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK_DELAY); - } else { - this.log(`Error launching worker: ${error.message || error}`, "error"); - - // Re-enable button on error - if (launchBtn) { - launchBtn.disabled = false; - } - } - } + return launchWorker(this, workerId); } async stopWorker(workerId) { - const worker = this.config.workers.find(w => w.id === workerId); - const stopBtn = document.querySelectorAll(`#controls-${workerId} button`)[1]; - - // Provide immediate feedback - if (stopBtn) { - stopBtn.disabled = true; - stopBtn.textContent = "Stopping..."; - stopBtn.style.backgroundColor = "#666"; - } - - try { - const result = await this.api.stopWorker(workerId); - if (result) { - this.log(`Stopped worker: ${result.message}`, "info"); - this.state.setWorkerManaged(workerId, null); - - // Immediately update status to offline - this.ui.updateStatusDot(workerId, "#c04c4c", "Offline"); - this.state.setWorkerStatus(workerId, { online: false }); - - // Flash success feedback - if (stopBtn) { - stopBtn.style.backgroundColor = BUTTON_STYLES.success; - stopBtn.textContent = "Stopped!"; - setTimeout(() => { - this.updateWorkerControls(workerId); - }, TIMEOUTS.FLASH_SHORT); - } - - // Verify status after a short delay - setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK); - } else { - this.log(`Failed to stop worker: ${result.message}`, "error"); - - // Flash error feedback - if (stopBtn) { - stopBtn.style.backgroundColor = BUTTON_STYLES.error; - stopBtn.textContent = result.message.includes("already stopped") ? "Not Running" : "Failed"; - - // If already stopped, update status immediately - if (result.message.includes("already stopped")) { - this.ui.updateStatusDot(workerId, "#c04c4c", "Offline"); - this.state.setWorkerStatus(workerId, { online: false }); - } - - setTimeout(() => { - this.updateWorkerControls(workerId); - }, TIMEOUTS.FLASH_MEDIUM); - } - } - } catch (error) { - this.log(`Error stopping worker: ${error}`, "error"); - - // Reset button on error - if (stopBtn) { - stopBtn.style.backgroundColor = BUTTON_STYLES.error; - stopBtn.textContent = "Error"; - setTimeout(() => { - this.updateWorkerControls(workerId); - }, TIMEOUTS.FLASH_MEDIUM); - } - } + return stopWorker(this, workerId); } async clearLaunchingFlag(workerId) { - try { - await this.api.clearLaunchingFlag(workerId); - this.log(`Cleared launching flag for worker ${workerId}`, "debug"); - } catch (error) { - this.log(`Error clearing launching flag: ${error.message || error}`, "error"); - } + return clearLaunchingFlag(this, workerId); } // Generic async button action handler @@ -822,185 +386,27 @@ class DistributedExtension { } async loadManagedWorkers() { - try { - const result = await this.api.getManagedWorkers(); - - // Check for launching workers - for (const [workerId, info] of Object.entries(result.managed_workers)) { - this.state.setWorkerManaged(workerId, info); - - // If worker is marked as launching, add to launchingWorkers set - if (info.launching) { - this.state.setWorkerLaunching(workerId, true); - this.log(`Worker ${workerId} is in launching state`, "debug"); - } - } - - // Update UI for all workers - if (this.config?.workers) { - this.config.workers.forEach(w => this.updateWorkerControls(w.id)); - } - } catch (error) { - this.log(`Error loading managed workers: ${error}`, "error"); - } + return loadManagedWorkers(this); } updateWorkerControls(workerId) { - const controlsDiv = document.getElementById(`controls-${workerId}`); - - if (!controlsDiv) return; - - const worker = this.config.workers.find(w => w.id === workerId); - if (!worker) return; - - // Skip button updates for remote workers - if (this.isRemoteWorker(worker)) { - return; - } - - // Ensure we check for string ID - const managedInfo = this.state.getWorker(workerId).managed; - const status = this.state.getWorkerStatus(workerId); - - // Update button states - buttons are now inside a wrapper div - const buttons = controlsDiv.querySelectorAll('button'); - const launchBtn = document.getElementById(`launch-${workerId}`); - const stopBtn = document.getElementById(`stop-${workerId}`); - const logBtn = document.getElementById(`log-${workerId}`); - - // Show log button immediately if we have log file info (even if worker is still starting) - if (managedInfo?.log_file && logBtn) { - logBtn.style.display = ''; - } else if (logBtn && !managedInfo) { - logBtn.style.display = 'none'; - } - - if (status?.online || managedInfo) { - // Worker is running or we just launched it - launchBtn.style.display = 'none'; // Hide launch button when running - - if (managedInfo) { - // Only show stop button if we manage this worker - stopBtn.style.display = ''; - stopBtn.disabled = false; - stopBtn.textContent = "Stop"; - stopBtn.style.backgroundColor = "#7c4a4a"; // Red when enabled - } else { - // Hide stop button for workers launched outside UI - stopBtn.style.display = 'none'; - } - } else { - // Worker is not running - launchBtn.style.display = ''; // Show launch button - launchBtn.disabled = false; - launchBtn.textContent = "Launch"; - launchBtn.style.backgroundColor = "#4a7c4a"; // Always green - - stopBtn.style.display = 'none'; // Hide stop button when not running - } + return updateWorkerControls(this, workerId); } async viewWorkerLog(workerId) { - const managedInfo = this.state.getWorker(workerId).managed; - if (!managedInfo?.log_file) return; - - const logBtn = document.getElementById(`log-${workerId}`); - - // Provide immediate feedback - if (logBtn) { - logBtn.disabled = true; - logBtn.textContent = "Loading..."; - logBtn.style.backgroundColor = "#666"; - } - - try { - // Fetch log content - const data = await this.api.getWorkerLog(workerId, 1000); - - // Create modal dialog - this.ui.showLogModal(this, workerId, data); - - // Restore button - if (logBtn) { - logBtn.disabled = false; - logBtn.textContent = "View Log"; - logBtn.style.backgroundColor = "#685434"; // Keep the yellow color - } - - } catch (error) { - this.log('Error viewing log: ' + error.message, "error"); - app.extensionManager.toast.add({ - severity: "error", - summary: "Error", - detail: `Failed to load log: ${error.message}`, - life: 5000 - }); - - // Flash error and restore button - if (logBtn) { - logBtn.style.backgroundColor = BUTTON_STYLES.error; - logBtn.textContent = "Error"; - setTimeout(() => { - logBtn.disabled = false; - logBtn.textContent = "View Log"; - logBtn.style.backgroundColor = "#685434"; // Keep the yellow color - }, TIMEOUTS.FLASH_LONG); - } - } + return viewWorkerLog(this, workerId); } async refreshLog(workerId, silent = false) { - const logContent = document.getElementById('distributed-log-content'); - if (!logContent) return; - - try { - const data = await this.api.getWorkerLog(workerId, 1000); - - // Update content - const shouldAutoScroll = logContent.scrollTop + logContent.clientHeight >= logContent.scrollHeight - 50; - logContent.textContent = data.content; - - // Auto-scroll if was at bottom - if (shouldAutoScroll) { - logContent.scrollTop = logContent.scrollHeight; - } - - // Only show toast if not in silent mode (manual refresh) - if (!silent) { - app.extensionManager.toast.add({ - severity: "success", - summary: "Log Refreshed", - detail: "Log content updated", - life: 2000 - }); - } - - } catch (error) { - // Only show error toast if not in silent mode - if (!silent) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Refresh Failed", - detail: error.message, - life: 3000 - }); - } - } + return refreshLog(this, workerId, silent); } isRemoteWorker(worker) { - // Check if explicitly marked as cloud worker - if (worker.type === "cloud") { - return true; - } - // Otherwise check by host (backward compatibility) - const parsed = this._parseHostInput(worker.host || window.location.hostname); - const host = parsed.host || window.location.hostname; - return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname; + return isRemoteWorker(this, worker); } isCloudWorker(worker) { - return worker.type === "cloud"; + return isCloudWorker(this, worker); } getMasterUrl() { @@ -1101,7 +507,7 @@ class DistributedExtension { } const worker = { - id: crypto.randomUUID(), + id: generateUUID(), name: `Worker ${workerNum}`, host: isRunpod ? null : "localhost", port: 8189 + portOffset, @@ -1207,319 +613,31 @@ class DistributedExtension { } async saveWorkerSettings(workerId) { - const worker = this.config.workers.find(w => w.id === workerId); - if (!worker) return; - - // Get form values - const name = document.getElementById(`name-${workerId}`).value; - const workerType = document.getElementById(`worker-type-${workerId}`).value; - const isRemote = workerType === 'remote' || workerType === 'cloud'; - const isCloud = workerType === 'cloud'; - const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname; - const parsedHost = isRemote ? this._parseHostInput(rawHost) : { host: window.location.hostname, port: null }; - const host = isRemote ? parsedHost.host : window.location.hostname; - let port = parseInt(document.getElementById(`port-${workerId}`).value); - const cudaDevice = isRemote ? undefined : parseInt(document.getElementById(`cuda-${workerId}`).value); - const extraArgs = isRemote ? undefined : document.getElementById(`args-${workerId}`).value; - - if (isRemote && Number.isFinite(parsedHost.port)) { - port = parsedHost.port; - } - - // Validate - if (!name.trim()) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Validation Error", - detail: "Worker name is required", - life: 3000 - }); - return; - } - - if ((workerType === 'remote' || workerType === 'cloud') && !host.trim()) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Validation Error", - detail: "Host is required for remote workers", - life: 3000 - }); - return; - } - - if (!isCloud && (isNaN(port) || port < 1 || port > 65535)) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Validation Error", - detail: "Port must be between 1 and 65535", - life: 3000 - }); - return; - } - - // Check for port conflicts - // Remote workers can reuse ports, but local workers cannot share ports with each other or master - if (!isRemote) { - // Check if port conflicts with master - const masterPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80); - if (port === masterPort) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Port Conflict", - detail: `Port ${port} is already in use by the master server`, - life: 3000 - }); - return; - } - - // Check if port conflicts with other local workers - const localPortConflict = this.config.workers.some(w => - w.id !== workerId && - w.port === port && - !w.host // local workers have no host or host is null - ); - - if (localPortConflict) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Port Conflict", - detail: `Port ${port} is already in use by another local worker`, - life: 3000 - }); - return; - } - } else { - // For remote workers, only check conflicts with other workers on the same host - const sameHostConflict = this.config.workers.some(w => - w.id !== workerId && - w.port === port && - w.host === host.trim() - ); - - if (sameHostConflict) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Port Conflict", - detail: `Port ${port} is already in use by another worker on ${host}`, - life: 3000 - }); - return; - } - } - - try { - await this.api.updateWorker(workerId, { - name: name.trim(), - type: workerType, - host: isRemote ? host.trim() : null, - port: port, - cuda_device: isRemote ? null : cudaDevice, - extra_args: isRemote ? null : (extraArgs ? extraArgs.trim() : "") - }); - - // Update local config - worker.name = name.trim(); - worker.type = workerType; - if (isRemote) { - worker.host = host.trim(); - delete worker.cuda_device; - delete worker.extra_args; - } else { - delete worker.host; - worker.cuda_device = cudaDevice; - worker.extra_args = extraArgs ? extraArgs.trim() : ""; - } - worker.port = port; - - // Sync to state - this.state.updateWorker(workerId, { enabled: worker.enabled }); - - app.extensionManager.toast.add({ - severity: "success", - summary: "Settings Saved", - detail: `Worker ${name} settings updated`, - life: 3000 - }); - - // Refresh the UI - if (this.panelElement) { - renderSidebarContent(this, this.panelElement); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Save Failed", - detail: error.message, - life: 5000 - }); - } + return saveWorkerSettings(this, workerId); } cancelWorkerSettings(workerId) { - // Collapse the settings panel - this.toggleWorkerExpanded(workerId); - - // Reset form values to original - const worker = this.config.workers.find(w => w.id === workerId); - if (worker) { - document.getElementById(`name-${workerId}`).value = worker.name; - document.getElementById(`host-${workerId}`).value = worker.host || ""; - document.getElementById(`port-${workerId}`).value = worker.port; - document.getElementById(`cuda-${workerId}`).value = worker.cuda_device || 0; - document.getElementById(`args-${workerId}`).value = worker.extra_args || ""; - - // Reset remote checkbox - const remoteCheckbox = document.getElementById(`remote-${workerId}`); - if (remoteCheckbox) { - remoteCheckbox.checked = this.isRemoteWorker(worker); - } - } + return cancelWorkerSettings(this, workerId); } async deleteWorker(workerId) { - const worker = this.config.workers.find(w => w.id === workerId); - if (!worker) return; - - // Confirm deletion - if (!confirm(`Are you sure you want to delete worker "${worker.name}"?`)) { - return; - } - - try { - await this.api.deleteWorker(workerId); - - // Remove from local config - const index = this.config.workers.findIndex(w => w.id === workerId); - if (index !== -1) { - this.config.workers.splice(index, 1); - } - - app.extensionManager.toast.add({ - severity: "success", - summary: "Worker Deleted", - detail: `Worker ${worker.name} has been removed`, - life: 3000 - }); - - // Refresh the UI - if (this.panelElement) { - renderSidebarContent(this, this.panelElement); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Delete Failed", - detail: error.message, - life: 5000 - }); - } + return deleteWorker(this, workerId); } async addNewWorker() { - // Generate new worker ID using UUID (fallback for non-secure contexts) - const newId = this.generateUUID(); - - // Find next available port - const usedPorts = this.config.workers.map(w => w.port); - let nextPort = 8189; - while (usedPorts.includes(nextPort)) { - nextPort++; - } - - // Create new worker object - const newWorker = { - id: newId, - name: `Worker ${this.config.workers.length + 1}`, - port: nextPort, - cuda_device: this.config.workers.length, - enabled: true, // Default to enabled for convenience - extra_args: "" - }; - - // Add to config - this.config.workers.push(newWorker); - - // Save immediately - try { - await this.api.updateWorker(newId, { - name: newWorker.name, - port: newWorker.port, - cuda_device: newWorker.cuda_device, - extra_args: newWorker.extra_args, - enabled: newWorker.enabled - }); - - // Sync to state - this.state.updateWorker(newId, { enabled: true }); - - app.extensionManager.toast.add({ - severity: "success", - summary: "Worker Added", - detail: `New worker created on port ${nextPort}`, - life: 3000 - }); - - // Refresh UI and expand the new worker - this.state.setWorkerExpanded(newId, true); - if (this.panelElement) { - renderSidebarContent(this, this.panelElement); - } - - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Failed to Add Worker", - detail: error.message, - life: 5000 - }); - } + return addNewWorker(this); } startLogAutoRefresh(workerId) { - // Stop any existing auto-refresh - this.stopLogAutoRefresh(); - - // Refresh every 2 seconds - this.logAutoRefreshInterval = setInterval(() => { - this.refreshLog(workerId, true); // silent mode - }, TIMEOUTS.LOG_REFRESH); + return startLogAutoRefresh(this, workerId); } stopLogAutoRefresh() { - if (this.logAutoRefreshInterval) { - clearInterval(this.logAutoRefreshInterval); - this.logAutoRefreshInterval = null; - } + return stopLogAutoRefresh(this); } toggleWorkerExpanded(workerId) { - const settingsDiv = document.getElementById(`settings-${workerId}`); - const gpuDiv = settingsDiv.closest('[style*="margin-bottom: 12px"]'); - const settingsArrow = gpuDiv.querySelector('.settings-arrow'); - - if (!settingsDiv) return; - - if (this.state.isWorkerExpanded(workerId)) { - this.state.setWorkerExpanded(workerId, false); - settingsDiv.classList.remove("expanded"); - if (settingsArrow) { - settingsArrow.style.transform = "rotate(0deg)"; - } - // Animate padding to 0 - settingsDiv.style.padding = "0 12px"; - settingsDiv.style.marginTop = "0"; - settingsDiv.style.marginBottom = "0"; - } else { - this.state.setWorkerExpanded(workerId, true); - settingsDiv.classList.add("expanded"); - if (settingsArrow) { - settingsArrow.style.transform = "rotate(90deg)"; - } - // Animate padding to full - settingsDiv.style.padding = "12px"; - settingsDiv.style.marginTop = "8px"; - settingsDiv.style.marginBottom = "8px"; - } + return toggleWorkerExpanded(this, workerId); } _handleInterruptWorkers(button) { diff --git a/web/sidebarRenderer.js b/web/sidebarRenderer.js index 1b3dde0..e92e44e 100644 --- a/web/sidebarRenderer.js +++ b/web/sidebarRenderer.js @@ -1,25 +1,57 @@ import { BUTTON_STYLES } from './constants.js'; - export async function renderSidebarContent(extension, el) { // Panel is being opened/rendered extension.log("Panel opened", "debug"); - if (!el) { extension.log("No element provided to renderSidebarContent", "debug"); return; } - // Prevent infinite recursion if (extension._isRendering) { extension.log("Already rendering, skipping", "debug"); return; } extension._isRendering = true; - + function createCheckboxSetting(id, label, tooltip, checked, onChange) { + const group = document.createElement("div"); + group.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 8px;"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = id; + checkbox.checked = checked; + checkbox.onchange = onChange; + const lbl = document.createElement("label"); + lbl.htmlFor = id; + lbl.textContent = label; + lbl.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; + if (tooltip) lbl.title = tooltip; + group.appendChild(checkbox); + group.appendChild(lbl); + return group; + } + function createNumberSetting(id, label, tooltip, value, min, step, onChange) { + const group = document.createElement("div"); + group.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 6px;"; + const lbl = document.createElement("label"); + lbl.htmlFor = id; + lbl.textContent = label; + lbl.style.cssText = "font-size: 12px; color: #ccc;"; + if (tooltip) lbl.title = tooltip; + const input = document.createElement("input"); + input.type = "number"; + input.id = id; + input.min = String(min); + input.step = String(step); + input.style.cssText = "width: 80px; padding: 2px 6px; background: #222; color: #ddd; border: 1px solid #333; border-radius: 3px;"; + input.value = value; + input.onchange = onChange; + group.appendChild(lbl); + group.appendChild(input); + return group; + } try { // Store reference to the panel element extension.panelElement = el; - // Show loading indicator el.innerHTML = ''; const loadingDiv = document.createElement("div"); @@ -28,7 +60,6 @@ export async function renderSidebarContent(extension, el) { `; el.appendChild(loadingDiv); - // Add rotation animation const style = document.createElement('style'); style.textContent = ` @@ -39,93 +70,71 @@ export async function renderSidebarContent(extension, el) { `; document.head.appendChild(style); loadingDiv.querySelector('svg').style.animation = 'rotate 1s linear infinite'; - // Preload data outside render await extension.loadConfig(); await extension.loadManagedWorkers(); await extension.refreshTunnelStatus(); extension.tunnelElements = {}; - el.innerHTML = ''; - // Create toolbar header to match ComfyUI style const toolbar = document.createElement("div"); toolbar.className = "p-toolbar p-component border-x-0 border-t-0 rounded-none px-2 py-1 min-h-8"; toolbar.style.cssText = "border-bottom: 1px solid #444; background: transparent; display: flex; align-items: center;"; - const toolbarStart = document.createElement("div"); toolbarStart.className = "p-toolbar-start"; toolbarStart.style.cssText = "display: flex; align-items: center;"; - const titleSpan = document.createElement("span"); titleSpan.className = "text-xs 2xl:text-sm truncate"; titleSpan.textContent = "COMFYUI DISTRIBUTED"; titleSpan.title = "ComfyUI Distributed"; - toolbarStart.appendChild(titleSpan); toolbar.appendChild(toolbarStart); - const toolbarCenter = document.createElement("div"); toolbarCenter.className = "p-toolbar-center"; toolbar.appendChild(toolbarCenter); - const toolbarEnd = document.createElement("div"); toolbarEnd.className = "p-toolbar-end"; toolbar.appendChild(toolbarEnd); - el.appendChild(toolbar); - // Main container with adjusted padding const container = document.createElement("div"); container.style.cssText = "padding: 15px; display: flex; flex-direction: column; height: calc(100% - 32px);"; - // Detect master info on panel open (in case CUDA info wasn't available at startup) extension.log(`Panel opened. CUDA device count: ${extension.cudaDeviceCount}, Workers: ${extension.config?.workers?.length || 0}`, "debug"); if (!extension.cudaDeviceCount) { await extension.detectMasterIP(); } - - // Now render with guaranteed up-to-date config // Master Node Section const masterDiv = extension.ui.renderEntityCard('master', extension.config?.master, extension); container.appendChild(masterDiv); - // Workers Section (no heading) const gpuSection = document.createElement("div"); gpuSection.style.cssText = "flex: 1; overflow-y: auto; margin-bottom: 15px;"; - const gpuList = document.createElement("div"); const workers = extension.config?.workers || []; - // If no workers exist, show a full blueprint placeholder first if (workers.length === 0) { const blueprintDiv = extension.ui.renderEntityCard('blueprint', { onClick: () => extension.addNewWorker() }, extension); gpuList.appendChild(blueprintDiv); } - // Show existing workers workers.forEach(worker => { const gpuDiv = extension.ui.renderEntityCard('worker', worker, extension); gpuList.appendChild(gpuDiv); }); gpuSection.appendChild(gpuList); - // Only show the minimal "Add Worker" box if there are existing workers if (workers.length > 0) { const addWorkerDiv = extension.ui.renderEntityCard('add', { onClick: () => extension.addNewWorker() }, extension); gpuSection.appendChild(addWorkerDiv); } - container.appendChild(gpuSection); - const actionsSection = document.createElement("div"); actionsSection.style.cssText = "padding-top: 10px; margin-bottom: 15px; border-top: 1px solid #444;"; - // Create a row for both buttons const buttonRow = document.createElement("div"); buttonRow.style.cssText = "display: flex; gap: 8px;"; - const clearMemButton = extension.ui.createButtonHelper( "Clear Worker VRAM", (e) => extension._handleClearMemory(e.target), @@ -133,7 +142,6 @@ export async function renderSidebarContent(extension, el) { ); clearMemButton.title = "Clear VRAM on all enabled worker GPUs (not master)"; clearMemButton.style.cssText = BUTTON_STYLES.base + " flex: 1;" + BUTTON_STYLES.clearMemory; - const interruptButton = extension.ui.createButtonHelper( "Interrupt Workers", (e) => extension._handleInterruptWorkers(e.target), @@ -141,23 +149,18 @@ export async function renderSidebarContent(extension, el) { ); interruptButton.title = "Cancel/interrupt execution on all enabled worker GPUs"; interruptButton.style.cssText = BUTTON_STYLES.base + " flex: 1;" + BUTTON_STYLES.interrupt; - buttonRow.appendChild(clearMemButton); buttonRow.appendChild(interruptButton); actionsSection.appendChild(buttonRow); - container.appendChild(actionsSection); - // Settings section const settingsSection = document.createElement("div"); // Top separator only; spacing handled by the clickable toggle area for equal top/bottom spacing settingsSection.style.cssText = "border-top: 1px solid #444; margin-bottom: 10px;"; - // Settings header with toggle (full-area clickable between separators) const settingsToggleArea = document.createElement("div"); // Equal spacing above header (to top separator) and below header (to bottom separator) settingsToggleArea.style.cssText = "padding: 16.5px 0; cursor: pointer; user-select: none;"; - const settingsHeader = document.createElement("div"); settingsHeader.style.cssText = "display: flex; align-items: center; justify-content: space-between;"; const workerSettingsTitle = document.createElement("h4"); @@ -172,19 +175,15 @@ export async function renderSidebarContent(extension, el) { // Hover effect for toggle area settingsToggleArea.onmouseover = () => { workerSettingsToggle.style.color = "#fff"; }; settingsToggleArea.onmouseout = () => { workerSettingsToggle.style.color = "#888"; }; - // A small separator shown only when collapsed (to make the section boundary obvious) const settingsSeparator = document.createElement("div"); // No margin so the bottom spacing is controlled by settingsToggleArea padding-bottom settingsSeparator.style.cssText = "border-bottom: 1px solid #444; margin: 0;"; - // Collapsible settings content const settingsContent = document.createElement("div"); settingsContent.style.cssText = "max-height: 0; overflow: hidden; opacity: 0; transition: max-height 0.3s ease, opacity 0.3s ease;"; - const settingsDiv = document.createElement("div"); settingsDiv.style.cssText = "display: grid; grid-template-columns: 1fr auto; row-gap: 10px; column-gap: 10px; padding-top: 10px; align-items: center;"; - // Toggle functionality let settingsExpanded = false; settingsToggleArea.onclick = () => { @@ -201,115 +200,51 @@ export async function renderSidebarContent(extension, el) { settingsSeparator.style.display = "block"; } }; - - // Debug mode setting // Section: General const generalLabel = document.createElement("div"); generalLabel.textContent = "GENERAL"; generalLabel.style.cssText = "grid-column: 1 / span 2; font-size: 11px; color: #888; letter-spacing: 0.06em; padding-top: 2px;"; - - const debugGroup = document.createElement("div"); - debugGroup.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 8px;"; - - const debugCheckbox = document.createElement("input"); - debugCheckbox.type = "checkbox"; - debugCheckbox.id = "setting-debug"; - debugCheckbox.checked = extension.config?.settings?.debug || false; - debugCheckbox.onchange = (e) => extension._updateSetting('debug', e.target.checked); - - const debugLabel = document.createElement("label"); - debugLabel.htmlFor = "setting-debug"; - debugLabel.textContent = "Debug Mode"; - debugLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; - debugLabel.title = "Enable verbose logging in the browser console."; - - debugGroup.appendChild(debugCheckbox); - debugGroup.appendChild(debugLabel); - - // Auto-launch workers setting - const autoLaunchGroup = document.createElement("div"); - autoLaunchGroup.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 8px;"; - - const autoLaunchCheckbox = document.createElement("input"); - autoLaunchCheckbox.type = "checkbox"; - autoLaunchCheckbox.id = "setting-auto-launch"; - autoLaunchCheckbox.checked = extension.config?.settings?.auto_launch_workers || false; - autoLaunchCheckbox.onchange = (e) => extension._updateSetting('auto_launch_workers', e.target.checked); - - const autoLaunchLabel = document.createElement("label"); - autoLaunchLabel.htmlFor = "setting-auto-launch"; - autoLaunchLabel.textContent = "Auto-launch Local Workers on Startup"; - autoLaunchLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; - autoLaunchLabel.title = "Start local worker processes automatically when the master starts."; - - autoLaunchGroup.appendChild(autoLaunchCheckbox); - autoLaunchGroup.appendChild(autoLaunchLabel); - - // Stop workers on exit setting (under General) - const stopOnExitGroup = document.createElement("div"); - stopOnExitGroup.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 8px;"; - - const stopOnExitCheckbox = document.createElement("input"); - stopOnExitCheckbox.type = "checkbox"; - stopOnExitCheckbox.id = "setting-stop-on-exit"; - stopOnExitCheckbox.checked = extension.config?.settings?.stop_workers_on_master_exit !== false; // Default true - stopOnExitCheckbox.onchange = (e) => extension._updateSetting('stop_workers_on_master_exit', e.target.checked); - - const stopOnExitLabel = document.createElement("label"); - stopOnExitLabel.htmlFor = "setting-stop-on-exit"; - stopOnExitLabel.textContent = "Stop Local Workers on Master Exit"; - stopOnExitLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; - stopOnExitLabel.title = "Stop local worker processes automatically when the master exits."; - - stopOnExitGroup.appendChild(stopOnExitCheckbox); - stopOnExitGroup.appendChild(stopOnExitLabel); - - settingsDiv.appendChild(generalLabel); - settingsDiv.appendChild(debugGroup); - settingsDiv.appendChild(autoLaunchGroup); - settingsDiv.appendChild(stopOnExitGroup); - - // Worker Timeout setting (seconds) // Section: Timeouts const timeoutsLabel = document.createElement("div"); timeoutsLabel.textContent = "TIMEOUTS"; timeoutsLabel.style.cssText = "grid-column: 1 / span 2; font-size: 11px; color: #888; letter-spacing: 0.06em; padding-top: 4px;"; - - const timeoutGroup = document.createElement("div"); - timeoutGroup.style.cssText = "grid-column: 1 / span 2; display: flex; align-items: center; gap: 6px;"; - - const timeoutLabel = document.createElement("label"); - timeoutLabel.htmlFor = "setting-worker-timeout"; - timeoutLabel.textContent = "Worker Timeout"; - timeoutLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: default;"; - timeoutLabel.title = "Seconds without a heartbeat before a worker is considered timed out and its tasks are requeued. Default 60. For WAN, consider 300–600."; - - const timeoutInput = document.createElement("input"); - timeoutInput.type = "number"; - timeoutInput.id = "setting-worker-timeout"; - timeoutInput.min = "10"; - timeoutInput.step = "1"; - timeoutInput.style.cssText = "width: 80px; padding: 2px 6px; background: #222; color: #ddd; border: 1px solid #333; border-radius: 3px;"; - timeoutInput.value = (extension.config?.settings?.worker_timeout_seconds ?? 60); - timeoutInput.onchange = (e) => { - const v = parseInt(e.target.value, 10); - if (!Number.isFinite(v) || v <= 0) return; - extension._updateSetting('worker_timeout_seconds', v); - }; - - timeoutGroup.appendChild(timeoutLabel); - timeoutGroup.appendChild(timeoutInput); + settingsDiv.appendChild(generalLabel); + settingsDiv.appendChild(createCheckboxSetting( + "setting-debug", "Debug Mode", + "Enable verbose logging in the browser console.", + extension.config?.settings?.debug || false, + (e) => extension._updateSetting('debug', e.target.checked) + )); + settingsDiv.appendChild(createCheckboxSetting( + "setting-auto-launch", "Auto-launch Local Workers on Startup", + "Start local worker processes automatically when the master starts.", + extension.config?.settings?.auto_launch_workers || false, + (e) => extension._updateSetting('auto_launch_workers', e.target.checked) + )); + settingsDiv.appendChild(createCheckboxSetting( + "setting-stop-on-exit", "Stop Local Workers on Master Exit", + "Stop local worker processes automatically when the master exits.", + extension.config?.settings?.stop_workers_on_master_exit !== false, + (e) => extension._updateSetting('stop_workers_on_master_exit', e.target.checked) + )); settingsDiv.appendChild(timeoutsLabel); - settingsDiv.appendChild(timeoutGroup); + settingsDiv.appendChild(createNumberSetting( + "setting-worker-timeout", "Worker Timeout", + "Seconds without a heartbeat before a worker is considered timed out. Default 60.", + extension.config?.settings?.worker_timeout_seconds ?? 60, + 10, 1, + (e) => { + const v = parseInt(e.target.value, 10); + if (!Number.isFinite(v) || v <= 0) return; + extension._updateSetting('worker_timeout_seconds', v); + } + )); settingsContent.appendChild(settingsDiv); - settingsSection.appendChild(settingsToggleArea); settingsSection.appendChild(settingsSeparator); settingsSection.appendChild(settingsContent); container.appendChild(settingsSection); - el.appendChild(container); - // Start checking worker statuses immediately in parallel setTimeout(() => extension.checkAllWorkerStatuses(), 0); } finally { diff --git a/web/tunnelManager.js b/web/tunnelManager.js new file mode 100644 index 0000000..1940fee --- /dev/null +++ b/web/tunnelManager.js @@ -0,0 +1,134 @@ +export function updateTunnelUIElements(extension, isRunning, isStarting) { + void isRunning; + void isStarting; + + const elements = extension.tunnelElements || {}; + const status = (extension.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 = extension.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(extension.tunnelStatus?.public_url); + elements.copyBtn.disabled = !hasUrl; + elements.copyBtn.style.opacity = hasUrl ? "1" : "0.5"; + } +} + +export async function refreshTunnelStatus(extension) { + try { + const data = await extension.api.getTunnelStatus(); + extension.tunnelStatus = data.tunnel || { status: "stopped" }; + if (data.master_host !== undefined) { + extension._applyMasterHost(data.master_host); + } + return extension.tunnelStatus; + } catch (error) { + extension.tunnelStatus = { status: "error", last_error: error.message }; + extension.log("Failed to fetch tunnel status: " + error.message, "error"); + return extension.tunnelStatus; + } finally { + updateTunnelUIElements(extension); + } +} + +export async function handleTunnelToggle(extension, button) { + const currentStatus = (extension.tunnelStatus?.status || "stopped").toLowerCase(); + if (currentStatus === "starting" || currentStatus === "stopping") { + return; + } + + const setStatus = (status) => { + extension.tunnelStatus = { ...(extension.tunnelStatus || {}), status }; + updateTunnelUIElements(extension); + }; + + if (currentStatus === "running") { + setStatus("stopping"); + try { + if (button) { + button.innerHTML = ` Stopping...`; + button.disabled = true; + } + const data = await extension.api.stopTunnel(); + extension.tunnelStatus = data.tunnel || { status: "stopped" }; + if (data.master_host !== undefined) { + extension._applyMasterHost(data.master_host); + } + updateTunnelUIElements(extension); + extension.ui.showToast(extension.app, "info", "Cloudflare Tunnel Disabled", "Master address restored", 4000); + } catch (error) { + extension.tunnelStatus = { status: "error", last_error: error.message }; + updateTunnelUIElements(extension); + extension.ui.showToast(extension.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 extension.api.startTunnel(); + extension.tunnelStatus = data.tunnel || { status: "running" }; + if (data.master_host !== undefined) { + extension._applyMasterHost(data.master_host); + } + updateTunnelUIElements(extension); + const url = data.tunnel?.public_url || data.master_host; + extension.ui.showToast(extension.app, "success", "Cloudflare Tunnel Ready", url || "Public URL created", 4500); + } catch (error) { + extension.tunnelStatus = { status: "error", last_error: error.message }; + updateTunnelUIElements(extension); + extension.ui.showToast(extension.app, "error", "Failed to start tunnel", error.message, 5000); + } finally { + if (button) { + button.disabled = false; + } + } +} diff --git a/web/workerLifecycle.js b/web/workerLifecycle.js new file mode 100644 index 0000000..e89f69e --- /dev/null +++ b/web/workerLifecycle.js @@ -0,0 +1,561 @@ +import { BUTTON_STYLES, TIMEOUTS, STATUS_COLORS, ENDPOINTS } from './constants.js'; + +export async function checkAllWorkerStatuses(extension) { + // Don't continue if panel is closed + if (!extension.panelElement) { + return; + } + + // Create new abort controller for this round of checks + extension.statusCheckAbortController = new AbortController(); + + // Check master status + checkMasterStatus(extension); + + if (!extension.config || !extension.config.workers) { + return; + } + + for (const worker of extension.config.workers) { + // Check status for enabled workers OR workers that are launching + if (worker.enabled || extension.state.isWorkerLaunching(worker.id)) { + checkWorkerStatus(extension, worker); + } + } + + // Determine next interval based on current state + let isActive = extension.state.getMasterStatus() === "processing"; // Master is busy + + // Check workers for activity + extension.config.workers.forEach((worker) => { + const ws = extension.state.getWorker(worker.id); // Get worker state + if (ws.launching || ws.status?.processing) { + // Launching or processing + isActive = true; + } + }); + + // Set next delay: 1s if active, 5s if idle + const nextInterval = isActive ? 1000 : 5000; + + // Schedule the next check + extension.statusCheckTimeout = setTimeout(() => checkAllWorkerStatuses(extension), nextInterval); +} + +export async function checkMasterStatus(extension) { + try { + const response = await fetch(`${window.location.origin}${ENDPOINTS.PROMPT}`, { + method: "GET", + signal: AbortSignal.timeout(TIMEOUTS.STATUS_CHECK), + }); + + if (response.ok) { + const data = await response.json(); + const queueRemaining = data.exec_info?.queue_remaining || 0; + const isProcessing = queueRemaining > 0; + + // Update master status in state + extension.state.setMasterStatus(isProcessing ? "processing" : "online"); + + // Update master status dot + const statusDot = document.getElementById("master-status"); + if (statusDot) { + if (!extension.isMasterParticipating()) { + if (isProcessing) { + statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; + statusDot.title = `Orchestrating (${queueRemaining} in queue)`; + } else { + statusDot.style.backgroundColor = STATUS_COLORS.DISABLED_GRAY; + statusDot.title = "Master orchestrator only"; + } + } else if (isProcessing) { + statusDot.style.backgroundColor = STATUS_COLORS.PROCESSING_YELLOW; + statusDot.title = `Processing (${queueRemaining} in queue)`; + } else { + statusDot.style.backgroundColor = STATUS_COLORS.ONLINE_GREEN; + statusDot.title = "Online"; + } + } + } + } catch (error) { + // Master is always online (we're running on it), so keep it green + const statusDot = document.getElementById("master-status"); + if (statusDot) { + statusDot.style.backgroundColor = extension.isMasterParticipating() ? STATUS_COLORS.ONLINE_GREEN : STATUS_COLORS.DISABLED_GRAY; + statusDot.title = extension.isMasterParticipating() ? "Online" : "Master orchestrator only"; + } + } +} + +// Helper to build worker URL +export function getWorkerUrl(extension, worker, endpoint = "") { + const parsed = extension._parseHostInput(worker.host || window.location.hostname); + const host = parsed.host || window.location.hostname; + const resolvedPort = parsed.port || worker.port; + + // Cloud workers always use HTTPS + const isCloud = worker.type === "cloud"; + + // Detect if we're running on Runpod (for local workers on Runpod infrastructure) + const isRunpodProxy = host.endsWith(".proxy.runpod.net"); + + // For local workers on Runpod, construct the port-specific proxy URL + let finalHost = host; + if (!worker.host && isRunpodProxy) { + const match = host.match(/^(.*)\.proxy\.runpod\.net$/); + if (match) { + const podId = match[1]; + const domain = "proxy.runpod.net"; + finalHost = `${podId}-${worker.port}.${domain}`; + } else { + // Fallback or log error if no match (shouldn't happen) + console.error(`[Distributed] Failed to parse Runpod proxy host: ${host}`); + } + } + + // Determine protocol: HTTPS for cloud, Runpod proxies, or port 443 + const useHttps = isCloud || isRunpodProxy || resolvedPort === 443; + const protocol = useHttps ? "https" : "http"; + + // Only add port if non-standard + const defaultPort = useHttps ? 443 : 80; + const needsPort = !isRunpodProxy && resolvedPort !== defaultPort; + const portStr = needsPort ? `:${resolvedPort}` : ""; + + return `${protocol}://${finalHost}${portStr}${endpoint}`; +} + +export async function checkWorkerStatus(extension, worker) { + // Assume caller ensured enabled; proceed with check + const url = getWorkerUrl(extension, worker, ENDPOINTS.PROMPT); + + try { + // Combine timeout with abort controller signal + const timeoutSignal = AbortSignal.timeout(TIMEOUTS.STATUS_CHECK); + const signal = extension.statusCheckAbortController + ? AbortSignal.any([timeoutSignal, extension.statusCheckAbortController.signal]) + : timeoutSignal; + + const response = await fetch(url, { + method: "GET", + mode: "cors", + signal, + }); + + if (response.ok) { + const data = await response.json(); + const queueRemaining = data.exec_info?.queue_remaining || 0; + const isProcessing = queueRemaining > 0; + + // Update status + extension.state.setWorkerStatus(worker.id, { + online: true, + processing: isProcessing, + queueCount: queueRemaining, + }); + + // Update status dot based on processing state + if (isProcessing) { + extension.ui.updateStatusDot(worker.id, "#f0ad4e", `Online - Processing (${queueRemaining} in queue)`, false); + } else { + extension.ui.updateStatusDot(worker.id, "#3ca03c", "Online - Idle", false); + } + + // Clear launching state since worker is now online + if (extension.state.isWorkerLaunching(worker.id)) { + extension.state.setWorkerLaunching(worker.id, false); + clearLaunchingFlag(extension, worker.id); + } + } else { + throw new Error(`HTTP ${response.status}`); + } + } catch (error) { + // Don't process aborted requests + if (error.name === "AbortError") { + return; + } + + // Worker is offline or unreachable + extension.state.setWorkerStatus(worker.id, { + online: false, + processing: false, + queueCount: 0, + }); + + // Check if worker is launching + if (extension.state.isWorkerLaunching(worker.id)) { + extension.ui.updateStatusDot(worker.id, "#f0ad4e", "Launching...", true); + } else if (worker.enabled) { + // Only update to red if not currently launching AND still enabled + extension.ui.updateStatusDot(worker.id, "#c04c4c", "Offline - Cannot connect", false); + } + // If disabled, don't update the dot (leave it gray) + + extension.log(`Worker ${worker.id} status check failed: ${error.message}`, "debug"); + } + + // Update control buttons based on new status + updateWorkerControls(extension, worker.id); +} + +export async function launchWorker(extension, workerId) { + const worker = extension.config.workers.find((w) => w.id === workerId); + + // If worker is disabled, enable it first + if (!worker.enabled) { + await extension.updateWorkerEnabled(workerId, true); + + // Update the checkbox UI + const checkbox = document.getElementById(`gpu-${workerId}`); + if (checkbox) { + checkbox.checked = true; + } + } + + // Re-query button AFTER updateWorkerEnabled (which may re-render sidebar) + const launchBtn = document.querySelector(`#controls-${workerId} button`); + + extension.ui.updateStatusDot(workerId, "#f0ad4e", "Launching...", true); + extension.state.setWorkerLaunching(workerId, true); + + // Allow 90 seconds for worker to launch (model loading can take time) + setTimeout(() => { + extension.state.setWorkerLaunching(workerId, false); + }, TIMEOUTS.LAUNCH); + + if (!launchBtn) { + return; + } + + try { + // Disable button immediately + launchBtn.disabled = true; + + const result = await extension.api.launchWorker(workerId); + if (result) { + extension.log(`Launched ${worker.name} (PID: ${result.pid})`, "info"); + if (result.log_file) { + extension.log(`Log file: ${result.log_file}`, "debug"); + } + + extension.state.setWorkerManaged(workerId, { + pid: result.pid, + log_file: result.log_file, + started_at: Date.now(), + }); + + // Update controls immediately to hide launch button and show stop/log buttons + updateWorkerControls(extension, workerId); + setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK); + } + } catch (error) { + // Check if worker was already running + if (error.message && error.message.includes("already running")) { + extension.log(`Worker ${worker.name} is already running`, "info"); + updateWorkerControls(extension, workerId); + setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK_DELAY); + } else { + extension.log(`Error launching worker: ${error.message || error}`, "error"); + + // Re-enable button on error + if (launchBtn) { + launchBtn.disabled = false; + } + } + } +} + +export async function stopWorker(extension, workerId) { + const worker = extension.config.workers.find((w) => w.id === workerId); + const stopBtn = document.querySelectorAll(`#controls-${workerId} button`)[1]; + + // Provide immediate feedback + if (stopBtn) { + stopBtn.disabled = true; + stopBtn.textContent = "Stopping..."; + stopBtn.style.backgroundColor = "#666"; + } + + try { + const result = await extension.api.stopWorker(workerId); + if (result) { + extension.log(`Stopped worker: ${result.message}`, "info"); + extension.state.setWorkerManaged(workerId, null); + + // Immediately update status to offline + extension.ui.updateStatusDot(workerId, "#c04c4c", "Offline"); + extension.state.setWorkerStatus(workerId, { online: false }); + + // Flash success feedback + if (stopBtn) { + stopBtn.style.backgroundColor = BUTTON_STYLES.success; + stopBtn.textContent = "Stopped!"; + setTimeout(() => { + updateWorkerControls(extension, workerId); + }, TIMEOUTS.FLASH_SHORT); + } + + // Verify status after a short delay + setTimeout(() => checkWorkerStatus(extension, worker), TIMEOUTS.STATUS_CHECK); + } else { + extension.log(`Failed to stop worker: ${result.message}`, "error"); + + // Flash error feedback + if (stopBtn) { + stopBtn.style.backgroundColor = BUTTON_STYLES.error; + stopBtn.textContent = result.message.includes("already stopped") ? "Not Running" : "Failed"; + + // If already stopped, update status immediately + if (result.message.includes("already stopped")) { + extension.ui.updateStatusDot(workerId, "#c04c4c", "Offline"); + extension.state.setWorkerStatus(workerId, { online: false }); + } + + setTimeout(() => { + updateWorkerControls(extension, workerId); + }, TIMEOUTS.FLASH_MEDIUM); + } + } + } catch (error) { + extension.log(`Error stopping worker: ${error}`, "error"); + + // Reset button on error + if (stopBtn) { + stopBtn.style.backgroundColor = BUTTON_STYLES.error; + stopBtn.textContent = "Error"; + setTimeout(() => { + updateWorkerControls(extension, workerId); + }, TIMEOUTS.FLASH_MEDIUM); + } + } +} + +export async function clearLaunchingFlag(extension, workerId) { + try { + await extension.api.clearLaunchingFlag(workerId); + extension.log(`Cleared launching flag for worker ${workerId}`, "debug"); + } catch (error) { + extension.log(`Error clearing launching flag: ${error.message || error}`, "error"); + } +} + +export async function loadManagedWorkers(extension) { + try { + const result = await extension.api.getManagedWorkers(); + + // Check for launching workers + for (const [workerId, info] of Object.entries(result.managed_workers)) { + extension.state.setWorkerManaged(workerId, info); + + // If worker is marked as launching, add to launchingWorkers set + if (info.launching) { + extension.state.setWorkerLaunching(workerId, true); + extension.log(`Worker ${workerId} is in launching state`, "debug"); + } + } + + // Update UI for all workers + if (extension.config?.workers) { + extension.config.workers.forEach((w) => updateWorkerControls(extension, w.id)); + } + } catch (error) { + extension.log(`Error loading managed workers: ${error}`, "error"); + } +} + +export function updateWorkerControls(extension, workerId) { + const controlsDiv = document.getElementById(`controls-${workerId}`); + + if (!controlsDiv) { + return; + } + + const worker = extension.config.workers.find((w) => w.id === workerId); + if (!worker) { + return; + } + + // Skip button updates for remote workers + if (extension.isRemoteWorker(worker)) { + return; + } + + // Ensure we check for string ID + const managedInfo = extension.state.getWorker(workerId).managed; + const status = extension.state.getWorkerStatus(workerId); + + // Update button states - buttons are now inside a wrapper div + const launchBtn = document.getElementById(`launch-${workerId}`); + const stopBtn = document.getElementById(`stop-${workerId}`); + const logBtn = document.getElementById(`log-${workerId}`); + + // Show log button immediately if we have log file info (even if worker is still starting) + if (managedInfo?.log_file && logBtn) { + logBtn.style.display = ""; + } else if (logBtn && !managedInfo) { + logBtn.style.display = "none"; + } + + if (status?.online || managedInfo) { + // Worker is running or we just launched it + launchBtn.style.display = "none"; // Hide launch button when running + + if (managedInfo) { + // Only show stop button if we manage this worker + stopBtn.style.display = ""; + stopBtn.disabled = false; + stopBtn.textContent = "Stop"; + stopBtn.style.backgroundColor = "#7c4a4a"; // Red when enabled + } else { + // Hide stop button for workers launched outside UI + stopBtn.style.display = "none"; + } + } else { + // Worker is not running + launchBtn.style.display = ""; // Show launch button + launchBtn.disabled = false; + launchBtn.textContent = "Launch"; + launchBtn.style.backgroundColor = "#4a7c4a"; // Always green + + stopBtn.style.display = "none"; // Hide stop button when not running + } +} + +export async function viewWorkerLog(extension, workerId) { + const managedInfo = extension.state.getWorker(workerId).managed; + if (!managedInfo?.log_file) { + return; + } + + const logBtn = document.getElementById(`log-${workerId}`); + + // Provide immediate feedback + if (logBtn) { + logBtn.disabled = true; + logBtn.textContent = "Loading..."; + logBtn.style.backgroundColor = "#666"; + } + + try { + // Fetch log content + const data = await extension.api.getWorkerLog(workerId, 1000); + + // Create modal dialog + extension.ui.showLogModal(extension, workerId, data); + + // Restore button + if (logBtn) { + logBtn.disabled = false; + logBtn.textContent = "View Log"; + logBtn.style.backgroundColor = "#685434"; // Keep the yellow color + } + } catch (error) { + extension.log("Error viewing log: " + error.message, "error"); + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Error", + detail: `Failed to load log: ${error.message}`, + life: 5000, + }); + + // Flash error and restore button + if (logBtn) { + logBtn.style.backgroundColor = BUTTON_STYLES.error; + logBtn.textContent = "Error"; + setTimeout(() => { + logBtn.disabled = false; + logBtn.textContent = "View Log"; + logBtn.style.backgroundColor = "#685434"; // Keep the yellow color + }, TIMEOUTS.FLASH_LONG); + } + } +} + +export async function refreshLog(extension, workerId, silent = false) { + const logContent = document.getElementById("distributed-log-content"); + if (!logContent) { + return; + } + + try { + const data = await extension.api.getWorkerLog(workerId, 1000); + + // Update content + const shouldAutoScroll = logContent.scrollTop + logContent.clientHeight >= logContent.scrollHeight - 50; + logContent.textContent = data.content; + + // Auto-scroll if was at bottom + if (shouldAutoScroll) { + logContent.scrollTop = logContent.scrollHeight; + } + + // Only show toast if not in silent mode (manual refresh) + if (!silent) { + extension.app.extensionManager.toast.add({ + severity: "success", + summary: "Log Refreshed", + detail: "Log content updated", + life: 2000, + }); + } + } catch (error) { + // Only show error toast if not in silent mode + if (!silent) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Refresh Failed", + detail: error.message, + life: 3000, + }); + } + } +} + +export function startLogAutoRefresh(extension, workerId) { + // Stop any existing auto-refresh + stopLogAutoRefresh(extension); + + // Refresh every 2 seconds + extension.logAutoRefreshInterval = setInterval(() => { + refreshLog(extension, workerId, true); // silent mode + }, TIMEOUTS.LOG_REFRESH); +} + +export function stopLogAutoRefresh(extension) { + if (extension.logAutoRefreshInterval) { + clearInterval(extension.logAutoRefreshInterval); + extension.logAutoRefreshInterval = null; + } +} + +export function toggleWorkerExpanded(extension, workerId) { + const settingsDiv = document.getElementById(`settings-${workerId}`); + const gpuDiv = settingsDiv?.closest('[style*="margin-bottom: 12px"]'); + const settingsArrow = gpuDiv?.querySelector(".settings-arrow"); + + if (!settingsDiv) { + return; + } + + if (extension.state.isWorkerExpanded(workerId)) { + extension.state.setWorkerExpanded(workerId, false); + settingsDiv.classList.remove("expanded"); + if (settingsArrow) { + settingsArrow.style.transform = "rotate(0deg)"; + } + // Animate padding to 0 + settingsDiv.style.padding = "0 12px"; + settingsDiv.style.marginTop = "0"; + settingsDiv.style.marginBottom = "0"; + } else { + extension.state.setWorkerExpanded(workerId, true); + settingsDiv.classList.add("expanded"); + if (settingsArrow) { + settingsArrow.style.transform = "rotate(90deg)"; + } + // Animate padding to full + settingsDiv.style.padding = "12px"; + settingsDiv.style.marginTop = "8px"; + settingsDiv.style.marginBottom = "8px"; + } +} diff --git a/web/workerSettings.js b/web/workerSettings.js new file mode 100644 index 0000000..130b302 --- /dev/null +++ b/web/workerSettings.js @@ -0,0 +1,283 @@ +import { renderSidebarContent } from './sidebarRenderer.js'; +import { generateUUID } from './constants.js'; + +export function isRemoteWorker(extension, worker) { + // Check if explicitly marked as cloud worker + if (worker.type === "cloud") { + return true; + } + // Otherwise check by host (backward compatibility) + const parsed = extension._parseHostInput(worker.host || window.location.hostname); + const host = parsed.host || window.location.hostname; + return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname; +} + +export function isCloudWorker(extension, worker) { + return worker.type === "cloud"; +} + +export async function saveWorkerSettings(extension, workerId) { + const worker = extension.config.workers.find((w) => w.id === workerId); + if (!worker) { + return; + } + + // Get form values + const name = document.getElementById(`name-${workerId}`).value; + const workerType = document.getElementById(`worker-type-${workerId}`).value; + const isRemote = workerType === "remote" || workerType === "cloud"; + const isCloud = workerType === "cloud"; + const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname; + const parsedHost = isRemote ? extension._parseHostInput(rawHost) : { host: window.location.hostname, port: null }; + const host = isRemote ? parsedHost.host : window.location.hostname; + let port = parseInt(document.getElementById(`port-${workerId}`).value); + const cudaDevice = isRemote ? undefined : parseInt(document.getElementById(`cuda-${workerId}`).value); + const extraArgs = isRemote ? undefined : document.getElementById(`args-${workerId}`).value; + + if (isRemote && Number.isFinite(parsedHost.port)) { + port = parsedHost.port; + } + + // Validate + if (!name.trim()) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Validation Error", + detail: "Worker name is required", + life: 3000, + }); + return; + } + + if ((workerType === "remote" || workerType === "cloud") && !host.trim()) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Validation Error", + detail: "Host is required for remote workers", + life: 3000, + }); + return; + } + + if (!isCloud && (isNaN(port) || port < 1 || port > 65535)) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Validation Error", + detail: "Port must be between 1 and 65535", + life: 3000, + }); + return; + } + + // Check for port conflicts + // Remote workers can reuse ports, but local workers cannot share ports with each other or master + if (!isRemote) { + // Check if port conflicts with master + const masterPort = parseInt(window.location.port) || (window.location.protocol === "https:" ? 443 : 80); + if (port === masterPort) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Port Conflict", + detail: `Port ${port} is already in use by the master server`, + life: 3000, + }); + return; + } + + // Check if port conflicts with other local workers + const localPortConflict = extension.config.workers.some( + (w) => w.id !== workerId && w.port === port && !w.host // local workers have no host or host is null + ); + + if (localPortConflict) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Port Conflict", + detail: `Port ${port} is already in use by another local worker`, + life: 3000, + }); + return; + } + } else { + // For remote workers, only check conflicts with other workers on the same host + const sameHostConflict = extension.config.workers.some((w) => w.id !== workerId && w.port === port && w.host === host.trim()); + + if (sameHostConflict) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Port Conflict", + detail: `Port ${port} is already in use by another worker on ${host}`, + life: 3000, + }); + return; + } + } + + try { + await extension.api.updateWorker(workerId, { + name: name.trim(), + type: workerType, + host: isRemote ? host.trim() : null, + port, + cuda_device: isRemote ? null : cudaDevice, + extra_args: isRemote ? null : extraArgs ? extraArgs.trim() : "", + }); + + // Update local config + worker.name = name.trim(); + worker.type = workerType; + if (isRemote) { + worker.host = host.trim(); + delete worker.cuda_device; + delete worker.extra_args; + } else { + delete worker.host; + worker.cuda_device = cudaDevice; + worker.extra_args = extraArgs ? extraArgs.trim() : ""; + } + worker.port = port; + + // Sync to state + extension.state.updateWorker(workerId, { enabled: worker.enabled }); + + extension.app.extensionManager.toast.add({ + severity: "success", + summary: "Settings Saved", + detail: `Worker ${name} settings updated`, + life: 3000, + }); + + // Refresh the UI + if (extension.panelElement) { + renderSidebarContent(extension, extension.panelElement); + } + } catch (error) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Save Failed", + detail: error.message, + life: 5000, + }); + } +} + +export function cancelWorkerSettings(extension, workerId) { + // Collapse the settings panel + extension.toggleWorkerExpanded(workerId); + + // Reset form values to original + const worker = extension.config.workers.find((w) => w.id === workerId); + if (worker) { + document.getElementById(`name-${workerId}`).value = worker.name; + document.getElementById(`host-${workerId}`).value = worker.host || ""; + document.getElementById(`port-${workerId}`).value = worker.port; + document.getElementById(`cuda-${workerId}`).value = worker.cuda_device || 0; + document.getElementById(`args-${workerId}`).value = worker.extra_args || ""; + + // Reset remote checkbox + const remoteCheckbox = document.getElementById(`remote-${workerId}`); + if (remoteCheckbox) { + remoteCheckbox.checked = extension.isRemoteWorker(worker); + } + } +} + +export async function deleteWorker(extension, workerId) { + const worker = extension.config.workers.find((w) => w.id === workerId); + if (!worker) { + return; + } + + // Confirm deletion + if (!confirm(`Are you sure you want to delete worker "${worker.name}"?`)) { + return; + } + + try { + await extension.api.deleteWorker(workerId); + + // Remove from local config + const index = extension.config.workers.findIndex((w) => w.id === workerId); + if (index !== -1) { + extension.config.workers.splice(index, 1); + } + + extension.app.extensionManager.toast.add({ + severity: "success", + summary: "Worker Deleted", + detail: `Worker ${worker.name} has been removed`, + life: 3000, + }); + + // Refresh the UI + if (extension.panelElement) { + renderSidebarContent(extension, extension.panelElement); + } + } catch (error) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Delete Failed", + detail: error.message, + life: 5000, + }); + } +} + +export async function addNewWorker(extension) { + // Generate new worker ID using UUID (fallback for non-secure contexts) + const newId = generateUUID(); + + // Find next available port + const usedPorts = extension.config.workers.map((w) => w.port); + let nextPort = 8189; + while (usedPorts.includes(nextPort)) { + nextPort++; + } + + // Create new worker object + const newWorker = { + id: newId, + name: `Worker ${extension.config.workers.length + 1}`, + port: nextPort, + cuda_device: extension.config.workers.length, + enabled: true, // Default to enabled for convenience + extra_args: "", + }; + + // Add to config + extension.config.workers.push(newWorker); + + // Save immediately + try { + await extension.api.updateWorker(newId, { + name: newWorker.name, + port: newWorker.port, + cuda_device: newWorker.cuda_device, + extra_args: newWorker.extra_args, + enabled: newWorker.enabled, + }); + + // Sync to state + extension.state.updateWorker(newId, { enabled: true }); + + extension.app.extensionManager.toast.add({ + severity: "success", + summary: "Worker Added", + detail: `New worker created on port ${nextPort}`, + life: 3000, + }); + + // Refresh UI and expand the new worker + extension.state.setWorkerExpanded(newId, true); + if (extension.panelElement) { + renderSidebarContent(extension, extension.panelElement); + } + } catch (error) { + extension.app.extensionManager.toast.add({ + severity: "error", + summary: "Failed to Add Worker", + detail: error.message, + life: 5000, + }); + } +} diff --git a/web/workerUtils.js b/web/workerUtils.js index d2f8831..6139b69 100644 --- a/web/workerUtils.js +++ b/web/workerUtils.js @@ -1,4 +1,4 @@ -import { BUTTON_STYLES, TIMEOUTS } from './constants.js'; +import { BUTTON_STYLES, TIMEOUTS, ENDPOINTS, NODE_CLASSES } from './constants.js'; export async function handleWorkerOperation(extension, button, operation, successText, errorText) { const originalText = button.textContent; @@ -58,7 +58,7 @@ export async function handleWorkerOperation(extension, button, operation, succes export async function handleInterruptWorkers(extension, button) { return handleWorkerOperation(extension, button, { name: "Interrupt", - endpoint: "/interrupt", + endpoint: ENDPOINTS.INTERRUPT, loadingText: "Interrupting...", onSuccess: () => setTimeout(() => extension.checkAllWorkerStatuses(), TIMEOUTS.POST_ACTION_DELAY) }, "Interrupted!", "Error! See Console"); @@ -67,7 +67,7 @@ export async function handleInterruptWorkers(extension, button) { export async function handleClearMemory(extension, button) { return handleWorkerOperation(extension, button, { name: "Clear memory", - endpoint: "/distributed/clear_memory", + endpoint: ENDPOINTS.CLEAR_MEMORY, loadingText: "Clearing..." }, "Success!", "Error! See Console"); } @@ -205,8 +205,8 @@ export function findCollectorDownstreamNodes(apiPrompt, collectorIds) { export function pruneWorkflowForWorker(extension, apiPrompt, distributedNodes = null) { // Find all distributed nodes if not provided if (!distributedNodes) { - const collectorNodes = findNodesByClass(apiPrompt, "DistributedCollector"); - const upscaleNodes = findNodesByClass(apiPrompt, "UltimateSDUpscaleDistributed"); + const collectorNodes = findNodesByClass(apiPrompt, NODE_CLASSES.DISTRIBUTED_COLLECTOR); + const upscaleNodes = findNodesByClass(apiPrompt, NODE_CLASSES.UPSCALE_DISTRIBUTED); distributedNodes = [...collectorNodes, ...upscaleNodes]; } @@ -263,7 +263,7 @@ export function pruneWorkflowForWorker(extension, apiPrompt, distributedNodes = inputs: { images: [distNodeId, 0] // Connect to first output of distributed node }, - class_type: "PreviewImage", + class_type: NODE_CLASSES.PREVIEW_IMAGE, _meta: { title: "Preview Image (auto-added)" } @@ -365,4 +365,4 @@ export async function getCachedWorkerSystemInfo(workerUrl) { */ export function clearSystemInfoCache() { systemInfoCache.clear(); -} \ No newline at end of file +}