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
+}