From f5aa01b48b66416f813cdf2d6125270cd9dbae61 Mon Sep 17 00:00:00 2001 From: robertvoy Date: Sun, 13 Jul 2025 09:26:50 +1000 Subject: [PATCH] major refactoring of js --- web/apiClient.js | 144 ++ web/constants.js | 92 ++ web/executionUtils.js | 447 +++++ web/gpupanel.js | 3521 ---------------------------------------- web/main.js | 1178 ++++++++++++++ web/sidebarRenderer.js | 273 ++++ web/stateManager.js | 61 + web/ui.js | 997 ++++++++++++ web/workerUtils.js | 266 +++ 9 files changed, 3458 insertions(+), 3521 deletions(-) create mode 100644 web/apiClient.js create mode 100644 web/constants.js create mode 100644 web/executionUtils.js delete mode 100644 web/gpupanel.js create mode 100644 web/main.js create mode 100644 web/sidebarRenderer.js create mode 100644 web/stateManager.js create mode 100644 web/ui.js create mode 100644 web/workerUtils.js diff --git a/web/apiClient.js b/web/apiClient.js new file mode 100644 index 0000000..061428d --- /dev/null +++ b/web/apiClient.js @@ -0,0 +1,144 @@ +export function createApiClient(baseUrl) { + const request = async (endpoint, options = {}, retries = 3) => { + let lastError; + let delay = 1000; // Initial delay for exponential backoff + + for (let attempt = 0; attempt < retries; attempt++) { + try { + const response = await fetch(`${baseUrl}${endpoint}`, { + headers: { 'Content-Type': 'application/json' }, + ...options + }); + + if (!response.ok) { + const error = await response.json().catch(() => ({ message: 'Request failed' })); + throw new Error(error.message || `HTTP ${response.status}`); + } + + return await response.json(); + } catch (error) { + lastError = error; + console.log(`API Error (attempt ${attempt + 1}/${retries}): ${endpoint} - ${error.message}`); + if (attempt < retries - 1) { + await new Promise(resolve => setTimeout(resolve, delay)); + delay *= 2; // Exponential backoff + } + } + } + throw lastError; + }; + + return { + // Config endpoints + async getConfig() { + return request('/distributed/config'); + }, + + async updateWorker(workerId, data) { + return request('/distributed/config/update_worker', { + method: 'POST', + body: JSON.stringify({ worker_id: workerId, ...data }) + }); + }, + + async deleteWorker(workerId) { + return request('/distributed/config/delete_worker', { + method: 'POST', + body: JSON.stringify({ worker_id: workerId }) + }); + }, + + async updateSetting(key, value) { + return request('/distributed/config/update_setting', { + method: 'POST', + body: JSON.stringify({ key, value }) + }); + }, + + async updateMaster(data) { + return request('/distributed/config/update_master', { + method: 'POST', + body: JSON.stringify(data) + }); + }, + + // Worker management endpoints + async launchWorker(workerId) { + return request('/distributed/launch_worker', { + method: 'POST', + body: JSON.stringify({ worker_id: workerId }) + }); + }, + + async stopWorker(workerId) { + return request('/distributed/stop_worker', { + method: 'POST', + body: JSON.stringify({ worker_id: workerId }) + }); + }, + + async getManagedWorkers() { + return request('/distributed/managed_workers'); + }, + + async getWorkerLog(workerId, lines = 1000) { + return request(`/distributed/worker_log/${workerId}?lines=${lines}`); + }, + + async clearLaunchingFlag(workerId) { + return request('/distributed/worker/clear_launching', { + method: 'POST', + body: JSON.stringify({ worker_id: workerId }) + }); + }, + + // Job preparation + async prepareJob(multiJobId) { + return request('/distributed/prepare_job', { + method: 'POST', + body: JSON.stringify({ multi_job_id: multiJobId }) + }); + }, + + // Image loading + async loadImage(imagePath) { + return request('/distributed/load_image', { + method: 'POST', + body: JSON.stringify({ image_path: imagePath }) + }); + }, + + // Network info + async getNetworkInfo() { + return request('/distributed/network_info'); + }, + + // Status checking (with timeout) + async checkStatus(url, timeout = 5000) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + signal: controller.signal + }); + clearTimeout(timeoutId); + + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return await response.json(); + } catch (error) { + clearTimeout(timeoutId); + throw error; + } + }, + + // Batch status checking + async checkMultipleStatuses(urls) { + return Promise.allSettled( + urls.map(url => this.checkStatus(url)) + ); + } + }; +} \ No newline at end of file diff --git a/web/constants.js b/web/constants.js new file mode 100644 index 0000000..223807d --- /dev/null +++ b/web/constants.js @@ -0,0 +1,92 @@ +export const BUTTON_STYLES = { + base: "width: 100%; padding: 8px 12px; font-size: 12px; color: white; border: none; border-radius: 4px; cursor: pointer; transition: all 0.2s; font-weight: 500;", + clearMemory: "background-color: #555;", + success: "background-color: #3ca03c;", + error: "background-color: #c04c4c;", + launch: "background-color: #4a7c4a;", + stop: "background-color: #7c4a4a;", + grey: "background-color: #555;", +}; + +export const STATUS_COLORS = { + DISABLED_GRAY: "#666", + OFFLINE_RED: "#c04c4c", + ONLINE_GREEN: "#3ca03c", + PROCESSING_YELLOW: "#f0ad4e" +}; + +export const PULSE_ANIMATION_CSS = ` + @keyframes pulse { + 0% { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 0 0 rgba(240, 173, 78, 0.7); + } + 50% { + opacity: 0.3; + transform: scale(1.3); + box-shadow: 0 0 0 6px rgba(240, 173, 78, 0); + } + 100% { + opacity: 1; + transform: scale(1); + box-shadow: 0 0 0 0 rgba(240, 173, 78, 0); + } + } + .status-pulsing { + animation: pulse 1.2s ease-in-out infinite; + } + + /* Button hover effects */ + .distributed-button:hover:not(:disabled) { + filter: brightness(1.2); + transition: filter 0.2s ease; + } + .distributed-button:disabled { + opacity: 0.6; + cursor: not-allowed; + } + + /* Settings button animation */ + .settings-btn { + transition: transform 0.2s ease; + } + + + /* Expanded settings panel */ + .worker-settings { + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease; + } + .worker-settings.expanded { + max-height: 500px; + opacity: 1; + padding: 12px 0; + } +`; + +export const UI_STYLES = { + statusDot: "display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px;", + controlsDiv: "padding: 0 12px 12px 12px; display: flex; gap: 6px;", + formGroup: "display: flex; flex-direction: column; gap: 5px;", + formLabel: "font-size: 12px; color: #ccc; font-weight: 500;", + formInput: "padding: 6px 10px; background: #2a2a2a; border: 1px solid #444; color: white; font-size: 12px; border-radius: 4px; transition: border-color 0.2s;", + workerCard: "margin-bottom: 12px; border-radius: 6px; overflow: hidden; display: flex; background: #2a2a2a;", + checkboxColumn: "flex: 0 0 44px; display: flex; align-items: center; justify-content: center; border-right: 1px solid #3a3a3a; cursor: default; background: rgba(0,0,0,0.1);", + contentColumn: "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;", + infoRow: "display: flex; align-items: center; padding: 12px; cursor: pointer;", + workerContent: "display: flex; align-items: center; gap: 10px; flex: 1;", + settingsArrow: "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto; padding: 4px;", + infoBox: "background-color: #333; color: #999; padding: 5px 14px; border-radius: 4px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;", + workerSettings: "margin: 0 12px; padding: 0 12px; background: #1e1e1e; border-radius: 4px; border: 1px solid #2a2a2a;" +}; + +export const TIMEOUTS = { + DEFAULT_FETCH: 5000, // ms for general API calls + STATUS_CHECK: 2000, // ms for status checks + LAUNCH: 90000, // ms for worker launch (longer for model loading) + RETRY_DELAY: 1000, // initial delay for exponential backoff + MAX_RETRIES: 3 // max retry attempts +}; \ No newline at end of file diff --git a/web/executionUtils.js b/web/executionUtils.js new file mode 100644 index 0000000..d26fcba --- /dev/null +++ b/web/executionUtils.js @@ -0,0 +1,447 @@ +import { api } from "../../scripts/api.js"; +import { findNodesByClass, findImageReferences, hasUpstreamNode, pruneWorkflowForWorker } from './workerUtils.js'; +import { TIMEOUTS } from './constants.js'; + +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; + + if (hasCollector || hasDistUpscale) { + const result = await executeParallelDistributed(extension, prompt); + // Check status after dispatching jobs + setTimeout(() => extension.checkAllWorkerStatuses(), 500); + return result; + } + } + return extension.originalQueuePrompt(number, prompt); + }; +} + +export async function executeParallelDistributed(extension, promptWrapper) { + try { + const executionPrefix = "exec_" + Date.now(); // Unique ID for this specific execution + const enabledWorkers = extension.enabledWorkers; + + // Pre-flight health check on all enabled workers + const activeWorkers = await performPreflightCheck(extension, enabledWorkers); + + if (activeWorkers.length === 0 && enabledWorkers.length > 0) { + extension.log("No active workers found. Running on master only."); + // Fall back to master-only execution + return extension.originalQueuePrompt(0, promptWrapper); + } + + extension.log(`Pre-flight check: ${activeWorkers.length} of ${enabledWorkers.length} workers are active`, "debug"); + + // Find all distributed nodes in the workflow + const collectorNodes = findNodesByClass(promptWrapper.output, "DistributedCollector"); + const upscaleNodes = findNodesByClass(promptWrapper.output, "UltimateSDUpscaleDistributed"); + const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + + // Map original node IDs to truly unique job IDs for this specific run + const job_id_map = new Map(allDistributedNodes.map(node => [node.id, `${executionPrefix}_${node.id}`])); + + // Prepare a separate job queue on the backend for each unique job ID + const preparePromises = Array.from(job_id_map.values()).map(uniqueId => prepareDistributedJob(extension, uniqueId)); + await Promise.all(preparePromises); + + const jobs = []; + // Use only active workers + const participants = ['master', ...activeWorkers.map(w => w.id)]; + + for (const participantId of participants) { + const options = { + enabled_worker_ids: activeWorkers.map(w => w.id), + workflow: promptWrapper.workflow, + job_id_map: job_id_map // Pass the map of unique IDs + }; + + const jobApiPrompt = prepareApiPromptForParticipant( + extension, promptWrapper.output, participantId, options + ); + + if (participantId === 'master') { + jobs.push({ type: 'master', promptWrapper: { ...promptWrapper, output: jobApiPrompt } }); + } else { + const worker = activeWorkers.find(w => w.id === participantId); + if (worker) { + const job = { + type: 'worker', + worker, + prompt: jobApiPrompt, + workflow: promptWrapper.workflow + }; + + // Add image references if found for remote workers + if (options._imageReferences) { + job.imageReferences = options._imageReferences; + } + + jobs.push(job); + } + } + } + + const result = await executeJobs(extension, jobs); + return result; + } catch (error) { + extension.log("Parallel execution failed: " + error.message, "error"); + throw error; + } +} + +export function prepareApiPromptForParticipant(extension, baseApiPrompt, participantId, options = {}) { + let jobApiPrompt = JSON.parse(JSON.stringify(baseApiPrompt)); + const isMaster = participantId === 'master'; + + // Find all distributed nodes once (before pruning) + const collectorNodes = findNodesByClass(jobApiPrompt, "DistributedCollector"); + const upscaleNodes = findNodesByClass(jobApiPrompt, "UltimateSDUpscaleDistributed"); + const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; + + // For workers, prune the workflow to only include distributed node dependencies + if (!isMaster && allDistributedNodes.length > 0) { + jobApiPrompt = pruneWorkflowForWorker(extension, jobApiPrompt, allDistributedNodes); + } + + // Handle image references for remote workers + if (!isMaster && options.enabled_worker_ids) { + // Check if this is a remote worker + const workerId = participantId; + const workerInfo = extension.config.workers.find(w => w.id === workerId); + const isRemote = workerInfo && workerInfo.host; + + if (isRemote) { + // Find all image references in the pruned workflow + const imageReferences = findImageReferences(extension, jobApiPrompt); + if (imageReferences.size > 0) { + extension.log(`Found ${imageReferences.size} image references for remote worker ${workerId}`, "debug"); + // Store image references for later processing + options._imageReferences = imageReferences; + } + } + } + + // Handle Distributed seed nodes + const distributorNodes = findNodesByClass(jobApiPrompt, "DistributedSeed"); + if (distributorNodes.length > 0) { + extension.log(`Found ${distributorNodes.length} seed node(s)`, "debug"); + } + + for (const seedNode of distributorNodes) { + const { inputs } = jobApiPrompt[seedNode.id]; + inputs.is_worker = !isMaster; + if (!isMaster) { + const workerIndex = options.enabled_worker_ids.indexOf(participantId); + inputs.worker_id = `worker_${workerIndex}`; + extension.log(`Set seed node ${seedNode.id} for worker ${workerIndex}`, "debug"); + } + } + + // Handle Distributed collector nodes (already found above) + for (const collector of collectorNodes) { + const { inputs } = jobApiPrompt[collector.id]; + + // Check if this collector is downstream from a distributed upscaler + const hasUpstreamDistributedUpscaler = hasUpstreamNode( + jobApiPrompt, + collector.id, + 'UltimateSDUpscaleDistributed' + ); + + if (hasUpstreamDistributedUpscaler) { + // Set pass_through mode for this collector + inputs.pass_through = true; + extension.log(`Collector ${collector.id} set to pass-through mode (downstream from distributed upscaler)`, "debug"); + } else { + // Normal collector behavior + // Get the unique job ID from the map created for this execution + const uniqueJobId = options.job_id_map ? options.job_id_map.get(collector.id) : collector.id; + + // Use the truly unique ID for this execution + inputs.multi_job_id = uniqueJobId; + inputs.is_worker = !isMaster; + if (isMaster) { + inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); + } else { + inputs.master_url = extension.getMasterUrl(); + // Also make the worker_job_id unique to prevent potential caching issues + inputs.worker_job_id = `${uniqueJobId}_worker_${participantId}`; + inputs.worker_id = participantId; + } + } + } + + // Handle Ultimate SD Upscale Distributed nodes + for (const upscaleNode of upscaleNodes) { + const { inputs } = jobApiPrompt[upscaleNode.id]; + + // Get the unique job ID from the map + const uniqueJobId = options.job_id_map ? options.job_id_map.get(upscaleNode.id) : upscaleNode.id; + + inputs.multi_job_id = uniqueJobId; + inputs.is_worker = !isMaster; + + if (isMaster) { + inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); + } else { + inputs.master_url = extension.getMasterUrl(); + inputs.worker_id = participantId; + // Workers also need the enabled_worker_ids to calculate tile distribution + inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); + } + } + + return jobApiPrompt; +} + +export async function prepareDistributedJob(extension, multi_job_id) { + try { + await extension.api.prepareJob(multi_job_id); + } catch (error) { + extension.log("Error preparing job: " + error.message, "error"); + throw error; + } +} + +export async function executeJobs(extension, jobs) { + let masterPromptId = null; + + // Pre-load all unique images before dispatching to workers + const allImageReferences = new Map(); + for (const job of jobs) { + if (job.type === 'worker' && job.imageReferences) { + for (const [filename, info] of job.imageReferences) { + allImageReferences.set(filename, info); + } + } + } + + if (allImageReferences.size > 0) { + extension.log(`Pre-loading ${allImageReferences.size} unique image(s) for all workers`, "debug"); + await loadImagesForWorker(extension, allImageReferences); + } + + // Now dispatch jobs in parallel + const promises = jobs.map(job => { + if (job.type === 'master') { + return extension.originalQueuePrompt(0, job.promptWrapper).then(result => { + masterPromptId = result; + return result; + }); + } else { + return dispatchToWorker(extension, job.worker, job.prompt, job.workflow, job.imageReferences); + } + }); + await Promise.all(promises); + return masterPromptId || { "prompt_id": "distributed-job-dispatched" }; +} + +async function dispatchToWorker(extension, worker, prompt, workflow, imageReferences) { + const workerUrl = extension.getWorkerUrl(worker); + + // Debug logging - always log to console for debugging + extension.log(`[Distributed] === Dispatching to ${worker.name} (${worker.id}) ===`, "debug"); + extension.log('[Distributed] Worker URL: ' + workerUrl, "debug"); + + // Handle image uploads for remote workers + if (imageReferences && imageReferences.size > 0) { + extension.log(`[Distributed] Processing ${imageReferences.size} image(s) for remote worker`, "debug"); + + try { + // Load images from master + const images = await loadImagesForWorker(extension, imageReferences); + + // Upload images to worker + if (images.length > 0) { + await uploadImagesToWorker(extension, workerUrl, images); + extension.log(`[Distributed] Successfully uploaded ${images.length} image(s) to worker`, "debug"); + } + } catch (error) { + extension.log(`Failed to process images for worker ${worker.name}: ${error.message}`, "error"); + // Continue with workflow execution even if image upload fails + } + } + + const promptToSend = { + prompt, + extra_data: { extra_pnginfo: { workflow } }, + client_id: api.clientId + }; + + extension.log('[Distributed] Prompt data: ' + JSON.stringify(promptToSend), "debug"); + + try { + await fetch(`${workerUrl}/prompt`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + mode: 'cors', + body: JSON.stringify(promptToSend) + }); + } catch (e) { + extension.log(`Failed to connect to worker ${worker.name} at ${workerUrl}: ${e.message}`, "error"); + } +} + +export async function loadImagesForWorker(extension, imageReferences) { + const images = []; + + // Use a cache to avoid loading the same image multiple times + if (!extension._imageCache) { + extension._imageCache = new Map(); + } + + for (const [filename, info] of imageReferences) { + try { + // Check cache first + if (extension._imageCache.has(filename)) { + images.push(extension._imageCache.get(filename)); + extension.log(`Using cached image: ${filename}`, "debug"); + continue; + } + + // Limit cache size + if (extension._imageCache.size >= 10) { + const oldestKey = extension._imageCache.keys().next().value; + extension._imageCache.delete(oldestKey); + extension.log(`Evicted oldest cache entry: ${oldestKey} (cache limit reached)`, "debug"); + } + + // Load image from master's filesystem via API + try { + const data = await extension.api.loadImage(filename); + const imageData = { + name: filename, + image: data.image_data + }; + images.push(imageData); + + // Cache the image for future use + extension._imageCache.set(filename, imageData); + extension.log(`Loaded and cached image: ${filename}`, "debug"); + } catch (loadError) { + extension.log(`Failed to load image ${filename}: ${loadError.message}`, "error"); + throw loadError; + } + } catch (error) { + extension.log(`Error loading image ${filename}: ${error.message}`, "error"); + } + } + + // Clear cache after a reasonable time to avoid memory issues + setTimeout(() => { + if (extension._imageCache && extension._imageCache.size > 0) { + extension.log(`Clearing image cache (${extension._imageCache.size} images)`, "debug"); + extension._imageCache.clear(); + } + }, 30000); // Clear after 30 seconds + + return images; +} + +export async function uploadImagesToWorker(extension, workerUrl, images) { + // Upload images to worker's ComfyUI instance + for (const imageData of images) { + const formData = new FormData(); + + // Detect MIME type from base64 header + const mimeMatch = imageData.image.match(/^data:(image\/\w+);base64,/); + const mimeType = mimeMatch ? mimeMatch[1] : 'image/png'; // Default to PNG if not detected + + // Convert base64 to blob + const base64Data = imageData.image.replace(/^data:image\/\w+;base64,/, ''); + const byteCharacters = atob(base64Data); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + const blob = new Blob([byteArray], { type: mimeType }); + + // Use original filename without heavy cleaning + let cleanName = imageData.name; + let subfolder = ''; + + // Extract subfolder if present + if (cleanName.includes('/')) { + const parts = cleanName.split('/'); + subfolder = parts.slice(0, -1).join('/'); + cleanName = parts[parts.length - 1]; + } + + formData.append('image', blob, cleanName); + formData.append('type', 'input'); + formData.append('subfolder', subfolder); + formData.append('overwrite', 'true'); + + try { + const response = await fetch(`${workerUrl}/upload/image`, { + method: 'POST', + mode: 'cors', + body: formData + }); + + if (!response.ok) { + throw new Error(`Upload failed: ${response.statusText}`); + } + + extension.log(`Uploaded image to worker: ${imageData.name} -> ${subfolder}/${cleanName}`, "debug"); + } catch (error) { + extension.log(`Failed to upload ${imageData.name}: ${error.message}`, "error"); + // Continue with other images + } + } +} + +export async function performPreflightCheck(extension, workers) { + if (workers.length === 0) return []; + + extension.log(`Performing pre-flight health check on ${workers.length} workers...`, "debug"); + const startTime = Date.now(); + + const checkPromises = workers.map(async (worker) => { + const url = extension.getWorkerUrl(worker, '/prompt'); + + extension.log(`Pre-flight checking ${worker.name} at: ${url}`, "debug"); + + try { + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + signal: AbortSignal.timeout(TIMEOUTS.STATUS_CHECK) + }); + + if (response.ok) { + extension.log(`Worker ${worker.name} is active`, "debug"); + return { worker, active: true }; + } else { + extension.log(`Worker ${worker.name} returned ${response.status}`, "debug"); + return { worker, active: false }; + } + } catch (error) { + extension.log(`Worker ${worker.name} is offline or unreachable: ${error.message}`, "debug"); + return { worker, active: false }; + } + }); + + const results = await Promise.all(checkPromises); + const activeWorkers = results.filter(r => r.active).map(r => r.worker); + + const elapsed = Date.now() - startTime; + extension.log(`Pre-flight check completed in ${elapsed}ms. Active workers: ${activeWorkers.length}/${workers.length}`, "debug"); + + // Update UI status indicators for inactive workers + results.filter(r => !r.active).forEach(r => { + const statusDot = document.getElementById(`status-${r.worker.id}`); + if (statusDot) { + // Remove pulsing animation once status is determined + statusDot.classList.remove('status-pulsing'); + statusDot.style.backgroundColor = "#c04c4c"; // Red for offline + statusDot.title = "Offline - Cannot connect"; + } + }); + + return activeWorkers; +} \ No newline at end of file diff --git a/web/gpupanel.js b/web/gpupanel.js deleted file mode 100644 index 619b647..0000000 --- a/web/gpupanel.js +++ /dev/null @@ -1,3521 +0,0 @@ -import { app } from "../../scripts/app.js"; -import { api } from "../../scripts/api.js"; - -class DistributedExtension { - // Button styling constants - static BUTTON_STYLES = { - base: "width: 100%; padding: 8px; font-size: 12px; color: white; border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.2s;", - clearMemory: "background-color: #555;", - success: "background-color: #3ca03c;", - error: "background-color: #c04c4c;", - }; - - // CSS for pulsing animation and button hover effects - static PULSE_ANIMATION_CSS = ` - @keyframes pulse { - 0% { - opacity: 1; - transform: scale(1); - box-shadow: 0 0 0 0 rgba(240, 173, 78, 0.7); - } - 50% { - opacity: 0.3; - transform: scale(1.3); - box-shadow: 0 0 0 6px rgba(240, 173, 78, 0); - } - 100% { - opacity: 1; - transform: scale(1); - box-shadow: 0 0 0 0 rgba(240, 173, 78, 0); - } - } - .status-pulsing { - animation: pulse 1.2s ease-in-out infinite; - } - - /* Button hover effects */ - .distributed-button:hover:not(:disabled) { - filter: brightness(1.2); - transition: filter 0.2s ease; - } - .distributed-button:disabled { - opacity: 0.6; - cursor: not-allowed; - } - - /* Settings button animation */ - .settings-btn { - transition: transform 0.2s ease; - } - - - /* Expanded settings panel */ - .worker-settings { - max-height: 0; - overflow: hidden; - opacity: 0; - transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease, margin 0.3s ease; - } - .worker-settings.expanded { - max-height: 300px; - opacity: 1; - } - `; - - constructor() { - this.config = null; - this.originalQueuePrompt = api.queuePrompt.bind(api); - this.statusCheckInterval = null; - this.logAutoRefreshInterval = null; - - // Initialize centralized state - this.state = this._createStateManager(); - - // Initialize UI component factory - this.ui = this._createUIFactory(); - - // Initialize API client - this.api = this._createApiClient(); - - // Inject CSS for pulsing animation - this.injectStyles(); - - this.loadConfig().then(async () => { - this.registerSidebarTab(); - this.setupInterceptor(); - this.startStatusChecking(); - this.loadManagedWorkers(); - // Detect master IP after everything is set up - this.detectMasterIP(); - }); - } - - // Debug logging helpers - debugLog(message) { - if (this.config?.settings?.debug) { - console.log(`[Distributed] ${message}`); - } - } - - log(message) { - console.log(`[Distributed] ${message}`); - } - - // --- UI Component Factory --- - _createUIFactory() { - const styles = { - statusDot: "display: inline-block; width: 8px; height: 8px; border-radius: 50%;", - controlsDiv: "padding: 0 8px 6px 8px; display: flex; gap: 4px;", - checkboxColumn: "flex: 0 0 40px; display: flex; align-items: center; justify-content: center; border-right: 1px solid #444;", - workerCard: "margin-bottom: 12px; background: #2a2a2a; border-radius: 4px; overflow: hidden; display: flex;", - contentColumn: "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;", - infoRow: "display: flex; align-items: center; padding: 8px; cursor: pointer; min-height: 60px;", - workerContent: "display: flex; align-items: center; gap: 8px; flex: 1;", - settingsArrow: "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto;", - formGroup: "display: flex; align-items: center; gap: 8px; margin-bottom: 8px;", - formLabel: "flex: 0 0 100px; font-size: 12px; color: #ccc;", - formInput: "flex: 1; padding: 4px 8px; background: #1a1a1a; border: 1px solid #444; border-radius: 4px; color: #fff; font-size: 12px;", - infoBox: "background-color: #333; color: #888; padding: 4px 12px; border-radius: 4px; font-size: 11px; text-align: center; flex: 1;" - }; - - return { - createStatusDot(id, color = "#666", title = "Status") { - const dot = document.createElement("span"); - if (id) dot.id = id; - dot.style.cssText = styles.statusDot + ` background-color: ${color};`; - dot.title = title; - return dot; - }, - - createButton(text, onClick, customStyle = "") { - const button = document.createElement("button"); - button.textContent = text; - button.className = "distributed-button"; - button.style.cssText = DistributedExtension.BUTTON_STYLES.base + customStyle; - if (onClick) button.onclick = onClick; - return button; - }, - - createButtonGroup(buttons, style = "") { - const group = document.createElement("div"); - group.style.cssText = "display: flex; gap: 4px; margin-top: 10px;" + style; - buttons.forEach(button => group.appendChild(button)); - return group; - }, - - createWorkerControls(workerId, handlers = {}) { - const controlsDiv = document.createElement("div"); - controlsDiv.id = `controls-${workerId}`; - controlsDiv.style.cssText = styles.controlsDiv; - - const buttons = []; - - if (handlers.launch) { - const launchBtn = this.createButton('Launch', handlers.launch); - launchBtn.id = `launch-${workerId}`; - launchBtn.title = "Launch this worker instance"; - buttons.push(launchBtn); - } - - if (handlers.stop) { - const stopBtn = this.createButton('Stop', handlers.stop); - stopBtn.id = `stop-${workerId}`; - stopBtn.title = "Stop this worker instance"; - buttons.push(stopBtn); - } - - if (handlers.viewLog) { - const logBtn = this.createButton('View Log', handlers.viewLog); - logBtn.id = `log-${workerId}`; - logBtn.title = "View worker log file"; - buttons.push(logBtn); - } - - buttons.forEach(btn => controlsDiv.appendChild(btn)); - return controlsDiv; - }, - - createFormGroup(label, value, id, type = "text", placeholder = "") { - const group = document.createElement("div"); - group.style.cssText = styles.formGroup; - - const labelEl = document.createElement("label"); - labelEl.textContent = label; - labelEl.htmlFor = id; - labelEl.style.cssText = styles.formLabel; - - const input = document.createElement("input"); - input.type = type; - input.id = id; - input.value = value; - input.placeholder = placeholder; - input.style.cssText = styles.formInput; - - group.appendChild(labelEl); - group.appendChild(input); - return { group, input }; - }, - - createWorkerCard() { - const card = document.createElement("div"); - card.style.cssText = styles.workerCard; - - const checkboxColumn = document.createElement("div"); - checkboxColumn.style.cssText = styles.checkboxColumn; - - const contentColumn = document.createElement("div"); - contentColumn.style.cssText = styles.contentColumn; - - const infoRow = document.createElement("div"); - infoRow.style.cssText = styles.infoRow; - - const workerContent = document.createElement("div"); - workerContent.style.cssText = styles.workerContent; - - const settingsArrow = document.createElement("span"); - settingsArrow.className = "settings-arrow"; - settingsArrow.innerHTML = "▶"; - settingsArrow.style.cssText = styles.settingsArrow; - - const controlsDiv = document.createElement("div"); - controlsDiv.style.cssText = styles.controlsDiv; - - return { - card, - checkboxColumn, - contentColumn, - infoRow, - workerContent, - settingsArrow, - controlsDiv - }; - }, - - createInfoBox(text) { - const box = document.createElement("div"); - box.style.cssText = styles.infoBox; - box.textContent = text; - return box; - }, - - addHoverEffect(element, onHover, onLeave) { - element.onmouseover = onHover; - element.onmouseout = onLeave; - }, - - createCard(type = 'worker', options = {}) { - const card = document.createElement("div"); - const baseStyle = "margin-bottom: 12px; border-radius: 4px; overflow: hidden; display: flex;"; - - switch(type) { - case 'master': - case 'worker': - card.style.cssText = baseStyle + "background: #2a2a2a;"; - break; - case 'blueprint': - card.style.cssText = baseStyle + "border: 2px solid #666; cursor: pointer; transition: all 0.2s ease; background: rgba(255, 255, 255, 0.02);"; - if (options.onClick) card.onclick = options.onClick; - if (options.title) card.title = options.title; - break; - case 'add': - card.style.cssText = "margin-top: 10px; " + baseStyle + "border: 1px solid #444; cursor: pointer; transition: all 0.2s ease;"; - if (options.onClick) card.onclick = options.onClick; - if (options.title) card.title = options.title; - break; - } - - if (options.onMouseEnter) { - card.addEventListener('mouseenter', options.onMouseEnter); - } - if (options.onMouseLeave) { - card.addEventListener('mouseleave', options.onMouseLeave); - } - - return card; - }, - - createCardColumn(type = 'checkbox', options = {}) { - const column = document.createElement("div"); - const baseStyle = "display: flex; align-items: center; justify-content: center;"; - - switch(type) { - case 'checkbox': - column.style.cssText = baseStyle + "flex: 0 0 40px; border-right: 1px solid #444; cursor: default;"; - if (options.title) column.title = options.title; - break; - case 'icon': - column.style.cssText = baseStyle + "width: 50px; flex-shrink: 0; font-size: 20px; color: #666;"; - break; - case 'content': - column.style.cssText = "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;"; - break; - } - - return column; - }, - - createInfoRow(options = {}) { - const row = document.createElement("div"); - row.style.cssText = "display: flex; align-items: center; padding: 8px; cursor: pointer; min-height: 60px;"; - if (options.onClick) row.onclick = options.onClick; - return row; - }, - - createWorkerContent() { - const content = document.createElement("div"); - content.style.cssText = "display: flex; align-items: center; gap: 8px; flex: 1;"; - return content; - }, - - createSettingsForm(fields = [], options = {}) { - const form = document.createElement("div"); - form.style.cssText = "display: flex; flex-direction: column; gap: 10px;"; - - fields.forEach(field => { - if (field.type === 'checkbox') { - const group = document.createElement("div"); - group.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;"; - - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.id = field.id; - checkbox.checked = field.checked || false; - if (field.onChange) checkbox.onchange = field.onChange; - - const label = document.createElement("label"); - label.htmlFor = field.id; - label.textContent = field.label; - label.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; - - group.appendChild(checkbox); - group.appendChild(label); - form.appendChild(group); - } else { - const result = this.createFormGroup(field.label, field.value, field.id, field.type, field.placeholder); - if (field.groupId) result.group.id = field.groupId; - if (field.display) result.group.style.display = field.display; - form.appendChild(result.group); - } - }); - - if (options.buttons) { - const buttonGroup = this.createButtonGroup(options.buttons, options.buttonStyle || " margin-top: 8px;"); - form.appendChild(buttonGroup); - } - - return form; - }, - - createCompleteCard(type, options = {}) { - // Create the main card - const card = this.createCard(type, options); - - // Create left column - let leftColumn; - if (type === 'master' || type === 'worker') { - leftColumn = this.createCardColumn('checkbox', options.checkboxOptions); - - // Add checkbox - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - if (options.checkboxId) checkbox.id = options.checkboxId; - checkbox.checked = options.checked || false; - checkbox.disabled = options.disabled || false; - checkbox.style.cssText = `cursor: ${options.disabled ? 'default' : 'pointer'}; width: 16px; height: 16px;${options.checkboxStyle || ''}`; - if (options.checkboxTitle) checkbox.title = options.checkboxTitle; - - leftColumn.appendChild(checkbox); - - if (options.onCheckboxClick && !options.disabled) { - checkbox.style.pointerEvents = "none"; - leftColumn.style.cursor = "pointer"; - leftColumn.onclick = options.onCheckboxClick; - } - } else { - // For blueprint and add cards, create icon column - leftColumn = document.createElement("div"); - const borderColor = type === 'blueprint' ? '#666' : '#444'; - leftColumn.style.cssText = `flex: 0 0 40px; display: flex; align-items: center; justify-content: center; border-right: ${type === 'blueprint' ? '2px' : '1px'} solid ${borderColor}; color: ${type === 'blueprint' ? '#888' : '#555'}; font-size: ${type === 'blueprint' ? '24px' : '18px'}; font-weight: ${type === 'blueprint' ? 'bold' : 'normal'};`; - leftColumn.innerHTML = "+"; - - if (options.onHover) { - card.addEventListener('mouseenter', () => { - leftColumn.style.color = type === 'blueprint' ? '#aaa' : '#888'; - leftColumn.style.borderColor = type === 'blueprint' ? '#888' : '#666'; - }); - card.addEventListener('mouseleave', () => { - leftColumn.style.color = type === 'blueprint' ? '#888' : '#555'; - leftColumn.style.borderColor = borderColor; - }); - } - } - - // Create right column - const rightColumn = this.createCardColumn('content'); - - // Add hover effect for master/worker cards - if ((type === 'master' || type === 'worker') && options.onHover !== false) { - rightColumn.onmouseover = () => rightColumn.style.backgroundColor = "#333"; - rightColumn.onmouseout = () => rightColumn.style.backgroundColor = "transparent"; - } - - // Create info row - const infoRow = this.createInfoRow(options.infoRowOptions); - - // Create worker content - const workerContent = this.createWorkerContent(); - - // Build the card - infoRow.appendChild(workerContent); - rightColumn.appendChild(infoRow); - - // Add controls div if specified - if (options.includeControls) { - const controlsDiv = document.createElement("div"); - controlsDiv.id = options.controlsId; - controlsDiv.style.cssText = styles.controlsDiv; - rightColumn.appendChild(controlsDiv); - } - - // Add settings div if specified - if (options.includeSettings) { - const settingsDiv = document.createElement("div"); - settingsDiv.id = options.settingsId; - settingsDiv.className = "worker-settings"; - settingsDiv.style.cssText = styles.workerSettings; - rightColumn.appendChild(settingsDiv); - } - - card.appendChild(leftColumn); - card.appendChild(rightColumn); - - return { - card, - leftColumn, - rightColumn, - infoRow, - workerContent, - checkbox: leftColumn.querySelector('input[type="checkbox"]') - }; - }, - - styles // Expose styles for custom usage - }; - } - - // --- State Management --- - _createStateManager() { - const state = { - workers: new Map(), // Unified worker state: { status, managed, launching, expanded, ... } - masterStatus: 'online', - domCache: new Map() // Cache DOM elements - }; - - return { - // Worker state management - getWorker(workerId) { - return state.workers.get(String(workerId)) || {}; - }, - - updateWorker(workerId, updates) { - const id = String(workerId); - const current = state.workers.get(id) || {}; - state.workers.set(id, { ...current, ...updates }); - return state.workers.get(id); - }, - - setWorkerStatus(workerId, status) { - return this.updateWorker(workerId, { status }); - }, - - setWorkerManaged(workerId, info) { - return this.updateWorker(workerId, { managed: info }); - }, - - setWorkerLaunching(workerId, launching) { - return this.updateWorker(workerId, { launching }); - }, - - setWorkerExpanded(workerId, expanded) { - return this.updateWorker(workerId, { expanded }); - }, - - isWorkerLaunching(workerId) { - return this.getWorker(workerId).launching || false; - }, - - isWorkerExpanded(workerId) { - return this.getWorker(workerId).expanded || false; - }, - - isWorkerManaged(workerId) { - return !!this.getWorker(workerId).managed; - }, - - getWorkerStatus(workerId) { - return this.getWorker(workerId).status || {}; - }, - - // Master state - setMasterStatus(status) { - state.masterStatus = status; - }, - - getMasterStatus() { - return state.masterStatus; - }, - - // DOM cache - cacheElement(key, element) { - state.domCache.set(key, element); - }, - - getCachedElement(key) { - return state.domCache.get(key); - }, - - clearCache() { - state.domCache.clear(); - } - }; - } - - // --- API Client --- - _createApiClient() { - const baseUrl = window.location.origin; - - const request = async (endpoint, options = {}) => { - try { - const response = await fetch(`${baseUrl}${endpoint}`, { - headers: { 'Content-Type': 'application/json' }, - ...options - }); - - if (!response.ok) { - const error = await response.json().catch(() => ({ message: 'Request failed' })); - throw new Error(error.message || `HTTP ${response.status}`); - } - - return await response.json(); - } catch (error) { - this.log(`API Error: ${endpoint} - ${error.message}`); - throw error; - } - }; - - return { - // Config endpoints - async getConfig() { - return request('/distributed/config'); - }, - - async updateWorker(workerId, data) { - return request('/distributed/config/update_worker', { - method: 'POST', - body: JSON.stringify({ worker_id: workerId, ...data }) - }); - }, - - async deleteWorker(workerId) { - return request('/distributed/config/delete_worker', { - method: 'POST', - body: JSON.stringify({ worker_id: workerId }) - }); - }, - - async updateSetting(key, value) { - return request('/distributed/config/update_setting', { - method: 'POST', - body: JSON.stringify({ key, value }) - }); - }, - - async updateMaster(data) { - return request('/distributed/config/update_master', { - method: 'POST', - body: JSON.stringify(data) - }); - }, - - // Worker management endpoints - async launchWorker(workerId) { - return request('/distributed/launch_worker', { - method: 'POST', - body: JSON.stringify({ worker_id: workerId }) - }); - }, - - async stopWorker(workerId) { - return request('/distributed/stop_worker', { - method: 'POST', - body: JSON.stringify({ worker_id: workerId }) - }); - }, - - async getManagedWorkers() { - return request('/distributed/managed_workers'); - }, - - async getWorkerLog(workerId, lines = 1000) { - return request(`/distributed/worker_log/${workerId}?lines=${lines}`); - }, - - async clearLaunchingFlag(workerId) { - return request('/distributed/worker/clear_launching', { - method: 'POST', - body: JSON.stringify({ worker_id: workerId }) - }); - }, - - // Job preparation - async prepareJob(multiJobId) { - return request('/distributed/prepare_job', { - method: 'POST', - body: JSON.stringify({ multi_job_id: multiJobId }) - }); - }, - - // Image loading - async loadImage(imagePath) { - return request('/distributed/load_image', { - method: 'POST', - body: JSON.stringify({ image_path: imagePath }) - }); - }, - - // Network info - async getNetworkInfo() { - return request('/distributed/network_info'); - }, - - // Status checking (with timeout) - async checkStatus(url, timeout = 900) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - - try { - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - signal: controller.signal - }); - clearTimeout(timeoutId); - - if (!response.ok) throw new Error(`HTTP ${response.status}`); - return await response.json(); - } catch (error) { - clearTimeout(timeoutId); - throw error; - } - }, - - // Batch status checking - async checkMultipleStatuses(urls) { - return Promise.allSettled( - urls.map(url => this.checkStatus(url)) - ); - } - }; - } - - injectStyles() { - const styleId = 'distributed-styles'; - if (!document.getElementById(styleId)) { - const style = document.createElement('style'); - style.id = styleId; - style.textContent = DistributedExtension.PULSE_ANIMATION_CSS; - document.head.appendChild(style); - } - } - - // --- State & Config Management (Single Source of Truth) --- - - get enabledWorkers() { - return this.config?.workers?.filter(w => w.enabled) || []; - } - - get isEnabled() { - return this.enabledWorkers.length > 0; - } - - async loadConfig() { - try { - this.config = await this.api.getConfig(); - this.log("Loaded config: " + JSON.stringify(this.config)); - } catch (error) { - this.log("Failed to load config: " + error.message); - this.config = { workers: [], settings: {} }; - } - } - - async updateWorkerEnabled(workerId, enabled) { - const worker = this.config.workers.find(w => w.id === workerId); - if (worker) { - worker.enabled = enabled; - } - - try { - await this.api.updateWorker(workerId, { enabled }); - } catch (error) { - this.log("Error updating worker: " + error.message); - } - } - - async _updateSetting(key, value) { - // Update local config - if (!this.config.settings) { - this.config.settings = {}; - } - this.config.settings[key] = value; - - try { - await this.api.updateSetting(key, value); - - app.extensionManager.toast.add({ - severity: "success", - summary: "Setting Updated", - detail: `${key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} ${value ? 'enabled' : 'disabled'}`, - life: 2000 - }); - } catch (error) { - this.log(`Error updating setting '${key}': ${error.message}`); - app.extensionManager.toast.add({ - severity: "error", - summary: "Setting Update Failed", - detail: error.message, - life: 3000 - }); - } - } - - // --- UI Rendering --- - - registerSidebarTab() { - app.extensionManager.registerSidebarTab({ - id: "distributed", - icon: "pi pi-server", - title: "Distributed", - tooltip: "Distributed Control Panel", - type: "custom", - render: (el) => this.renderSidebarContent(el) - }); - } - - _createButton(text, onClick, style) { - return this.ui.createButton(text, onClick, style); - } - - // Generic handler for parallel worker operations - async _handleWorkerOperation(button, operation, successText, errorText) { - const originalText = button.textContent; - const originalStyle = button.style.cssText; - - button.textContent = operation.loadingText; - button.disabled = true; - - try { - const urlsToProcess = this.enabledWorkers.map(w => ({ - name: w.name, - url: this.getWorkerUrl(w) - })); - - if (urlsToProcess.length === 0) { - button.textContent = "No Workers"; - button.style.backgroundColor = "#c04c4c"; - setTimeout(() => { - button.textContent = originalText; - button.style.cssText = originalStyle; - button.disabled = false; - }, 3000); - return; - } - - const promises = urlsToProcess.map(target => - fetch(`${target.url}${operation.endpoint}`, { - method: 'POST', - mode: 'cors' - }) - .then(response => ({ ok: response.ok, name: target.name })) - .catch(() => ({ ok: false, name: target.name })) - ); - - const results = await Promise.all(promises); - const failures = results.filter(r => !r.ok); - - if (failures.length === 0) { - button.textContent = successText; - button.style.backgroundColor = DistributedExtension.BUTTON_STYLES.success.split(':')[1].trim().replace(';', ''); - if (operation.onSuccess) operation.onSuccess(); - } else { - button.textContent = errorText; - button.style.backgroundColor = DistributedExtension.BUTTON_STYLES.error.split(':')[1].trim().replace(';', ''); - this.log(`${operation.name} failed on: ${failures.map(f => f.name).join(", ")}`); - } - - setTimeout(() => { - button.textContent = originalText; - button.style.cssText = originalStyle; - }, 3000); - } finally { - button.disabled = false; - } - } - - async _handleInterruptWorkers(button) { - return this._handleWorkerOperation(button, { - name: "Interrupt", - endpoint: "/interrupt", - loadingText: "Interrupting...", - onSuccess: () => setTimeout(() => this.checkAllWorkerStatuses(), 500) - }, "Interrupted!", "Error! See Console"); - } - - async _handleClearMemory(button) { - return this._handleWorkerOperation(button, { - name: "Clear memory", - endpoint: "/distributed/clear_memory", - loadingText: "Clearing..." - }, "Success!", "Error! See Console"); - } - - - async renderSidebarContent(el) { - // Panel is being opened/rendered - this.debugLog("Panel opened"); - - if (!el) { - this.debugLog("No element provided to renderSidebarContent"); - return; - } - - // Prevent infinite recursion - if (this._isRendering) { - this.debugLog("Already rendering, skipping"); - return; - } - this._isRendering = true; - - try { - // Store reference to the panel element - this.panelElement = el; - - // Reload config when panel opens to pick up any external changes - await this.loadConfig(); - - // Reload managed workers when panel opens - this.loadManagedWorkers().then(() => { - // Update all worker controls after loading - if (this.config?.workers) { - this.config.workers.forEach(w => this.updateWorkerControls(w.id)); - } - }); - - // Start checking worker statuses immediately in parallel - // Results will update the UI as they come in - setTimeout(() => this.checkAllWorkerStatuses(), 0); - - 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;"; - - const toolbarStart = document.createElement("div"); - toolbarStart.className = "p-toolbar-start"; - - 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) - this.log(`Panel opened. CUDA device count: ${this.cudaDeviceCount}, Workers: ${this.config?.workers?.length || 0}`); - if (!this.cudaDeviceCount) { - await this.detectMasterIP(); - } - - // Check if we should auto-populate workers after detection - if (this.cudaDeviceCount > 0 && (!this.config.workers || this.config.workers.length === 0)) { - this.log(`Auto-populating workers based on ${this.cudaDeviceCount} CUDA devices (excluding master on CUDA ${this.masterCudaDevice})`); - - const newWorkers = []; - let workerNum = 1; - let portOffset = 0; - - for (let i = 0; i < this.cudaDeviceCount; i++) { - // Skip the CUDA device used by master - if (i === this.masterCudaDevice) { - this.log(`Skipping CUDA ${i} (used by master)`); - continue; - } - - const worker = { - id: Date.now() + i, - name: `Worker ${workerNum}`, - host: "localhost", - port: 8189 + portOffset, - cuda_device: i, - enabled: true - }; - newWorkers.push(worker); - workerNum++; - portOffset++; - } - - // Only proceed if we have workers to add - if (newWorkers.length > 0) { - this.log(`Auto-populating ${newWorkers.length} workers`); - - // Add workers to config - this.config.workers = newWorkers; - - // Save each worker using the update endpoint - for (const worker of newWorkers) { - try { - await this.api.updateWorker(worker.id, worker); - } catch (error) { - this.log(`Error saving worker ${worker.name}: ${error.message}`); - } - } - - this.log(`Auto-populated ${newWorkers.length} workers and saved config`); - - // Reload the config to include the new workers - await this.loadConfig(); - - // Continue rendering with the updated config - } else { - this.log("No additional CUDA devices available for workers (all used by master)"); - } - } - - // Master Node Section - styled exactly like a worker card - const masterDiv = this.ui.createCard('master'); - - // Left column - checkbox area (always checked and disabled) - const checkboxColumn = this.ui.createCardColumn('checkbox', { title: "Master node is always enabled" }); - - // Greyed out checkbox that's always checked - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.checked = true; - checkbox.disabled = true; - checkbox.style.cssText = "cursor: default; width: 16px; height: 16px; opacity: 0.6;"; - - checkboxColumn.appendChild(checkbox); - - // Right column - content area - const contentColumn = this.ui.createCardColumn('content'); - - // First row: master info - const infoRow = this.ui.createInfoRow({ onClick: null }); - infoRow.title = "Click to expand settings"; - - // Worker content - const workerContent = this.ui.createWorkerContent(); - - const statusDot = this.ui.createStatusDot("master-status", "#4CAF50", "Online"); - - const gpuInfo = document.createElement("span"); - const cudaInfo = this.masterCudaDevice !== undefined ? `CUDA ${this.masterCudaDevice} • ` : ''; - const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); - gpuInfo.innerHTML = `${this.config?.master?.name || "Master"}
${cudaInfo}Port ${port}`; - - workerContent.appendChild(statusDot); - workerContent.appendChild(gpuInfo); - - // Settings arrow - const settingsArrow = document.createElement("span"); - settingsArrow.className = "settings-arrow"; - settingsArrow.innerHTML = "▶"; - settingsArrow.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto;"; - - infoRow.appendChild(workerContent); - infoRow.appendChild(settingsArrow); - - // Click handler for info row - let masterSettingsExpanded = false; - infoRow.onclick = () => { - masterSettingsExpanded = !masterSettingsExpanded; - if (masterSettingsExpanded) { - masterSettingsDiv.classList.add("expanded"); - masterSettingsDiv.style.padding = "12px"; - masterSettingsDiv.style.marginTop = "8px"; - masterSettingsDiv.style.marginBottom = "8px"; - settingsArrow.style.transform = "rotate(90deg)"; - } else { - masterSettingsDiv.classList.remove("expanded"); - masterSettingsDiv.style.padding = "0 12px"; - masterSettingsDiv.style.marginTop = "0"; - masterSettingsDiv.style.marginBottom = "0"; - settingsArrow.style.transform = "rotate(0deg)"; - } - }; - - // Hover effect for entire content column - contentColumn.onmouseover = () => { - contentColumn.style.backgroundColor = "#333"; - settingsArrow.style.color = "#fff"; - }; - contentColumn.onmouseout = () => { - contentColumn.style.backgroundColor = "transparent"; - settingsArrow.style.color = "#888"; - }; - - contentColumn.appendChild(infoRow); - - // Second row: master controls (styled like remote worker) - const controlsDiv = document.createElement("div"); - controlsDiv.style.cssText = "padding: 0 8px 6px 8px; display: flex; gap: 4px;"; - - // Master info box styled like remote worker - const masterInfo = document.createElement("div"); - masterInfo.style.cssText = "background-color: #333; color: #888; padding: 4px 12px; border-radius: 4px; font-size: 11px; text-align: center; flex: 1;"; - masterInfo.textContent = "Master"; - controlsDiv.appendChild(masterInfo); - - // Third row: expandable settings panel - const masterSettingsDiv = document.createElement("div"); - masterSettingsDiv.id = "master-settings"; - masterSettingsDiv.className = "worker-settings"; - masterSettingsDiv.style.cssText = "margin: 0 8px; padding: 0 12px; background: #1e1e1e; border-radius: 4px;"; - - // Create settings form using reusable components - const settingsForm = document.createElement("div"); - settingsForm.style.cssText = "display: flex; flex-direction: column; gap: 8px;"; - - // Name field - const nameResult = this.ui.createFormGroup("Name:", this.config?.master?.name || "Master", "master-name"); - settingsForm.appendChild(nameResult.group); - - // Host field - const hostResult = this.ui.createFormGroup("Host:", this.config?.master?.host || "", "master-host", "text", "Auto-detect if empty"); - settingsForm.appendChild(hostResult.group); - - // Buttons row - using same style as workers - const saveBtn = this._createButton("Save", - async () => { - const nameInput = document.getElementById('master-name'); - const hostInput = document.getElementById('master-host'); - - // Update config - if (!this.config.master) this.config.master = {}; - this.config.master.name = nameInput.value.trim() || "Master"; - - // Save both host and name - await this.saveMasterConfig({ - host: hostInput.value.trim(), - name: this.config.master.name - }); - - // Update display - document.getElementById('master-name-display').textContent = this.config.master.name; - this.updateMasterDisplay(); - - saveBtn.textContent = "Saved!"; - setTimeout(() => { saveBtn.textContent = "Save"; }, 2000); - }, - "background-color: #4a7c4a;"); - saveBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; - - const cancelBtn = this._createButton("Cancel", - () => { - // Reset inputs to original values - document.getElementById('master-name').value = this.config?.master?.name || "Master"; - document.getElementById('master-host').value = this.config?.master?.host || ""; - }, - "background-color: #555;"); - cancelBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; - - const buttonGroup = this.ui.createButtonGroup([saveBtn, cancelBtn], " margin-top: 8px;"); - settingsForm.appendChild(buttonGroup); - masterSettingsDiv.appendChild(settingsForm); - - contentColumn.appendChild(controlsDiv); - contentColumn.appendChild(masterSettingsDiv); - - masterDiv.appendChild(checkboxColumn); - masterDiv.appendChild(contentColumn); - 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 = this.config?.workers || []; - - // If no workers exist, show a full blueprint placeholder first - if (workers.length === 0) { - const blueprintDiv = this.ui.createCard('blueprint', { - title: "Click to add your first worker", - onClick: () => this.addNewWorker() - }); - - // Left column - plus icon - const blueprintCheckbox = document.createElement("div"); - blueprintCheckbox.style.cssText = "flex: 0 0 40px; display: flex; align-items: center; justify-content: center; border-right: 2px solid #666; color: #888; font-size: 24px; font-weight: bold;"; - blueprintCheckbox.innerHTML = "+"; - - // Right column - const blueprintContent = this.ui.createCardColumn('content'); - - // Info row with placeholder content - const blueprintInfo = this.ui.createInfoRow(); - - const blueprintWorkerContent = this.ui.createWorkerContent(); - - const blueprintDot = this.ui.createStatusDot(null, "transparent", ""); - blueprintDot.style.border = "1px solid #555"; - - const blueprintText = document.createElement("span"); - blueprintText.innerHTML = `Add New Worker
[CUDA] • [Port]`; - - blueprintWorkerContent.appendChild(blueprintDot); - blueprintWorkerContent.appendChild(blueprintText); - blueprintInfo.appendChild(blueprintWorkerContent); - - // Placeholder button row - single Launch button spanning width - const blueprintButtons = document.createElement("div"); - blueprintButtons.style.cssText = "padding: 0 8px 6px 8px;"; - - const ghostLaunch = document.createElement("div"); - ghostLaunch.style.cssText = "width: 100%; padding: 3px 8px; font-size: 11px; border: 1px solid #444; background: transparent; color: #555; border-radius: 4px; text-align: center;"; - ghostLaunch.textContent = "Launch"; - - blueprintButtons.appendChild(ghostLaunch); - - blueprintContent.appendChild(blueprintInfo); - blueprintContent.appendChild(blueprintButtons); - - blueprintDiv.appendChild(blueprintCheckbox); - blueprintDiv.appendChild(blueprintContent); - - // Hover effect - blueprintDiv.onmouseover = () => { - blueprintDiv.style.borderColor = "#888"; - blueprintDiv.style.backgroundColor = "rgba(255, 255, 255, 0.05)"; - blueprintCheckbox.style.borderColor = "#888"; - blueprintCheckbox.style.color = "#aaa"; - }; - blueprintDiv.onmouseout = () => { - blueprintDiv.style.borderColor = "#666"; - blueprintDiv.style.backgroundColor = "rgba(255, 255, 255, 0.02)"; - blueprintCheckbox.style.borderColor = "#666"; - blueprintCheckbox.style.color = "#888"; - }; - - gpuList.appendChild(blueprintDiv); - } - - // Show existing workers - workers.forEach(worker => { - const gpuDiv = this.ui.createCard('worker'); - - // Left column - checkbox area (extends full height) - const checkboxColumn = this.ui.createCardColumn('checkbox'); - checkboxColumn.style.cursor = "pointer"; - checkboxColumn.title = "Enable/disable this worker"; - - // Standard checkbox - const checkbox = document.createElement("input"); - checkbox.type = "checkbox"; - checkbox.id = `gpu-${worker.id}`; - checkbox.checked = worker.enabled; - checkbox.style.cssText = "cursor: pointer; width: 16px; height: 16px; pointer-events: none;"; // pointer-events: none so the column click handles it - - checkboxColumn.appendChild(checkbox); - - // Click handler for checkbox column - checkboxColumn.onclick = async (e) => { - checkbox.checked = !checkbox.checked; - await this.updateWorkerEnabled(worker.id, checkbox.checked); - this.updateSummary(); - }; - - // Right column - content area - const contentColumn = this.ui.createCardColumn('content'); - - // First row: worker info - const infoRow = this.ui.createInfoRow(); - infoRow.title = "Click to expand settings"; - - // Worker content - const workerContent = this.ui.createWorkerContent(); - - const statusDot = this.ui.createStatusDot(`status-${worker.id}`, "#666", "Checking status..."); - - const gpuInfo = document.createElement("span"); - const isRemote = this.isRemoteWorker(worker); - - if (isRemote) { - // For remote workers: show host:port - gpuInfo.innerHTML = `${worker.name}
${worker.host}:${worker.port}`; - } else { - // For local workers: show CUDA and port - const cudaInfo = worker.cuda_device !== undefined ? `CUDA ${worker.cuda_device} • ` : ''; - gpuInfo.innerHTML = `${worker.name}
${cudaInfo}Port ${worker.port}`; - } - - workerContent.appendChild(statusDot); - workerContent.appendChild(gpuInfo); - - // Settings arrow - const settingsArrow = document.createElement("span"); - settingsArrow.className = "settings-arrow"; - settingsArrow.innerHTML = "▶"; - settingsArrow.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto;"; - if (this.state.isWorkerExpanded(worker.id)) { - settingsArrow.style.transform = "rotate(90deg)"; - } - - infoRow.appendChild(workerContent); - infoRow.appendChild(settingsArrow); - - - // Add content and arrow back to info row - infoRow.appendChild(workerContent); - infoRow.appendChild(settingsArrow); - - // Click handler for info row - infoRow.onclick = () => { - this.toggleWorkerExpanded(worker.id); - }; - - // Hover effect for entire content column - contentColumn.onmouseover = () => { - contentColumn.style.backgroundColor = "#333"; - settingsArrow.style.color = "#fff"; - }; - contentColumn.onmouseout = () => { - contentColumn.style.backgroundColor = "transparent"; - settingsArrow.style.color = "#888"; - }; - - contentColumn.appendChild(infoRow); - - // Second row: launch controls - const controlsDiv = document.createElement("div"); - controlsDiv.id = `controls-${worker.id}`; - controlsDiv.style.cssText = "padding: 0 8px 6px 8px; display: flex; gap: 4px;"; - - if (this.isRemoteWorker(worker)) { - // For remote workers, show styled info box - const remoteInfo = document.createElement("div"); - remoteInfo.style.cssText = "background-color: #333; color: #888; padding: 4px 12px; border-radius: 4px; font-size: 11px; text-align: center; flex: 1;"; - remoteInfo.textContent = "Remote worker"; - controlsDiv.appendChild(remoteInfo); - } else { - // Local workers get launch/stop/log buttons - const controls = this.ui.createWorkerControls(worker.id, { - launch: () => this.launchWorker(worker.id), - stop: () => this.stopWorker(worker.id), - viewLog: () => this.viewWorkerLog(worker.id) - }); - - // Apply custom button styles - const launchBtn = controls.querySelector(`#launch-${worker.id}`); - const stopBtn = controls.querySelector(`#stop-${worker.id}`); - const logBtn = controls.querySelector(`#log-${worker.id}`); - - launchBtn.style.cssText += ' padding: 3px 8px; font-size: 11px; background-color: #555;'; - launchBtn.title = "Launch worker (runs in background with logging)"; - - stopBtn.style.cssText += ' padding: 3px 8px; font-size: 11px; background-color: #555;'; - stopBtn.title = "Stop worker"; - - logBtn.style.cssText += ' padding: 3px 8px; font-size: 11px; display: none; background-color: #685434;'; - - // Move controls into the controlsDiv - while (controls.firstChild) { - controlsDiv.appendChild(controls.firstChild); - } - } - - // Third row: expandable settings panel - const settingsDiv = document.createElement("div"); - settingsDiv.id = `settings-${worker.id}`; - settingsDiv.className = "worker-settings"; - if (this.state.isWorkerExpanded(worker.id)) { - settingsDiv.classList.add("expanded"); - } - settingsDiv.style.cssText = "margin: 0 8px; padding: 0 12px; background: #1e1e1e; border-radius: 4px;"; - if (this.state.isWorkerExpanded(worker.id)) { - settingsDiv.style.padding = "12px"; - settingsDiv.style.marginTop = "8px"; - settingsDiv.style.marginBottom = "8px"; - } - - // Create settings form - const settingsForm = this.createWorkerSettingsForm(worker); - settingsDiv.appendChild(settingsForm); - - contentColumn.appendChild(controlsDiv); - contentColumn.appendChild(settingsDiv); - - gpuDiv.appendChild(checkboxColumn); - gpuDiv.appendChild(contentColumn); - gpuList.appendChild(gpuDiv); - - // Update controls based on managed worker status - this.updateWorkerControls(worker.id); - }); - gpuSection.appendChild(gpuList); - - // Only show the minimal "Add Worker" box if there are existing workers - if (workers.length > 0) { - // Add Worker placeholder box - const addWorkerDiv = this.ui.createCard('add', { - title: "Click to add a new worker", - onClick: () => this.addNewWorker() - }); - - // Left column - plus icon area - const plusColumn = document.createElement("div"); - plusColumn.style.cssText = "flex: 0 0 40px; display: flex; align-items: center; justify-content: center; border-right: 1px solid #444; color: #555; font-size: 18px;"; - plusColumn.innerHTML = "+"; - - // Right column - content area - const addContentColumn = this.ui.createCardColumn('content'); - - // Info row - const addInfoRow = this.ui.createInfoRow(); - addInfoRow.style.minHeight = "42px"; // Reduced from 60px to 42px (30% reduction) - - // Placeholder content - const addContent = this.ui.createWorkerContent(); - - // Placeholder dot - const placeholderDot = this.ui.createStatusDot(null, "transparent", ""); - placeholderDot.style.border = "1px solid #555"; - - // Placeholder text - const placeholderInfo = document.createElement("span"); - placeholderInfo.innerHTML = `Add New Worker`; - - addContent.appendChild(placeholderDot); - addContent.appendChild(placeholderInfo); - addInfoRow.appendChild(addContent); - addContentColumn.appendChild(addInfoRow); - - addWorkerDiv.appendChild(plusColumn); - addWorkerDiv.appendChild(addContentColumn); - - // Hover effects - addWorkerDiv.onmouseover = () => { - addWorkerDiv.style.borderColor = "#666"; - addWorkerDiv.style.backgroundColor = "rgba(255, 255, 255, 0.02)"; - plusColumn.style.color = "#888"; - plusColumn.style.borderColor = "#666"; - }; - - addWorkerDiv.onmouseout = () => { - addWorkerDiv.style.borderColor = "#444"; - addWorkerDiv.style.backgroundColor = "transparent"; - plusColumn.style.color = "#555"; - plusColumn.style.borderColor = "#444"; - }; - - 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 = this._createButton("Clear Worker VRAM", (e) => this._handleClearMemory(e.target), DistributedExtension.BUTTON_STYLES.clearMemory); - clearMemButton.title = "Clear VRAM on all enabled worker GPUs (not master)"; - clearMemButton.style.cssText += " flex: 1;"; - - const interruptButton = this._createButton("Interrupt Workers", (e) => this._handleInterruptWorkers(e.target), DistributedExtension.BUTTON_STYLES.clearMemory); - interruptButton.title = "Cancel/interrupt execution on all enabled worker GPUs"; - interruptButton.style.cssText += " flex: 1;"; - - buttonRow.appendChild(clearMemButton); - buttonRow.appendChild(interruptButton); - actionsSection.appendChild(buttonRow); - - container.appendChild(actionsSection); - - // Settings section - const settingsSection = document.createElement("div"); - settingsSection.style.cssText = "border-top: 1px solid #444; padding-top: 10px; margin-bottom: 10px;"; - - // Settings header with toggle - const settingsHeader = document.createElement("div"); - settingsHeader.style.cssText = "display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none;"; - - const workerSettingsTitle = document.createElement("h4"); - workerSettingsTitle.textContent = "Settings"; - workerSettingsTitle.style.cssText = "margin: 0; font-size: 14px;"; - - const workerSettingsToggle = document.createElement("span"); - workerSettingsToggle.textContent = "▶"; // Right arrow when collapsed - workerSettingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; - - settingsHeader.appendChild(workerSettingsTitle); - settingsHeader.appendChild(workerSettingsToggle); - - // Hover effect for header - settingsHeader.onmouseover = () => { - workerSettingsToggle.style.color = "#fff"; - }; - settingsHeader.onmouseout = () => { - workerSettingsToggle.style.color = "#888"; - }; - - // Collapsible settings content - const settingsContent = document.createElement("div"); - settingsContent.style.cssText = "max-height: 0; overflow: hidden; transition: max-height 0.3s ease;"; - - const settingsDiv = document.createElement("div"); - settingsDiv.style.cssText = "display: flex; flex-direction: column; gap: 8px; padding-top: 10px;"; - - // Toggle functionality - let settingsExpanded = false; - settingsHeader.onclick = () => { - settingsExpanded = !settingsExpanded; - if (settingsExpanded) { - settingsContent.style.maxHeight = "200px"; - workerSettingsToggle.style.transform = "rotate(90deg)"; - } else { - settingsContent.style.maxHeight = "0"; - workerSettingsToggle.style.transform = "rotate(0deg)"; - } - }; - - // Debug mode setting - const debugGroup = document.createElement("div"); - debugGroup.style.cssText = "display: flex; align-items: center; gap: 8px;"; - - const debugCheckbox = document.createElement("input"); - debugCheckbox.type = "checkbox"; - debugCheckbox.id = "setting-debug"; - debugCheckbox.checked = this.config?.settings?.debug || false; - debugCheckbox.onchange = (e) => this._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;"; - - debugGroup.appendChild(debugCheckbox); - debugGroup.appendChild(debugLabel); - - // Auto-launch workers setting - const autoLaunchGroup = document.createElement("div"); - autoLaunchGroup.style.cssText = "display: flex; align-items: center; gap: 8px;"; - - const autoLaunchCheckbox = document.createElement("input"); - autoLaunchCheckbox.type = "checkbox"; - autoLaunchCheckbox.id = "setting-auto-launch"; - autoLaunchCheckbox.checked = this.config?.settings?.auto_launch_workers || false; - autoLaunchCheckbox.onchange = (e) => this._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;"; - - autoLaunchGroup.appendChild(autoLaunchCheckbox); - autoLaunchGroup.appendChild(autoLaunchLabel); - - // Stop workers on exit setting - const stopOnExitGroup = document.createElement("div"); - stopOnExitGroup.style.cssText = "display: flex; align-items: center; gap: 8px;"; - - const stopOnExitCheckbox = document.createElement("input"); - stopOnExitCheckbox.type = "checkbox"; - stopOnExitCheckbox.id = "setting-stop-on-exit"; - stopOnExitCheckbox.checked = this.config?.settings?.stop_workers_on_master_exit !== false; // Default true - stopOnExitCheckbox.onchange = (e) => this._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;"; - - stopOnExitGroup.appendChild(stopOnExitCheckbox); - stopOnExitGroup.appendChild(stopOnExitLabel); - - settingsDiv.appendChild(debugGroup); - settingsDiv.appendChild(autoLaunchGroup); - settingsDiv.appendChild(stopOnExitGroup); - settingsContent.appendChild(settingsDiv); - - settingsSection.appendChild(settingsHeader); - settingsSection.appendChild(settingsContent); - container.appendChild(settingsSection); - - const summarySection = document.createElement("div"); - summarySection.style.cssText = "border-top: 1px solid #444; padding-top: 10px;"; - const summary = document.createElement("div"); - summary.id = "distributed-summary"; - summary.style.cssText = "font-size: 11px; color: #888;"; - summarySection.appendChild(summary); - container.appendChild(summarySection); - el.appendChild(container); - this.updateSummary(); - } finally { - // Always reset the rendering flag - this._isRendering = false; - } - } - - updateMasterDisplay() { - // Update CUDA info if element exists - const cudaInfo = document.getElementById('master-cuda-info'); - if (cudaInfo) { - const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); - if (this.masterCudaDevice !== undefined && this.masterCudaDevice !== null) { - cudaInfo.textContent = `CUDA ${this.masterCudaDevice} • Port ${port}`; - } else { - cudaInfo.textContent = `Port ${port}`; - } - } - - // Update name if changed - const nameDisplay = document.getElementById('master-name-display'); - if (nameDisplay && this.config?.master?.name) { - nameDisplay.textContent = this.config.master.name; - } - } - - updateSummary() { - const summaryEl = document.getElementById('distributed-summary'); - if (summaryEl) { - const totalGPUs = this.enabledWorkers.length + 1; - if (this.isEnabled) { - summaryEl.textContent = `If Collector node is present, total generation = (${totalGPUs} GPUs × Batch Size)`; - } else { - summaryEl.textContent = "Only the master GPU will be used."; - } - } - } - - // --- Helper Methods --- - - // --- Core Logic & Execution --- - - setupInterceptor() { - api.queuePrompt = async (number, prompt) => { - if (this.isEnabled) { - const hasCollector = this.findNodesByClass(prompt.output, "DistributedCollector").length > 0; - const hasDistUpscale = this.findNodesByClass(prompt.output, "UltimateSDUpscaleDistributed").length > 0; - - if (hasCollector || hasDistUpscale) { - const result = await this.executeParallelDistributed(prompt); - // Check status after dispatching jobs - setTimeout(() => this.checkAllWorkerStatuses(), 500); - return result; - } - } - return this.originalQueuePrompt(number, prompt); - }; - } - - async executeParallelDistributed(promptWrapper) { - try { - const executionPrefix = "exec_" + Date.now(); // Unique ID for this specific execution - const enabledWorkers = this.enabledWorkers; - - // Pre-flight health check on all enabled workers - const activeWorkers = await this.performPreflightCheck(enabledWorkers); - - if (activeWorkers.length === 0 && enabledWorkers.length > 0) { - this.log("No active workers found. Running on master only."); - // Fall back to master-only execution - return this.originalQueuePrompt(0, promptWrapper); - } - - this.debugLog(`Pre-flight check: ${activeWorkers.length} of ${enabledWorkers.length} workers are active`); - - // Find all distributed nodes in the workflow - const collectorNodes = this.findNodesByClass(promptWrapper.output, "DistributedCollector"); - const upscaleNodes = this.findNodesByClass(promptWrapper.output, "UltimateSDUpscaleDistributed"); - const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; - - // Map original node IDs to truly unique job IDs for this specific run - const job_id_map = new Map(allDistributedNodes.map(node => [node.id, `${executionPrefix}_${node.id}`])); - - // Prepare a separate job queue on the backend for each unique job ID - const preparePromises = Array.from(job_id_map.values()).map(uniqueId => this._prepareDistributedJob(uniqueId)); - await Promise.all(preparePromises); - - const jobs = []; - // Use only active workers - const participants = ['master', ...activeWorkers.map(w => w.id)]; - - for (const participantId of participants) { - const options = { - enabled_worker_ids: activeWorkers.map(w => w.id), - workflow: promptWrapper.workflow, - job_id_map: job_id_map // Pass the map of unique IDs - }; - - const jobApiPrompt = this._prepareApiPromptForParticipant( - promptWrapper.output, participantId, options - ); - - if (participantId === 'master') { - jobs.push({ type: 'master', promptWrapper: { ...promptWrapper, output: jobApiPrompt } }); - } else { - const worker = activeWorkers.find(w => w.id === participantId); - if (worker) { - const job = { - type: 'worker', - worker, - prompt: jobApiPrompt, - workflow: promptWrapper.workflow - }; - - // Add image references if found for remote workers - if (options._imageReferences) { - job.imageReferences = options._imageReferences; - } - - jobs.push(job); - } - } - } - - const result = await this._executeJobs(jobs); - return result; - } catch (error) { - this.log("Parallel execution failed: " + error.message); - throw error; - } - } - - - async _executeJobs(jobs) { - let masterPromptId = null; - - // Pre-load all unique images before dispatching to workers - const allImageReferences = new Map(); - for (const job of jobs) { - if (job.type === 'worker' && job.imageReferences) { - for (const [filename, info] of job.imageReferences) { - allImageReferences.set(filename, info); - } - } - } - - if (allImageReferences.size > 0) { - this.debugLog(`Pre-loading ${allImageReferences.size} unique image(s) for all workers`); - await this.loadImagesForWorker(allImageReferences); - } - - // Now dispatch jobs in parallel - const promises = jobs.map(job => { - if (job.type === 'master') { - return this.originalQueuePrompt(0, job.promptWrapper).then(result => { - masterPromptId = result; - return result; - }); - } else { - return this._dispatchToWorker(job.worker, job.prompt, job.workflow, job.imageReferences); - } - }); - await Promise.all(promises); - return masterPromptId || { "prompt_id": "distributed-job-dispatched" }; - } - - // --- Helper Methods --- - - findNodesByClass(apiPrompt, className) { - return Object.entries(apiPrompt) - .filter(([, nodeData]) => nodeData.class_type === className) - .map(([nodeId, nodeData]) => ({ id: nodeId, data: nodeData })); - } - - /** - * Find all image references in the workflow - * Looks for inputs named "image" that contain filename strings - */ - findImageReferences(apiPrompt) { - const images = new Map(); - // Updated regex to handle: - // - Standard files: "image.png" - // - Subfolder files: "subfolder/image.png" - // - ComfyUI special format: "clipspace/file.png [input]" - const imageExtensions = /\.(png|jpg|jpeg|gif|webp|bmp)(\s*\[\w+\])?$/i; - - for (const [nodeId, node] of Object.entries(apiPrompt)) { - if (node.inputs && node.inputs.image) { - const imageValue = node.inputs.image; - // Check if it's a string filename (not an array connection) - if (typeof imageValue === 'string' && imageExtensions.test(imageValue)) { - images.set(imageValue, { - nodeId, - nodeType: node.class_type, - inputName: 'image' - }); - this.debugLog(`Found image reference: ${imageValue} in node ${nodeId} (${node.class_type})`); - } - } - } - - return images; - } - - /** - * Find all nodes connected to collector nodes (both upstream and downstream) - * @param {Object} apiPrompt - The workflow API prompt - * @param {Array} collectorIds - Array of collector node IDs - * @returns {Set} Set of node IDs connected to any collector - */ - findCollectorConnectedNodes(apiPrompt, collectorIds) { - const connected = new Set(collectorIds); // Include all collectors - const toProcess = [...collectorIds]; - - // First, build a map of which nodes output to which nodes - const outputMap = new Map(); // sourceNode -> Set of target nodes - for (const [nodeId, node] of Object.entries(apiPrompt)) { - if (node.inputs) { - for (const [inputName, inputValue] of Object.entries(node.inputs)) { - if (Array.isArray(inputValue) && inputValue.length === 2) { - const sourceNodeId = String(inputValue[0]); - if (!outputMap.has(sourceNodeId)) { - outputMap.set(sourceNodeId, new Set()); - } - outputMap.get(sourceNodeId).add(nodeId); - } - } - } - } - - // Traverse both upstream and downstream - while (toProcess.length > 0) { - const nodeId = toProcess.pop(); - const node = apiPrompt[nodeId]; - - // Traverse upstream (inputs) - if (node && node.inputs) { - for (const [inputName, inputValue] of Object.entries(node.inputs)) { - if (Array.isArray(inputValue) && inputValue.length === 2) { - const sourceNodeId = String(inputValue[0]); - if (!connected.has(sourceNodeId)) { - connected.add(sourceNodeId); - toProcess.push(sourceNodeId); - } - } - } - } - - // Traverse downstream (outputs) - if (outputMap.has(nodeId)) { - for (const targetNodeId of outputMap.get(nodeId)) { - if (!connected.has(targetNodeId)) { - connected.add(targetNodeId); - toProcess.push(targetNodeId); - } - } - } - } - - return connected; - } - - /** - * Find only upstream nodes (inputs) for distributed collector nodes - * This is used for workers to avoid executing downstream nodes like SaveImage - * @param {Object} apiPrompt - The API prompt containing the workflow - * @param {Array} collectorIds - Array of collector node IDs - * @returns {Set} Set of node IDs that feed into collectors - */ - findCollectorUpstreamNodes(apiPrompt, collectorIds) { - const connected = new Set(collectorIds); // Include all collectors - const toProcess = [...collectorIds]; - - // Only traverse upstream (inputs) - while (toProcess.length > 0) { - const nodeId = toProcess.pop(); - const node = apiPrompt[nodeId]; - - // Traverse upstream (inputs) only - if (node && node.inputs) { - for (const [inputName, inputValue] of Object.entries(node.inputs)) { - if (Array.isArray(inputValue) && inputValue.length === 2) { - const sourceNodeId = String(inputValue[0]); - if (!connected.has(sourceNodeId)) { - connected.add(sourceNodeId); - toProcess.push(sourceNodeId); - } - } - } - } - } - - return connected; - } - - /** - * Prune workflow to only include nodes connected to distributed nodes - * @param {Object} apiPrompt - The full workflow API prompt - * @param {Array} distributedNodes - Array of distributed nodes (optional, will find if not provided) - * @returns {Object} Pruned API prompt with only required nodes - */ - pruneWorkflowForWorker(apiPrompt, distributedNodes = null) { - // Find all distributed nodes if not provided - if (!distributedNodes) { - const collectorNodes = this.findNodesByClass(apiPrompt, "DistributedCollector"); - const upscaleNodes = this.findNodesByClass(apiPrompt, "UltimateSDUpscaleDistributed"); - distributedNodes = [...collectorNodes, ...upscaleNodes]; - } - - if (distributedNodes.length === 0) { - // No distributed nodes, return full workflow - return apiPrompt; - } - - // Get all nodes connected to distributed nodes - const distributedIds = distributedNodes.map(node => node.id); - - // For workers, only include upstream nodes (this removes ALL downstream nodes after collectors) - const connectedNodes = this.findCollectorUpstreamNodes(apiPrompt, distributedIds); - - this.debugLog(`Pruning workflow: keeping ${connectedNodes.size} of ${Object.keys(apiPrompt).length} nodes (removed all downstream nodes)`); - - // Create pruned prompt with only required nodes - const prunedPrompt = {}; - for (const nodeId of connectedNodes) { - prunedPrompt[nodeId] = JSON.parse(JSON.stringify(apiPrompt[nodeId])); - } - - // Check if any distributed node has downstream SaveImage nodes that were removed - // If so, add a PreviewImage node after the collector - for (const distNode of distributedNodes) { - const distNodeId = distNode.id; - - // Check if this distributed node had any downstream nodes in the original workflow - const originalOutputMap = new Map(); - for (const [nodeId, node] of Object.entries(apiPrompt)) { - if (node.inputs) { - for (const [inputName, inputValue] of Object.entries(node.inputs)) { - if (Array.isArray(inputValue) && inputValue.length === 2 && String(inputValue[0]) === distNodeId) { - if (!originalOutputMap.has(distNodeId)) { - originalOutputMap.set(distNodeId, []); - } - originalOutputMap.get(distNodeId).push({nodeId, inputName}); - } - } - } - } - - // If this distributed node had downstream nodes that were removed, add a PreviewImage - if (originalOutputMap.has(distNodeId) && originalOutputMap.get(distNodeId).length > 0) { - // Generate a unique ID for the preview node - const previewNodeId = `preview_${distNodeId}`; - - // Add PreviewImage node connected to the distributed node - prunedPrompt[previewNodeId] = { - inputs: { - images: [distNodeId, 0] // Connect to first output of distributed node - }, - class_type: "PreviewImage", - _meta: { - title: "Preview Image (auto-added)" - } - }; - - this.debugLog(`Added PreviewImage node ${previewNodeId} after distributed node ${distNodeId} for worker`); - } - } - - return prunedPrompt; - } - - - _prepareApiPromptForParticipant(baseApiPrompt, participantId, options = {}) { - let jobApiPrompt = JSON.parse(JSON.stringify(baseApiPrompt)); - const isMaster = participantId === 'master'; - - // Find all distributed nodes once (before pruning) - const collectorNodes = this.findNodesByClass(jobApiPrompt, "DistributedCollector"); - const upscaleNodes = this.findNodesByClass(jobApiPrompt, "UltimateSDUpscaleDistributed"); - const allDistributedNodes = [...collectorNodes, ...upscaleNodes]; - - // For workers, prune the workflow to only include distributed node dependencies - if (!isMaster && allDistributedNodes.length > 0) { - jobApiPrompt = this.pruneWorkflowForWorker(jobApiPrompt, allDistributedNodes); - } - - // Handle image references for remote workers - if (!isMaster && options.enabled_worker_ids) { - // Check if this is a remote worker - const workerId = participantId; - const workerInfo = this.config.workers.find(w => w.id === workerId); - const isRemote = workerInfo && workerInfo.host; - - if (isRemote) { - // Find all image references in the pruned workflow - const imageReferences = this.findImageReferences(jobApiPrompt); - if (imageReferences.size > 0) { - this.debugLog(`Found ${imageReferences.size} image references for remote worker ${workerId}`); - // Store image references for later processing - options._imageReferences = imageReferences; - } - } - } - - // Handle Distributed seed nodes - const distributorNodes = this.findNodesByClass(jobApiPrompt, "DistributedSeed"); - if (distributorNodes.length > 0) { - this.debugLog(`Found ${distributorNodes.length} seed node(s)`); - } - - for (const seedNode of distributorNodes) { - const { inputs } = jobApiPrompt[seedNode.id]; - inputs.is_worker = !isMaster; - if (!isMaster) { - const workerIndex = options.enabled_worker_ids.indexOf(participantId); - inputs.worker_id = `worker_${workerIndex}`; - this.debugLog(`Set seed node ${seedNode.id} for worker ${workerIndex}`); - } - } - - // Handle Distributed collector nodes (already found above) - for (const collector of collectorNodes) { - const { inputs } = jobApiPrompt[collector.id]; - - // Check if this collector is downstream from a distributed upscaler - const hasUpstreamDistributedUpscaler = this._hasUpstreamNode( - jobApiPrompt, - collector.id, - 'UltimateSDUpscaleDistributed' - ); - - if (hasUpstreamDistributedUpscaler) { - // Set pass_through mode for this collector - inputs.pass_through = true; - this.debugLog(`Collector ${collector.id} set to pass-through mode (downstream from distributed upscaler)`); - } else { - // Normal collector behavior - // Get the unique job ID from the map created for this execution - const uniqueJobId = options.job_id_map ? options.job_id_map.get(collector.id) : collector.id; - - // Use the truly unique ID for this execution - inputs.multi_job_id = uniqueJobId; - inputs.is_worker = !isMaster; - if (isMaster) { - inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); - } else { - inputs.master_url = this.getMasterUrl(); - // Also make the worker_job_id unique to prevent potential caching issues - inputs.worker_job_id = `${uniqueJobId}_worker_${participantId}`; - inputs.worker_id = participantId; - } - } - } - - // Handle Ultimate SD Upscale Distributed nodes - for (const upscaleNode of upscaleNodes) { - const { inputs } = jobApiPrompt[upscaleNode.id]; - - // Get the unique job ID from the map - const uniqueJobId = options.job_id_map ? options.job_id_map.get(upscaleNode.id) : upscaleNode.id; - - inputs.multi_job_id = uniqueJobId; - inputs.is_worker = !isMaster; - - if (isMaster) { - inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); - } else { - inputs.master_url = this.getMasterUrl(); - inputs.worker_id = participantId; - // Workers also need the enabled_worker_ids to calculate tile distribution - inputs.enabled_worker_ids = JSON.stringify(options.enabled_worker_ids || []); - } - } - - return jobApiPrompt; - } - - async _prepareDistributedJob(multi_job_id) { - try { - await this.api.prepareJob(multi_job_id); - } catch (error) { - this.log("Error preparing job: " + error.message); - throw error; - } - } - - /** - * Check if a node has an upstream node of a specific type - * @param {Object} apiPrompt - The workflow API prompt - * @param {string} nodeId - The node to check - * @param {string} upstreamType - The class_type to look for upstream - * @returns {boolean} True if an upstream node of the specified type exists - */ - _hasUpstreamNode(apiPrompt, nodeId, upstreamType) { - const visited = new Set(); - const toProcess = [nodeId]; - - while (toProcess.length > 0) { - const currentId = toProcess.pop(); - if (visited.has(currentId)) continue; - visited.add(currentId); - - const node = apiPrompt[currentId]; - if (!node) continue; - - // Check inputs for upstream connections - if (node.inputs) { - for (const [inputName, inputValue] of Object.entries(node.inputs)) { - if (Array.isArray(inputValue) && inputValue.length === 2) { - const sourceNodeId = String(inputValue[0]); - const sourceNode = apiPrompt[sourceNodeId]; - - if (sourceNode && sourceNode.class_type === upstreamType) { - return true; - } - - if (!visited.has(sourceNodeId)) { - toProcess.push(sourceNodeId); - } - } - } - } - } - - return false; - } - - startStatusChecking() { - // Start checking every 2 seconds for more responsive updates - // Don't check immediately since panel might not be open yet - this.statusCheckInterval = setInterval(() => { - this.checkAllWorkerStatuses(); - }, 2000); - } - - async checkAllWorkerStatuses() { - // 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); - } - } - } - - async checkMasterStatus() { - try { - const response = await fetch(`${window.location.origin}/prompt`, { - method: 'GET', - signal: AbortSignal.timeout(900) - }); - - if (response.ok) { - const data = await response.json(); - const queueRemaining = data.exec_info?.queue_remaining || 0; - const isProcessing = queueRemaining > 0; - - // Update master status dot - const statusDot = document.getElementById('master-status'); - if (statusDot) { - if (isProcessing) { - statusDot.style.backgroundColor = "#f0ad4e"; - statusDot.title = `Processing (${queueRemaining} in queue)`; - } else { - statusDot.style.backgroundColor = "#4CAF50"; - 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 = "#4CAF50"; - statusDot.title = "Online"; - } - } - } - - // Helper to build worker URL - getWorkerUrl(worker, endpoint = '') { - const host = worker.host || window.location.hostname; - - // Simple rule: port 443 = HTTPS, anything else = HTTP - const useHttps = worker.port === 443; - const protocol = useHttps ? 'https' : 'http'; - - // Don't add port for standard HTTPS/HTTP ports - const defaultPort = useHttps ? 443 : 80; - const port = worker.port === defaultPort ? '' : `:${worker.port}`; - - return `${protocol}://${host}${port}${endpoint}`; - } - - async checkWorkerStatus(worker) { - const url = this.getWorkerUrl(worker, '/prompt'); - const statusDot = document.getElementById(`status-${worker.id}`); - - try { - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - signal: AbortSignal.timeout(900) // Use your increased timeout - }); - - 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.updateStatusDot(worker.id, "#f0ad4e", `Online - Processing (${queueRemaining} in queue)`, false); - } else { - this.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) { - // 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.updateStatusDot(worker.id, "#f0ad4e", "Launching...", true); - } else { - // Only update to red if not currently launching - this.updateStatusDot(worker.id, "#c04c4c", "Offline - Cannot connect", false); - } - - this.debugLog(`Worker ${worker.id} status check failed: ${error.message}`); - } - - // Update control buttons based on new status - this.updateWorkerControls(worker.id); - } - - async _dispatchToWorker(worker, prompt, workflow, imageReferences) { - const workerUrl = this.getWorkerUrl(worker); - - // Debug logging - always log to console for debugging - console.log(`[Distributed] === Dispatching to ${worker.name} (${worker.id}) ===`); - console.log('[Distributed] Worker URL:', workerUrl); - - // Handle image uploads for remote workers - if (imageReferences && imageReferences.size > 0) { - if (this.debugMode) { - console.log(`[Distributed] Processing ${imageReferences.size} image(s) for remote worker`); - } - - try { - // Load images from master - const images = await this.loadImagesForWorker(imageReferences); - - // Upload images to worker - if (images.length > 0) { - await this.uploadImagesToWorker(workerUrl, images); - if (this.debugMode) { - console.log(`[Distributed] Successfully uploaded ${images.length} image(s) to worker`); - } - } - } catch (error) { - this.log(`Failed to process images for worker ${worker.name}: ${error.message}`); - // Continue with workflow execution even if image upload fails - } - } - - const promptToSend = { - prompt, - extra_data: { extra_pnginfo: { workflow } }, - client_id: api.clientId - }; - - console.log('[Distributed] Prompt data:', promptToSend); - - try { - await fetch(`${workerUrl}/prompt`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - mode: 'cors', - body: JSON.stringify(promptToSend) - }); - } catch (e) { - this.log(`Failed to connect to worker ${worker.name} at ${workerUrl}: ${e.message}`); - } - } - - async loadImagesForWorker(imageReferences) { - const images = []; - - // Use a cache to avoid loading the same image multiple times - if (!this._imageCache) { - this._imageCache = new Map(); - } - - for (const [filename, info] of imageReferences) { - try { - // Check cache first - if (this._imageCache.has(filename)) { - images.push(this._imageCache.get(filename)); - this.debugLog(`Using cached image: ${filename}`); - continue; - } - - // Load image from master's filesystem via API - try { - const data = await this.api.loadImage(filename); - const imageData = { - name: filename, - image: data.image_data - }; - images.push(imageData); - - // Cache the image for future use - this._imageCache.set(filename, imageData); - this.debugLog(`Loaded and cached image: ${filename}`); - } catch (loadError) { - this.log(`Failed to load image ${filename}: ${loadError.message}`); - throw loadError; - } - } catch (error) { - this.log(`Error loading image ${filename}: ${error.message}`); - } - } - - // Clear cache after a reasonable time to avoid memory issues - setTimeout(() => { - if (this._imageCache && this._imageCache.size > 0) { - this.debugLog(`Clearing image cache (${this._imageCache.size} images)`); - this._imageCache.clear(); - } - }, 30000); // Clear after 30 seconds - - return images; - } - - async uploadImagesToWorker(workerUrl, images) { - // Upload images to worker's ComfyUI instance - for (const imageData of images) { - const formData = new FormData(); - - // Convert base64 to blob - const base64Data = imageData.image.replace(/^data:image\/\w+;base64,/, ''); - const byteCharacters = atob(base64Data); - const byteNumbers = new Array(byteCharacters.length); - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i); - } - const byteArray = new Uint8Array(byteNumbers); - const blob = new Blob([byteArray], { type: 'image/png' }); - - // Clean the filename - remove [input] suffix and handle subfolder - let cleanName = imageData.name; - let subfolder = ''; - - // Remove [input] or other suffixes - cleanName = cleanName.replace(/\s*\[\w+\]$/, ''); - - // Extract subfolder if present - if (cleanName.includes('/')) { - const parts = cleanName.split('/'); - subfolder = parts.slice(0, -1).join('/'); - cleanName = parts[parts.length - 1]; - } - - formData.append('image', blob, cleanName); - formData.append('type', 'input'); - formData.append('subfolder', subfolder); - formData.append('overwrite', 'true'); - - try { - const response = await fetch(`${workerUrl}/upload/image`, { - method: 'POST', - mode: 'cors', - body: formData - }); - - if (!response.ok) { - throw new Error(`Upload failed: ${response.statusText}`); - } - - this.debugLog(`Uploaded image to worker: ${imageData.name} -> ${subfolder}/${cleanName}`); - } catch (error) { - throw new Error(`Failed to upload ${imageData.name}: ${error.message}`); - } - } - } - - async performPreflightCheck(workers) { - if (workers.length === 0) return []; - - this.debugLog(`Performing pre-flight health check on ${workers.length} workers...`); - const startTime = Date.now(); - - const checkPromises = workers.map(async (worker) => { - const url = this.getWorkerUrl(worker, '/prompt'); - - this.debugLog(`Pre-flight checking ${worker.name} at: ${url}`); - - try { - const response = await fetch(url, { - method: 'GET', - mode: 'cors', - signal: AbortSignal.timeout(900) // Use your increased timeout - }); - - if (response.ok) { - this.debugLog(`Worker ${worker.name} is active`); - return { worker, active: true }; - } else { - this.debugLog(`Worker ${worker.name} returned ${response.status}`); - return { worker, active: false }; - } - } catch (error) { - this.debugLog(`Worker ${worker.name} is offline or unreachable: ${error.message}`); - return { worker, active: false }; - } - }); - - const results = await Promise.all(checkPromises); - const activeWorkers = results.filter(r => r.active).map(r => r.worker); - - const elapsed = Date.now() - startTime; - this.debugLog(`Pre-flight check completed in ${elapsed}ms. Active workers: ${activeWorkers.length}/${workers.length}`); - - // Update UI status indicators for inactive workers - results.filter(r => !r.active).forEach(r => { - const statusDot = document.getElementById(`status-${r.worker.id}`); - if (statusDot) { - // Remove pulsing animation once status is determined - statusDot.classList.remove('status-pulsing'); - statusDot.style.backgroundColor = "#c04c4c"; // Red for offline - statusDot.title = "Offline - Cannot connect"; - } - }); - - return activeWorkers; - } - async launchWorker(workerId) { - const worker = this.config.workers.find(w => w.id === workerId); - const launchBtn = document.querySelector(`#controls-${workerId} button`); - - this.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); - }, 90000); - - 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})`); - if (result.log_file) { - this.debugLog(`Log file: ${result.log_file}`); - } - - 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), 2000); - } - } catch (error) { - // Check if worker was already running - if (error.message && error.message.includes("already running")) { - this.log(`Worker ${worker.name} is already running`); - this.updateWorkerControls(workerId); - setTimeout(() => this.checkWorkerStatus(worker), 100); - } else { - this.log(`Error launching worker: ${error.message || error}`); - - // Re-enable button on error - if (launchBtn) { - launchBtn.disabled = false; - } - } - } - } - - 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}`); - this.state.setWorkerManaged(workerId, null); - - // Flash success feedback - if (stopBtn) { - stopBtn.style.backgroundColor = DistributedExtension.BUTTON_STYLES.success; - stopBtn.textContent = "Stopped!"; - setTimeout(() => { - this.updateWorkerControls(workerId); - }, 1500); - } - - // Update status - setTimeout(() => this.checkWorkerStatus(worker), 500); - } else { - this.log(`Failed to stop worker: ${result.message}`); - - // Flash error feedback - if (stopBtn) { - stopBtn.style.backgroundColor = DistributedExtension.BUTTON_STYLES.error; - stopBtn.textContent = result.message.includes("already stopped") ? "Not Running" : "Failed"; - setTimeout(() => { - this.updateWorkerControls(workerId); - }, 2000); - } - } - } catch (error) { - this.log(`Error stopping worker: ${error}`); - - // Flash error feedback - if (stopBtn) { - stopBtn.style.backgroundColor = DistributedExtension.BUTTON_STYLES.error; - stopBtn.textContent = "Error"; - setTimeout(() => { - this.updateWorkerControls(workerId); - }, 2000); - } - } - } - - async clearLaunchingFlag(workerId) { - try { - await this.api.clearLaunchingFlag(workerId); - this.debugLog(`Cleared launching flag for worker ${workerId}`); - } catch (error) { - this.debugLog(`Error clearing launching flag: ${error.message || error}`); - } - } - - // Helper Functions to reduce redundancy - - /** - * Generic async button action handler - */ - async handleAsyncButtonAction(button, action, successText, errorText, resetDelay = 3000) { - const originalText = button.textContent; - const originalStyle = button.style.cssText; - button.disabled = true; - - try { - await action(); - button.textContent = successText; - button.style.cssText = originalStyle; - button.style.backgroundColor = DistributedExtension.BUTTON_STYLES.success; - return true; - } catch (error) { - button.textContent = errorText || `Error: ${error.message}`; - button.style.cssText = originalStyle; - button.style.backgroundColor = DistributedExtension.BUTTON_STYLES.error; - throw error; - } finally { - setTimeout(() => { - button.textContent = originalText; - button.style.cssText = originalStyle; - button.disabled = false; - }, resetDelay); - } - } - - /** - * Show toast notification helper - */ - showToast(severity, summary, detail, life = 3000) { - if (app.extensionManager?.toast?.add) { - app.extensionManager.toast.add({ severity, summary, detail, life }); - } - } - - /** - * Update status dot helper - */ - updateStatusDot(workerId, color, title, pulsing = false) { - const statusDot = document.getElementById(`status-${workerId}`); - if (!statusDot) return; - - statusDot.style.backgroundColor = color; - statusDot.title = title; - statusDot.classList.toggle('status-pulsing', pulsing); - } - - /** - * Cleanup method to stop intervals and listeners - */ - cleanup() { - if (this.statusCheckInterval) { - clearInterval(this.statusCheckInterval); - this.statusCheckInterval = null; - } - - if (this.logAutoRefreshInterval) { - clearInterval(this.logAutoRefreshInterval); - this.logAutoRefreshInterval = null; - } - - this.debugLog("Cleaned up intervals"); - } - - async loadManagedWorkers() { - try { - const result = await this.api.getManagedWorkers(); - // Clear all managed worker info - this.state.clearCache(); - - // 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.debugLog(`Worker ${workerId} is in launching state`); - } - } - - // Update UI for all workers - if (this.config?.workers) { - this.config.workers.forEach(w => this.updateWorkerControls(w.id)); - } - } catch (error) { - this.debugLog(`Error loading managed workers: ${error}`); - } - } - - 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 - const buttons = controlsDiv.querySelectorAll('button'); - const launchBtn = buttons[0]; - const stopBtn = buttons[1]; - 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 = 'inline-block'; - } 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 = 'inline-block'; - 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 = 'inline-block'; // Show launch button - launchBtn.disabled = false; - launchBtn.textContent = "Launch"; - launchBtn.style.backgroundColor = "#4a7c4a"; // Green when enabled - - stopBtn.style.display = 'none'; // Hide stop button when not running - } - } - - 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.showLogModal(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); - 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 = DistributedExtension.BUTTON_STYLES.error; - logBtn.textContent = "Error"; - setTimeout(() => { - logBtn.disabled = false; - logBtn.textContent = "View Log"; - logBtn.style.backgroundColor = "#685434"; // Keep the yellow color - }, 2000); - } - } - } - - showLogModal(workerId, logData) { - // Remove any existing modal - const existingModal = document.getElementById('distributed-log-modal'); - if (existingModal) { - existingModal.remove(); - } - - const worker = this.config.workers.find(w => w.id === workerId); - const workerName = worker?.name || `Worker ${workerId}`; - - // Create modal container - const modal = document.createElement('div'); - modal.id = 'distributed-log-modal'; - modal.style.cssText = ` - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: rgba(0, 0, 0, 0.8); - display: flex; - align-items: center; - justify-content: center; - z-index: 10000; - `; - - // Create modal content - const content = document.createElement('div'); - content.style.cssText = ` - background: #1e1e1e; - border-radius: 8px; - width: 90%; - max-width: 1200px; - height: 80%; - display: flex; - flex-direction: column; - box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); - `; - - // Header - const header = document.createElement('div'); - header.style.cssText = ` - padding: 15px 20px; - border-bottom: 1px solid #444; - display: flex; - justify-content: space-between; - align-items: center; - `; - - const title = document.createElement('h3'); - title.textContent = `${workerName} - Log Viewer`; - title.style.cssText = 'margin: 0; color: #fff;'; - - const headerButtons = document.createElement('div'); - headerButtons.style.cssText = 'display: flex; gap: 20px; align-items: center;'; - - // Auto-refresh container - const refreshContainer = document.createElement('div'); - refreshContainer.style.cssText = 'display: flex; align-items: center; gap: 4px;'; - - // Auto-refresh checkbox - const refreshCheckbox = document.createElement('input'); - refreshCheckbox.type = 'checkbox'; - refreshCheckbox.id = 'log-auto-refresh'; - refreshCheckbox.checked = true; // Enabled by default - refreshCheckbox.style.cssText = 'cursor: pointer;'; - refreshCheckbox.onchange = (e) => { - if (e.target.checked) { - this.startLogAutoRefresh(workerId); - } else { - this.stopLogAutoRefresh(); - } - }; - - const refreshLabel = document.createElement('label'); - refreshLabel.htmlFor = 'log-auto-refresh'; - refreshLabel.style.cssText = 'font-size: 12px; color: #ccc; cursor: pointer; white-space: nowrap;'; - refreshLabel.textContent = 'Auto-refresh'; - - // Add checkbox and label to container - refreshContainer.appendChild(refreshCheckbox); - refreshContainer.appendChild(refreshLabel); - - // Close button - const closeBtn = this._createButton('✕', - () => { - this.stopLogAutoRefresh(); - modal.remove(); - }, - 'background-color: #c04c4c;'); - closeBtn.style.cssText += ' padding: 5px 10px; font-size: 14px; font-weight: bold;'; - - headerButtons.appendChild(refreshContainer); - headerButtons.appendChild(closeBtn); - - header.appendChild(title); - header.appendChild(headerButtons); - - // Log content area - const logContainer = document.createElement('div'); - logContainer.style.cssText = ` - flex: 1; - overflow: auto; - padding: 15px; - font-family: 'Consolas', 'Monaco', 'Courier New', monospace; - font-size: 12px; - line-height: 1.4; - color: #ddd; - background: #0d0d0d; - white-space: pre-wrap; - word-wrap: break-word; - `; - logContainer.id = 'distributed-log-content'; - logContainer.textContent = logData.content; - - // Auto-scroll to bottom - setTimeout(() => { - logContainer.scrollTop = logContainer.scrollHeight; - }, 0); - - // Status bar - const statusBar = document.createElement('div'); - statusBar.style.cssText = ` - padding: 10px 20px; - border-top: 1px solid #444; - font-size: 11px; - color: #888; - `; - statusBar.textContent = `Log file: ${logData.log_file}`; - if (logData.truncated) { - statusBar.textContent += ` (showing last ${logData.lines_shown} lines of ${this.formatFileSize(logData.file_size)})`; - } - - // Assemble modal - content.appendChild(header); - content.appendChild(logContainer); - content.appendChild(statusBar); - modal.appendChild(content); - - // Close on background click - modal.onclick = (e) => { - if (e.target === modal) { - this.stopLogAutoRefresh(); - modal.remove(); - } - }; - - // Close on Escape key - const handleEscape = (e) => { - if (e.key === 'Escape') { - this.stopLogAutoRefresh(); - modal.remove(); - document.removeEventListener('keydown', handleEscape); - } - }; - document.addEventListener('keydown', handleEscape); - - document.body.appendChild(modal); - - // Start auto-refresh - this.startLogAutoRefresh(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 - }); - } - } - } - - formatFileSize(bytes) { - if (bytes < 1024) return bytes + ' B'; - if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; - return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; - } - - isRemoteWorker(worker) { - const host = worker.host || window.location.hostname; - return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname; - } - - getMasterUrl() { - // Always use the detected/configured master IP for consistency - if (this.config?.master?.host) { - const configuredHost = this.config.master.host; - - // If the configured host already includes protocol, use as-is - if (configuredHost.startsWith('http://') || configuredHost.startsWith('https://')) { - return configuredHost; - } - - // For domain names (not IPs), default to HTTPS - const isIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(configuredHost); - const isLocalhost = configuredHost === 'localhost' || configuredHost === '127.0.0.1'; - - if (!isIP && !isLocalhost && configuredHost.includes('.')) { - // It's a domain name, use HTTPS - return `https://${configuredHost}`; - } else { - // For IPs and localhost, use current access method - const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); - if ((window.location.protocol === 'https:' && port === '443') || - (window.location.protocol === 'http:' && port === '80')) { - return `${window.location.protocol}//${configuredHost}`; - } - return `${window.location.protocol}//${configuredHost}:${port}`; - } - } - - // If no master IP is set but we're on a network address, use it - const hostname = window.location.hostname; - if (hostname !== 'localhost' && hostname !== '127.0.0.1') { - return window.location.origin; - } - - // Fallback warning - this won't work for remote workers - this.debugLog("No master host configured - remote workers won't be able to connect. " + - "Master host should be auto-detected on startup."); - return window.location.origin; - } - - async detectMasterIP() { - try { - const data = await this.api.getNetworkInfo(); - this.log("Network info: " + JSON.stringify(data)); - - // Store CUDA device info - if (data.cuda_device !== null && data.cuda_device !== undefined) { - this.masterCudaDevice = data.cuda_device; - // Update the master display with CUDA info - this.updateMasterDisplay(); - } - - // Store CUDA device count for auto-population - if (data.cuda_device_count > 0) { - this.cudaDeviceCount = data.cuda_device_count; - this.log(`Detected ${this.cudaDeviceCount} CUDA devices`); - - // Auto-populate workers if none exist - this.log(`Current workers: ${this.config.workers ? this.config.workers.length : 'null'}`); - if (!this.config.workers || this.config.workers.length === 0) { - this.log(`Auto-populating ${this.cudaDeviceCount} workers based on CUDA devices`); - - const newWorkers = []; - for (let i = 0; i < this.cudaDeviceCount; i++) { - const worker = { - id: Date.now() + i, - name: `Worker ${i + 1}`, - host: "localhost", - port: 8189 + i, - cuda_device: i, - enabled: true - }; - newWorkers.push(worker); - } - - // Update config and save - this.config.workers = newWorkers; - - // Refresh the panel if it's already open - const panel = document.getElementById("distributed-panel"); - if (panel) { - this.renderSidebarContent(panel); - } - } - } - - // Check if we already have a master host configured - if (this.config?.master?.host) { - this.debugLog(`Master host already configured: ${this.config.master.host}`); - return; - } - - // Use the recommended IP from the backend - if (data.recommended_ip && data.recommended_ip !== '127.0.0.1') { - this.log(`Auto-detected master IP: ${data.recommended_ip}`); - - // Save the detected IP (pass true to suppress notification) - await this.saveMasterConfig({ host: data.recommended_ip }, true); - - // Show a single combined notification - if (app.extensionManager?.toast) { - app.extensionManager.toast.add({ - severity: "info", - summary: "Master IP Auto-Detected", - detail: `Master IP set to ${data.recommended_ip} for remote worker communication`, - life: 5000 - }); - } - } - } catch (error) { - this.debugLog("Error detecting master IP: " + error.message); - } - } - - async saveMasterConfig(updates, suppressNotification = false) { - try { - const response = await fetch(`${window.location.origin}/distributed/config/update_master`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(updates) - }); - - if (response.ok) { - // Update local config - if (!this.config.master) { - this.config.master = {}; - } - // Update all provided fields - Object.assign(this.config.master, updates); - - // Only show notification if not suppressed - if (!suppressNotification) { - app.extensionManager.toast.add({ - severity: "success", - summary: "Master Host Saved", - detail: masterIp.trim() ? `Master host set to ${masterIp.trim()}` : "Master host cleared (auto-detect)", - life: 3000 - }); - } - } else { - throw new Error("Failed to save master host"); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Save Failed", - detail: error.message, - life: 5000 - }); - } - } - - startLogAutoRefresh(workerId) { - // Stop any existing auto-refresh - this.stopLogAutoRefresh(); - - // Refresh every 2 seconds - this.logAutoRefreshInterval = setInterval(() => { - this.refreshLog(workerId, true); // silent mode - }, 2000); - } - - stopLogAutoRefresh() { - if (this.logAutoRefreshInterval) { - clearInterval(this.logAutoRefreshInterval); - this.logAutoRefreshInterval = null; - } - } - - 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"; - } - } - - createWorkerSettingsForm(worker) { - const form = document.createElement("div"); - form.style.cssText = "display: flex; flex-direction: column; gap: 10px;"; - - // Name field - const nameGroup = this.createFormGroup("Name:", worker.name, `name-${worker.id}`); - form.appendChild(nameGroup); - - // Remote checkbox - const remoteGroup = document.createElement("div"); - remoteGroup.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;"; - - const remoteCheckbox = document.createElement("input"); - remoteCheckbox.type = "checkbox"; - remoteCheckbox.id = `remote-${worker.id}`; - remoteCheckbox.checked = this.isRemoteWorker(worker); - remoteCheckbox.onchange = (e) => { - const isRemote = e.target.checked; - // Show/hide relevant fields - const hostGroup = document.getElementById(`host-group-${worker.id}`); - const cudaGroup = document.getElementById(`cuda-group-${worker.id}`); - const argsGroup = document.getElementById(`args-group-${worker.id}`); - - if (isRemote) { - hostGroup.style.display = "flex"; - cudaGroup.style.display = "none"; - argsGroup.style.display = "none"; - // If switching to remote and host is localhost, clear it - const hostInput = document.getElementById(`host-${worker.id}`); - if (hostInput.value === "localhost" || hostInput.value === "127.0.0.1") { - hostInput.value = ""; - } - } else { - hostGroup.style.display = "none"; - cudaGroup.style.display = "flex"; - argsGroup.style.display = "flex"; - } - }; - - const remoteLabel = document.createElement("label"); - remoteLabel.htmlFor = `remote-${worker.id}`; - remoteLabel.textContent = "Remote Worker"; - remoteLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; - - remoteGroup.appendChild(remoteCheckbox); - remoteGroup.appendChild(remoteLabel); - form.appendChild(remoteGroup); - - // Host field (only for remote workers) - const hostGroup = this.createFormGroup("Host:", worker.host || "", `host-${worker.id}`, "text", "e.g., 192.168.1.100"); - hostGroup.id = `host-group-${worker.id}`; - hostGroup.style.display = this.isRemoteWorker(worker) ? "flex" : "none"; - form.appendChild(hostGroup); - - // Port field - const portGroup = this.createFormGroup("Port:", worker.port, `port-${worker.id}`, "number"); - form.appendChild(portGroup); - - // CUDA Device field (only for local workers) - const cudaGroup = this.createFormGroup("CUDA Device:", worker.cuda_device || 0, `cuda-${worker.id}`, "number"); - cudaGroup.id = `cuda-group-${worker.id}`; - cudaGroup.style.display = this.isRemoteWorker(worker) ? "none" : "flex"; - form.appendChild(cudaGroup); - - // Extra Args field (only for local workers) - const argsGroup = this.createFormGroup("Extra Args:", worker.extra_args || "", `args-${worker.id}`); - argsGroup.id = `args-group-${worker.id}`; - argsGroup.style.display = this.isRemoteWorker(worker) ? "none" : "flex"; - form.appendChild(argsGroup); - - // Buttons - const buttonGroup = document.createElement("div"); - buttonGroup.style.cssText = "display: flex; gap: 8px; margin-top: 8px;"; - - const saveBtn = this._createButton("Save", - () => this.saveWorkerSettings(worker.id), - "background-color: #4a7c4a;"); - saveBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; - - const cancelBtn = this._createButton("Cancel", - () => this.cancelWorkerSettings(worker.id), - "background-color: #555;"); - cancelBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; - - const deleteBtn = this._createButton("Delete", - () => this.deleteWorker(worker.id), - "background-color: #7c4a4a;"); - deleteBtn.style.cssText += " padding: 6px 12px; font-size: 12px; margin-left: auto;"; - - buttonGroup.appendChild(saveBtn); - buttonGroup.appendChild(cancelBtn); - buttonGroup.appendChild(deleteBtn); - - form.appendChild(buttonGroup); - - return form; - } - - createSettingsToggle() { - const settingsRow = document.createElement("div"); - settingsRow.style.cssText = "display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; user-select: none;"; - - const settingsTitle = document.createElement("h4"); - settingsTitle.textContent = "Settings"; - settingsTitle.style.cssText = "margin: 0; font-size: 14px;"; - - const settingsToggle = document.createElement("span"); - settingsToggle.textContent = "▶"; // Right arrow when collapsed - settingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; - - settingsRow.appendChild(settingsToggle); - settingsRow.appendChild(settingsTitle); - - return { settingsRow, settingsToggle }; - } - - createFormGroup(label, value, id, type = "text", placeholder = "") { - const result = this.ui.createFormGroup(label, value, id, type, placeholder); - return result.group; - } - - 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 isRemote = document.getElementById(`remote-${workerId}`).checked; - const host = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname; - const 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; - - // Validate - if (!name.trim()) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Validation Error", - detail: "Worker name is required", - life: 3000 - }); - return; - } - - if (isRemote && !host.trim()) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Validation Error", - detail: "Host is required for remote workers", - life: 3000 - }); - return; - } - - if (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 { - const response = await fetch(`${window.location.origin}/distributed/config/update_worker`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - worker_id: workerId, - name: name.trim(), - host: isRemote ? host.trim() : null, - port: port, - cuda_device: isRemote ? null : cudaDevice, - extra_args: isRemote ? null : (extraArgs ? extraArgs.trim() : "") - }) - }); - - if (response.ok) { - // Update local config - worker.name = name.trim(); - 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; - - app.extensionManager.toast.add({ - severity: "success", - summary: "Settings Saved", - detail: `Worker ${name} settings updated`, - life: 3000 - }); - - // Refresh the UI - if (this.panelElement) { - this.renderSidebarContent(this.panelElement); - } - } else { - const error = await response.json(); - throw new Error(error.message || "Failed to save settings"); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Save Failed", - detail: error.message, - life: 5000 - }); - } - } - - 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); - } - } - } - - 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 { - const response = await fetch(`${window.location.origin}/distributed/config/delete_worker`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ worker_id: workerId }) - }); - - if (response.ok) { - // 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) { - this.renderSidebarContent(this.panelElement); - } - } else { - const error = await response.json(); - throw new Error(error.message || "Failed to delete worker"); - } - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Delete Failed", - detail: error.message, - life: 5000 - }); - } - } - - // Backward compatibility wrapper - async saveMasterIp(masterIp, suppressNotification = false) { - return this.saveMasterConfig({ host: masterIp.trim() }, suppressNotification); - } - - async addNewWorker() { - // Generate new worker ID - const newId = `worker_${Date.now()}`; - - // 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 { - const response = await fetch(`${window.location.origin}/distributed/config/update_worker`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - worker_id: newId, - name: newWorker.name, - port: newWorker.port, - cuda_device: newWorker.cuda_device, - extra_args: newWorker.extra_args, - enabled: newWorker.enabled - }) - }); - - if (!response.ok) { - // Remove from local config if save failed - this.config.workers.pop(); - throw new Error("Failed to save new worker"); - } - - 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) { - this.renderSidebarContent(this.panelElement); - } - - } catch (error) { - app.extensionManager.toast.add({ - severity: "error", - summary: "Failed to Add Worker", - detail: error.message, - life: 5000 - }); - } - } -} - -app.registerExtension({ - name: "Distributed.Panel", - async setup() { - new DistributedExtension(); - } -}); \ No newline at end of file diff --git a/web/main.js b/web/main.js new file mode 100644 index 0000000..c0310c5 --- /dev/null +++ b/web/main.js @@ -0,0 +1,1178 @@ +import { app } from "../../scripts/app.js"; +import { api } from "../../scripts/api.js"; +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 { setupInterceptor, executeParallelDistributed } from './executionUtils.js'; +import { BUTTON_STYLES, PULSE_ANIMATION_CSS, TIMEOUTS, STATUS_COLORS } from './constants.js'; + +class DistributedExtension { + constructor() { + this.config = null; + this.originalQueuePrompt = api.queuePrompt.bind(api); + this.statusCheckInterval = null; + this.logAutoRefreshInterval = null; + this.masterSettingsExpanded = false; + + // Initialize centralized state + this.state = createStateManager(); + + // Initialize UI component factory + this.ui = new DistributedUI(); + + // Initialize API client + this.api = createApiClient(window.location.origin); + + // Inject CSS for pulsing animation + this.injectStyles(); + + this.loadConfig().then(async () => { + this.registerSidebarTab(); + this.setupInterceptor(); + this.startStatusChecking(); + this.loadManagedWorkers(); + // Detect master IP after everything is set up + this.detectMasterIP(); + }); + } + + // Debug logging helpers + log(message, level = "info") { + if (level === "debug" && !this.config?.settings?.debug) return; + if (level === "error") { + console.error(`[Distributed] ${message}`); + } else { + console.log(`[Distributed] ${message}`); + } + } + + injectStyles() { + const styleId = 'distributed-styles'; + if (!document.getElementById(styleId)) { + const style = document.createElement('style'); + style.id = styleId; + style.textContent = PULSE_ANIMATION_CSS; + document.head.appendChild(style); + } + } + + // --- State & Config Management (Single Source of Truth) --- + + get enabledWorkers() { + return this.config?.workers?.filter(w => w.enabled) || []; + } + + get isEnabled() { + return this.enabledWorkers.length > 0; + } + + async loadConfig() { + try { + this.config = await this.api.getConfig(); + this.log("Loaded config: " + JSON.stringify(this.config), "debug"); + + // Ensure default flag values + if (!this.config.settings) { + this.config.settings = {}; + } + if (this.config.settings.has_auto_populated_workers === undefined) { + this.config.settings.has_auto_populated_workers = false; + } + + // Load stored master CUDA device + this.masterCudaDevice = this.config?.master?.cuda_device ?? undefined; + + // Sync to state + if (this.config.workers) { + this.config.workers.forEach(w => { + this.state.updateWorker(w.id, { enabled: w.enabled }); + }); + } + } catch (error) { + this.log("Failed to load config: " + error.message, "error"); + this.config = { workers: [], settings: { has_auto_populated_workers: false } }; + } + } + + async updateWorkerEnabled(workerId, enabled) { + const worker = this.config.workers.find(w => w.id === workerId); + if (worker) { + worker.enabled = enabled; + this.state.updateWorker(workerId, { enabled }); + + // Immediately update status dot based on enabled state + const statusDot = document.getElementById(`status-${workerId}`); + if (statusDot) { + if (enabled) { + // Enabled: Start with checking state and trigger check + this.ui.updateStatusDot(workerId, STATUS_COLORS.OFFLINE_RED, "Checking status...", true); + setTimeout(() => this.checkWorkerStatus(worker), 100); + } else { + // Disabled: Set to gray + this.ui.updateStatusDot(workerId, STATUS_COLORS.DISABLED_GRAY, "Disabled", false); + } + } + } + + try { + await this.api.updateWorker(workerId, { enabled }); + } catch (error) { + this.log("Error updating worker: " + error.message, "error"); + } + } + + async _updateSetting(key, value) { + // Update local config + if (!this.config.settings) { + this.config.settings = {}; + } + this.config.settings[key] = value; + + try { + await this.api.updateSetting(key, value); + + app.extensionManager.toast.add({ + severity: "success", + summary: "Setting Updated", + detail: `${key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase())} ${value ? 'enabled' : 'disabled'}`, + life: 2000 + }); + } catch (error) { + this.log(`Error updating setting '${key}': ${error.message}`, "error"); + app.extensionManager.toast.add({ + severity: "error", + summary: "Setting Update Failed", + detail: error.message, + life: 3000 + }); + } + } + + // --- UI Rendering --- + + registerSidebarTab() { + app.extensionManager.registerSidebarTab({ + id: "distributed", + icon: "pi pi-server", + title: "Distributed", + tooltip: "Distributed Control Panel", + type: "custom", + render: (el) => renderSidebarContent(this, el) + }); + } + + updateSummary() { + const summaryEl = document.getElementById('distributed-summary'); + if (summaryEl) { + // Count active workers from state + const activeWorkers = this.config.workers.filter(w => { + const status = this.state.getWorkerStatus(w.id); + return status.online; + }).length; + const totalGPUs = activeWorkers + 1; + if (this.isEnabled) { + summaryEl.textContent = `If Collector node is present, total generation = (${totalGPUs} GPUs × Batch Size)`; + } else { + summaryEl.textContent = "Only the master GPU will be used."; + } + } + } + + // --- Core Logic & Execution --- + + setupInterceptor() { + setupInterceptor(this); + } + + async executeParallelDistributed(promptWrapper) { + return executeParallelDistributed(this, promptWrapper); + } + + startStatusChecking() { + // Start checking every 2 seconds for more responsive updates + // Don't check immediately since panel might not be open yet + this.statusCheckInterval = setInterval(() => { + this.checkAllWorkerStatuses(); + }, 2000); + } + + async checkAllWorkerStatuses() { + // 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); + } + } + } + + 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 (isProcessing) { + statusDot.style.backgroundColor = "#f0ad4e"; + statusDot.title = `Processing (${queueRemaining} in queue)`; + } else { + statusDot.style.backgroundColor = "#4CAF50"; + 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 = "#4CAF50"; + statusDot.title = "Online"; + } + } + } + + // Helper to build worker URL + getWorkerUrl(worker, endpoint = '') { + const host = worker.host || window.location.hostname; + + // Simple rule: port 443 = HTTPS, anything else = HTTP + const useHttps = worker.port === 443; + const protocol = useHttps ? 'https' : 'http'; + + // Don't add port for standard HTTPS/HTTP ports + const defaultPort = useHttps ? 443 : 80; + const port = worker.port === defaultPort ? '' : `:${worker.port}`; + + return `${protocol}://${host}${port}${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 { + const response = await fetch(url, { + method: 'GET', + mode: 'cors', + 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 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) { + // 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); + } + + async launchWorker(workerId) { + const worker = this.config.workers.find(w => w.id === workerId); + const launchBtn = document.querySelector(`#controls-${workerId} button`); + + // 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; + } + + this.updateSummary(); + } + + 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); + }, 90000); + + 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), 2000); + } + } 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), 100); + } else { + this.log(`Error launching worker: ${error.message || error}`, "error"); + + // Re-enable button on error + if (launchBtn) { + launchBtn.disabled = false; + } + } + } + } + + 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); + }, 1000); + } + + // Verify status after a short delay + setTimeout(() => this.checkWorkerStatus(worker), 2000); + } 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); + }, 1500); + } + } + } 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); + }, 1500); + } + } + } + + 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"); + } + } + + // Generic async button action handler + async handleAsyncButtonAction(button, action, successText, errorText, resetDelay = 3000) { + const originalText = button.textContent; + const originalStyle = button.style.cssText; + button.disabled = true; + + try { + await action(); + button.textContent = successText; + button.style.cssText = originalStyle; + button.style.backgroundColor = BUTTON_STYLES.success; + return true; + } catch (error) { + button.textContent = errorText || `Error: ${error.message}`; + button.style.cssText = originalStyle; + button.style.backgroundColor = BUTTON_STYLES.error; + throw error; + } finally { + setTimeout(() => { + button.textContent = originalText; + button.style.cssText = originalStyle; + button.disabled = false; + }, resetDelay); + } + } + + /** + * Cleanup method to stop intervals and listeners + */ + cleanup() { + if (this.statusCheckInterval) { + clearInterval(this.statusCheckInterval); + this.statusCheckInterval = null; + } + + if (this.logAutoRefreshInterval) { + clearInterval(this.logAutoRefreshInterval); + this.logAutoRefreshInterval = null; + } + + this.log("Cleaned up intervals", "debug"); + } + + 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"); + } + } + + 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 + } + } + + 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 + }, 2000); + } + } + } + + 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 + }); + } + } + } + + isRemoteWorker(worker) { + const host = worker.host || window.location.hostname; + return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname; + } + + getMasterUrl() { + // Always use the detected/configured master IP for consistency + if (this.config?.master?.host) { + const configuredHost = this.config.master.host; + + // If the configured host already includes protocol, use as-is + if (configuredHost.startsWith('http://') || configuredHost.startsWith('https://')) { + return configuredHost; + } + + // For domain names (not IPs), default to HTTPS + const isIP = /^(\d{1,3}\.){3}\d{1,3}$/.test(configuredHost); + const isLocalhost = configuredHost === 'localhost' || configuredHost === '127.0.0.1'; + + if (!isIP && !isLocalhost && configuredHost.includes('.')) { + // It's a domain name, use HTTPS + return `https://${configuredHost}`; + } else { + // For IPs and localhost, use current access method + const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); + if ((window.location.protocol === 'https:' && port === '443') || + (window.location.protocol === 'http:' && port === '80')) { + return `${window.location.protocol}//${configuredHost}`; + } + return `${window.location.protocol}//${configuredHost}:${port}`; + } + } + + // If no master IP is set but we're on a network address, use it + const hostname = window.location.hostname; + if (hostname !== 'localhost' && hostname !== '127.0.0.1') { + return window.location.origin; + } + + // Fallback warning - this won't work for remote workers + this.log("No master host configured - remote workers won't be able to connect. " + + "Master host should be auto-detected on startup.", "debug"); + return window.location.origin; + } + + async detectMasterIP() { + try { + const data = await this.api.getNetworkInfo(); + this.log("Network info: " + JSON.stringify(data), "debug"); + + // Store CUDA device info + if (data.cuda_device !== null && data.cuda_device !== undefined) { + this.masterCudaDevice = data.cuda_device; + + // Store persistently in config if not already set or changed + if (!this.config.master) this.config.master = {}; + if (this.config.master.cuda_device === undefined || this.config.master.cuda_device !== data.cuda_device) { + this.config.master.cuda_device = data.cuda_device; + try { + await this.api.updateMaster({ cuda_device: data.cuda_device }); + this.log(`Stored master CUDA device: ${data.cuda_device}`, "debug"); + } catch (error) { + this.log(`Error storing master CUDA device: ${error.message}`, "error"); + } + } + + // Update the master display with CUDA info + this.ui.updateMasterDisplay(this); + } + + // Store CUDA device count for auto-population + if (data.cuda_device_count > 0) { + this.cudaDeviceCount = data.cuda_device_count; + this.log(`Detected ${this.cudaDeviceCount} CUDA devices`, "info"); + + // Auto-populate workers if conditions are met + const shouldAutoPopulate = + !this.config.settings.has_auto_populated_workers && // Never populated before + (!this.config.workers || this.config.workers.length === 0); // No workers exist + + this.log(`Auto-population check: has_populated=${this.config.settings.has_auto_populated_workers}, workers=${this.config.workers ? this.config.workers.length : 'null'}, should_populate=${shouldAutoPopulate}`, "debug"); + + if (shouldAutoPopulate) { + this.log(`Auto-populating workers based on ${this.cudaDeviceCount} CUDA devices (excluding master on CUDA ${this.masterCudaDevice})`, "info"); + + const newWorkers = []; + let workerNum = 1; + let portOffset = 0; + + for (let i = 0; i < this.cudaDeviceCount; i++) { + // Skip the CUDA device used by master + if (i === this.masterCudaDevice) { + this.log(`Skipping CUDA ${i} (used by master)`, "debug"); + continue; + } + + const worker = { + id: crypto.randomUUID(), + name: `Worker ${workerNum}`, + host: "localhost", + port: 8189 + portOffset, + cuda_device: i, + enabled: true + }; + newWorkers.push(worker); + workerNum++; + portOffset++; + } + + // Only proceed if we have workers to add + if (newWorkers.length > 0) { + this.log(`Auto-populating ${newWorkers.length} workers`, "info"); + + // Add workers to config + this.config.workers = newWorkers; + + // Set the flag to prevent future auto-population + this.config.settings.has_auto_populated_workers = true; + + // Save each worker using the update endpoint + for (const worker of newWorkers) { + try { + await this.api.updateWorker(worker.id, worker); + } catch (error) { + this.log(`Error saving worker ${worker.name}: ${error.message}`, "error"); + } + } + + // Save the updated settings + try { + await this.api.updateSetting('has_auto_populated_workers', true); + } catch (error) { + this.log(`Error saving auto-population flag: ${error.message}`, "error"); + } + + this.log(`Auto-populated ${newWorkers.length} workers and saved config`, "info"); + + // Reload the config to include the new workers + await this.loadConfig(); + } else { + this.log("No additional CUDA devices available for workers (all used by master)", "debug"); + } + } + } + + // Check if we already have a master host configured + if (this.config?.master?.host) { + this.log(`Master host already configured: ${this.config.master.host}`, "debug"); + return; + } + + // Use the recommended IP from the backend + if (data.recommended_ip && data.recommended_ip !== '127.0.0.1') { + this.log(`Auto-detected master IP: ${data.recommended_ip}`, "info"); + + // Save the detected IP (pass true to suppress notification) + await this.api.updateMaster({ host: data.recommended_ip }); + + // Show a single combined notification + if (app.extensionManager?.toast) { + app.extensionManager.toast.add({ + severity: "info", + summary: "Master IP Auto-Detected", + detail: `Master IP set to ${data.recommended_ip} for remote worker communication`, + life: 5000 + }); + } + } + } catch (error) { + this.log("Error detecting master IP: " + error.message, "error"); + } + } + + 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 isRemote = document.getElementById(`remote-${workerId}`).checked; + const host = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname; + const 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; + + // Validate + if (!name.trim()) { + app.extensionManager.toast.add({ + severity: "error", + summary: "Validation Error", + detail: "Worker name is required", + life: 3000 + }); + return; + } + + if (isRemote && !host.trim()) { + app.extensionManager.toast.add({ + severity: "error", + summary: "Validation Error", + detail: "Host is required for remote workers", + life: 3000 + }); + return; + } + + if (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(), + 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(); + 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 + }); + } + } + + 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); + } + } + } + + 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 + }); + } + } + + async addNewWorker() { + // Generate new worker ID using UUID + const newId = crypto.randomUUID(); + + // 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 + }); + } + } + + startLogAutoRefresh(workerId) { + // Stop any existing auto-refresh + this.stopLogAutoRefresh(); + + // Refresh every 2 seconds + this.logAutoRefreshInterval = setInterval(() => { + this.refreshLog(workerId, true); // silent mode + }, 2000); + } + + stopLogAutoRefresh() { + if (this.logAutoRefreshInterval) { + clearInterval(this.logAutoRefreshInterval); + this.logAutoRefreshInterval = null; + } + } + + 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"; + } + } + + _handleInterruptWorkers(button) { + return handleInterruptWorkers(this, button); + } + + _handleClearMemory(button) { + return handleClearMemory(this, button); + } +} + +app.registerExtension({ + name: "Distributed.Panel", + async setup() { + new DistributedExtension(); + } +}); \ No newline at end of file diff --git a/web/sidebarRenderer.js b/web/sidebarRenderer.js new file mode 100644 index 0000000..d88e4cd --- /dev/null +++ b/web/sidebarRenderer.js @@ -0,0 +1,273 @@ +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; + + try { + // Store reference to the panel element + extension.panelElement = el; + + // Show loading indicator + el.innerHTML = ''; + const loadingDiv = document.createElement("div"); + loadingDiv.style.cssText = "display: flex; align-items: center; justify-content: center; height: calc(100vh - 100px); color: #888;"; + loadingDiv.innerHTML = `
◐
`; + el.appendChild(loadingDiv); + + // Add rotation animation + const style = document.createElement('style'); + style.textContent = ` + @keyframes rotate { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } + } + `; + document.head.appendChild(style); + loadingDiv.querySelector('div').style.animation = 'rotate 1s linear infinite'; + + // Preload data outside render + await extension.loadConfig(); + await extension.loadManagedWorkers(); + + 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), + BUTTON_STYLES.clearMemory + ); + clearMemButton.title = "Clear VRAM on all enabled worker GPUs (not master)"; + clearMemButton.style.cssText += " flex: 1;"; + + const interruptButton = extension.ui.createButtonHelper( + "Interrupt Workers", + (e) => extension._handleInterruptWorkers(e.target), + BUTTON_STYLES.clearMemory + ); + interruptButton.title = "Cancel/interrupt execution on all enabled worker GPUs"; + interruptButton.style.cssText += " flex: 1;"; + + buttonRow.appendChild(clearMemButton); + buttonRow.appendChild(interruptButton); + actionsSection.appendChild(buttonRow); + + container.appendChild(actionsSection); + + // Settings section + const settingsSection = document.createElement("div"); + settingsSection.style.cssText = "border-top: 1px solid #444; padding-top: 10px; margin-bottom: 10px;"; + + // Settings header with toggle + const settingsHeader = document.createElement("div"); + settingsHeader.style.cssText = "display: flex; align-items: center; justify-content: space-between; cursor: pointer; user-select: none;"; + + const workerSettingsTitle = document.createElement("h4"); + workerSettingsTitle.textContent = "Settings"; + workerSettingsTitle.style.cssText = "margin: 0; font-size: 14px;"; + + const workerSettingsToggle = document.createElement("span"); + workerSettingsToggle.textContent = "▶"; // Right arrow when collapsed + workerSettingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; + + settingsHeader.appendChild(workerSettingsTitle); + settingsHeader.appendChild(workerSettingsToggle); + + // Hover effect for header + settingsHeader.onmouseover = () => { + workerSettingsToggle.style.color = "#fff"; + }; + settingsHeader.onmouseout = () => { + workerSettingsToggle.style.color = "#888"; + }; + + // Collapsible settings content + const settingsContent = document.createElement("div"); + settingsContent.style.cssText = "max-height: 0; overflow: hidden; transition: max-height 0.3s ease;"; + + const settingsDiv = document.createElement("div"); + settingsDiv.style.cssText = "display: flex; flex-direction: column; gap: 8px; padding-top: 10px;"; + + // Toggle functionality + let settingsExpanded = false; + settingsHeader.onclick = () => { + settingsExpanded = !settingsExpanded; + if (settingsExpanded) { + settingsContent.style.maxHeight = "200px"; + workerSettingsToggle.style.transform = "rotate(90deg)"; + } else { + settingsContent.style.maxHeight = "0"; + workerSettingsToggle.style.transform = "rotate(0deg)"; + } + }; + + // Debug mode setting + const debugGroup = document.createElement("div"); + debugGroup.style.cssText = "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;"; + + debugGroup.appendChild(debugCheckbox); + debugGroup.appendChild(debugLabel); + + // Auto-launch workers setting + const autoLaunchGroup = document.createElement("div"); + autoLaunchGroup.style.cssText = "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;"; + + autoLaunchGroup.appendChild(autoLaunchCheckbox); + autoLaunchGroup.appendChild(autoLaunchLabel); + + // Stop workers on exit setting + const stopOnExitGroup = document.createElement("div"); + stopOnExitGroup.style.cssText = "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;"; + + stopOnExitGroup.appendChild(stopOnExitCheckbox); + stopOnExitGroup.appendChild(stopOnExitLabel); + + settingsDiv.appendChild(debugGroup); + settingsDiv.appendChild(autoLaunchGroup); + settingsDiv.appendChild(stopOnExitGroup); + settingsContent.appendChild(settingsDiv); + + settingsSection.appendChild(settingsHeader); + settingsSection.appendChild(settingsContent); + container.appendChild(settingsSection); + + const summarySection = document.createElement("div"); + summarySection.style.cssText = "border-top: 1px solid #444; padding-top: 10px;"; + const summary = document.createElement("div"); + summary.id = "distributed-summary"; + summary.style.cssText = "font-size: 11px; color: #888;"; + summarySection.appendChild(summary); + container.appendChild(summarySection); + el.appendChild(container); + extension.updateSummary(); + + // Start checking worker statuses immediately in parallel + setTimeout(() => extension.checkAllWorkerStatuses(), 0); + } finally { + // Always reset the rendering flag + extension._isRendering = false; + } +} \ No newline at end of file diff --git a/web/stateManager.js b/web/stateManager.js new file mode 100644 index 0000000..f400f4a --- /dev/null +++ b/web/stateManager.js @@ -0,0 +1,61 @@ +export function createStateManager() { + const state = { + workers: new Map(), // Unified worker state: { status, managed, launching, expanded, ... } + masterStatus: 'online', + }; + + return { + // Worker state management + getWorker(workerId) { + return state.workers.get(String(workerId)) || {}; + }, + + updateWorker(workerId, updates) { + const id = String(workerId); + const current = state.workers.get(id) || {}; + state.workers.set(id, { ...current, ...updates }); + return state.workers.get(id); + }, + + setWorkerStatus(workerId, status) { + return this.updateWorker(workerId, { status }); + }, + + setWorkerManaged(workerId, info) { + return this.updateWorker(workerId, { managed: info }); + }, + + setWorkerLaunching(workerId, launching) { + return this.updateWorker(workerId, { launching }); + }, + + setWorkerExpanded(workerId, expanded) { + return this.updateWorker(workerId, { expanded }); + }, + + isWorkerLaunching(workerId) { + return this.getWorker(workerId).launching || false; + }, + + isWorkerExpanded(workerId) { + return this.getWorker(workerId).expanded || false; + }, + + isWorkerManaged(workerId) { + return !!this.getWorker(workerId).managed; + }, + + getWorkerStatus(workerId) { + return this.getWorker(workerId).status || {}; + }, + + // Master state + setMasterStatus(status) { + state.masterStatus = status; + }, + + getMasterStatus() { + return state.masterStatus; + } + }; +} \ No newline at end of file diff --git a/web/ui.js b/web/ui.js new file mode 100644 index 0000000..87bbbd9 --- /dev/null +++ b/web/ui.js @@ -0,0 +1,997 @@ +import { BUTTON_STYLES, UI_STYLES, STATUS_COLORS } from './constants.js'; + +const cardConfigs = { + master: { + checkbox: { + enabled: false, + checked: true, + disabled: true, + opacity: 0.6, + title: "Master node is always enabled" + }, + statusDot: { + color: STATUS_COLORS.ONLINE_GREEN, + title: 'Online', + id: 'master-status', + dynamic: true + }, + infoText: (data, extension) => { + const cudaDevice = extension.config?.master?.cuda_device ?? extension.masterCudaDevice; + const cudaInfo = cudaDevice !== undefined ? `CUDA ${cudaDevice} • ` : ''; + const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); + return `${data?.name || extension.config?.master?.name || "Master"}
${cudaInfo}Port ${port}`; + }, + controls: { + type: 'info', + text: 'Master', + style: "background-color: #333; color: #999;" + }, + settings: { + formType: 'master', + id: 'master-settings', + expandedTracker: 'masterSettingsExpanded' + }, + hover: true, + expand: true, + border: 'solid' + }, + worker: { + checkbox: { + enabled: true, + title: "Enable/disable this worker" + }, + statusDot: { + dynamic: true, + initialColor: (data) => data.enabled ? STATUS_COLORS.OFFLINE_RED : STATUS_COLORS.DISABLED_GRAY, + initialTitle: (data) => data.enabled ? "Checking status..." : "Disabled", + pulsing: (data) => data.enabled, + id: (data) => `status-${data.id}` + }, + infoText: (data, extension) => { + const isRemote = extension.isRemoteWorker(data); + if (isRemote) { + return `${data.name}
${data.host}:${data.port}`; + } else { + const cudaInfo = data.cuda_device !== undefined ? `CUDA ${data.cuda_device} • ` : ''; + return `${data.name}
${cudaInfo}Port ${data.port}`; + } + }, + controls: { + dynamic: true + }, + settings: { + formType: 'worker', + id: (data) => `settings-${data.id}`, + expandedId: (data) => data?.id + }, + hover: true, + expand: true, + border: 'solid' + }, + blueprint: { + checkbox: { + type: 'icon', + content: '+', + width: 42, + style: 'border-right: 2px dashed #555; color: #777; font-size: 24px; font-weight: 500;' + }, + statusDot: { + color: 'transparent', + border: '1px solid #555' + }, + infoText: () => `Add New Worker
[CUDA] • [Port]`, + controls: { + type: 'ghost', + text: 'Configure', + style: 'border: 1px solid #444; background: transparent; color: #555;' + }, + hover: 'placeholder', + expand: false, + border: 'dashed' + }, + add: { + checkbox: { + type: 'icon', + content: '+', + width: 43, + style: 'border-right: 1px dashed #444; color: #555; font-size: 18px;' + }, + statusDot: { + color: 'transparent', + border: '1px solid #555' + }, + infoText: () => `Add New Worker`, + controls: null, + hover: 'placeholder', + expand: false, + border: 'dashed', + minHeight: '48px' + } +}; + +export class DistributedUI { + constructor() { + // UI element styles + this.styles = UI_STYLES; + } + + createStatusDot(id, color = "#666", title = "Status") { + const dot = document.createElement("span"); + if (id) dot.id = id; + dot.style.cssText = this.styles.statusDot + ` background-color: ${color};`; + dot.title = title; + return dot; + } + + createButton(text, onClick, customStyle = "") { + const button = document.createElement("button"); + button.textContent = text; + button.className = "distributed-button"; + button.style.cssText = BUTTON_STYLES.base + customStyle; + if (onClick) button.onclick = onClick; + return button; + } + + createButtonGroup(buttons, style = "") { + const group = document.createElement("div"); + group.style.cssText = "display: flex; gap: 4px; margin-top: 10px;" + style; + buttons.forEach(button => group.appendChild(button)); + return group; + } + + createWorkerControls(workerId, handlers = {}) { + const controlsDiv = document.createElement("div"); + controlsDiv.id = `controls-${workerId}`; + controlsDiv.style.cssText = this.styles.controlsDiv; + + const buttons = []; + + if (handlers.launch) { + const launchBtn = this.createButton('Launch', handlers.launch); + launchBtn.id = `launch-${workerId}`; + launchBtn.title = "Launch this worker instance"; + buttons.push(launchBtn); + } + + if (handlers.stop) { + const stopBtn = this.createButton('Stop', handlers.stop); + stopBtn.id = `stop-${workerId}`; + stopBtn.title = "Stop this worker instance"; + buttons.push(stopBtn); + } + + if (handlers.viewLog) { + const logBtn = this.createButton('View Log', handlers.viewLog); + logBtn.id = `log-${workerId}`; + logBtn.title = "View worker log file"; + buttons.push(logBtn); + } + + buttons.forEach(btn => controlsDiv.appendChild(btn)); + return controlsDiv; + } + + createFormGroup(label, value, id, type = "text", placeholder = "") { + const group = document.createElement("div"); + group.style.cssText = this.styles.formGroup; + + const labelEl = document.createElement("label"); + labelEl.textContent = label; + labelEl.htmlFor = id; + labelEl.style.cssText = this.styles.formLabel; + + const input = document.createElement("input"); + input.type = type; + input.id = id; + input.value = value; + input.placeholder = placeholder; + input.style.cssText = this.styles.formInput; + + group.appendChild(labelEl); + group.appendChild(input); + return { group, input }; + } + + + createInfoBox(text) { + const box = document.createElement("div"); + box.style.cssText = this.styles.infoBox; + box.textContent = text; + return box; + } + + addHoverEffect(element, onHover, onLeave) { + element.onmouseover = onHover; + element.onmouseout = onLeave; + } + + createCard(type = 'worker', options = {}) { + const card = document.createElement("div"); + const baseStyle = "margin-bottom: 12px; border-radius: 6px; overflow: hidden; display: flex;"; + + switch(type) { + case 'master': + case 'worker': + card.style.cssText = baseStyle + "background: #2a2a2a;"; + break; + case 'blueprint': + card.style.cssText = baseStyle + "border: 2px dashed #555; cursor: pointer; transition: all 0.2s ease; background: rgba(255, 255, 255, 0.02);"; + if (options.onClick) card.onclick = options.onClick; + if (options.title) card.title = options.title; + break; + case 'add': + card.style.cssText = baseStyle + "border: 1px dashed #444; cursor: pointer; transition: all 0.2s ease; background: transparent;"; + if (options.onClick) card.onclick = options.onClick; + if (options.title) card.title = options.title; + break; + } + + if (options.onMouseEnter) { + card.addEventListener('mouseenter', options.onMouseEnter); + } + if (options.onMouseLeave) { + card.addEventListener('mouseleave', options.onMouseLeave); + } + + return card; + } + + createCardColumn(type = 'checkbox', options = {}) { + const column = document.createElement("div"); + const baseStyle = "display: flex; align-items: center; justify-content: center;"; + + switch(type) { + case 'checkbox': + column.style.cssText = baseStyle + "flex: 0 0 44px; border-right: 1px solid #3a3a3a; cursor: default; background: rgba(0,0,0,0.1);"; + if (options.title) column.title = options.title; + break; + case 'icon': + column.style.cssText = baseStyle + "width: 44px; flex-shrink: 0; font-size: 20px; color: #666;"; + break; + case 'content': + column.style.cssText = "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;"; + break; + } + + return column; + } + + createInfoRow(options = {}) { + const row = document.createElement("div"); + row.style.cssText = "display: flex; align-items: center; padding: 12px; cursor: pointer; min-height: 64px;"; + if (options.onClick) row.onclick = options.onClick; + return row; + } + + createWorkerContent() { + const content = document.createElement("div"); + content.style.cssText = "display: flex; align-items: center; gap: 10px; flex: 1;"; + return content; + } + + createSettingsForm(fields = [], options = {}) { + const form = document.createElement("div"); + form.style.cssText = "display: flex; flex-direction: column; gap: 10px;"; + + fields.forEach(field => { + if (field.type === 'checkbox') { + const group = document.createElement("div"); + group.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;"; + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = field.id; + checkbox.checked = field.checked || false; + if (field.onChange) checkbox.onchange = field.onChange; + + const label = document.createElement("label"); + label.htmlFor = field.id; + label.textContent = field.label; + label.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; + + group.appendChild(checkbox); + group.appendChild(label); + form.appendChild(group); + } else { + const result = this.createFormGroup(field.label, field.value, field.id, field.type, field.placeholder); + if (field.groupId) result.group.id = field.groupId; + if (field.display) result.group.style.display = field.display; + form.appendChild(result.group); + } + }); + + if (options.buttons) { + const buttonGroup = this.createButtonGroup(options.buttons, options.buttonStyle || " margin-top: 8px;"); + form.appendChild(buttonGroup); + } + + return form; + } + + + createButtonHelper(text, onClick, style) { + return this.createButton(text, onClick, style); + } + + updateMasterDisplay(extension) { + // Use persistent config value as fallback + const cudaDevice = extension?.config?.master?.cuda_device ?? extension?.masterCudaDevice; + + // Update CUDA info if element exists + const cudaInfo = document.getElementById('master-cuda-info'); + if (cudaInfo) { + const port = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); + if (cudaDevice !== undefined && cudaDevice !== null) { + cudaInfo.textContent = `CUDA ${cudaDevice} • Port ${port}`; + } else { + cudaInfo.textContent = `Port ${port}`; + } + } + + // Update name if changed + const nameDisplay = document.getElementById('master-name-display'); + if (nameDisplay && extension?.config?.master?.name) { + nameDisplay.textContent = extension.config.master.name; + } + } + + showToast(app, severity, summary, detail, life = 3000) { + if (app.extensionManager?.toast?.add) { + app.extensionManager.toast.add({ severity, summary, detail, life }); + } + } + + updateStatusDot(workerId, color, title, pulsing = false) { + const statusDot = document.getElementById(`status-${workerId}`); + if (!statusDot) return; + + statusDot.style.backgroundColor = color; + statusDot.title = title; + statusDot.classList.toggle('status-pulsing', pulsing); + } + + showLogModal(extension, workerId, logData) { + // Remove any existing modal + const existingModal = document.getElementById('distributed-log-modal'); + if (existingModal) { + existingModal.remove(); + } + + const worker = extension.config.workers.find(w => w.id === workerId); + const workerName = worker?.name || `Worker ${workerId}`; + + // Create modal container + const modal = document.createElement('div'); + modal.id = 'distributed-log-modal'; + modal.style.cssText = ` + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.8); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; + `; + + // Create modal content + const content = document.createElement('div'); + content.style.cssText = ` + background: #1e1e1e; + border-radius: 8px; + width: 90%; + max-width: 1200px; + height: 80%; + display: flex; + flex-direction: column; + border: 1px solid #444; + `; + + // Header + const header = document.createElement('div'); + header.style.cssText = ` + padding: 15px 20px; + border-bottom: 1px solid #444; + display: flex; + justify-content: space-between; + align-items: center; + `; + + const title = document.createElement('h3'); + title.textContent = `${workerName} - Log Viewer`; + title.style.cssText = 'margin: 0; color: #fff;'; + + const headerButtons = document.createElement('div'); + headerButtons.style.cssText = 'display: flex; gap: 20px; align-items: center;'; + + // Auto-refresh container + const refreshContainer = document.createElement('div'); + refreshContainer.style.cssText = 'display: flex; align-items: center; gap: 4px;'; + + // Auto-refresh checkbox + const refreshCheckbox = document.createElement('input'); + refreshCheckbox.type = 'checkbox'; + refreshCheckbox.id = 'log-auto-refresh'; + refreshCheckbox.checked = true; // Enabled by default + refreshCheckbox.style.cssText = 'cursor: pointer;'; + refreshCheckbox.onchange = (e) => { + if (e.target.checked) { + extension.startLogAutoRefresh(workerId); + } else { + extension.stopLogAutoRefresh(); + } + }; + + const refreshLabel = document.createElement('label'); + refreshLabel.htmlFor = 'log-auto-refresh'; + refreshLabel.style.cssText = 'font-size: 12px; color: #ccc; cursor: pointer; white-space: nowrap;'; + refreshLabel.textContent = 'Auto-refresh'; + + // Add checkbox and label to container + refreshContainer.appendChild(refreshCheckbox); + refreshContainer.appendChild(refreshLabel); + + // Close button + const closeBtn = this.createButton('✕', + () => { + extension.stopLogAutoRefresh(); + modal.remove(); + }, + 'background-color: #c04c4c;'); + closeBtn.style.cssText += ' padding: 5px 10px; font-size: 14px; font-weight: bold;'; + + headerButtons.appendChild(refreshContainer); + headerButtons.appendChild(closeBtn); + + header.appendChild(title); + header.appendChild(headerButtons); + + // Log content area + const logContainer = document.createElement('div'); + logContainer.style.cssText = ` + flex: 1; + overflow: auto; + padding: 15px; + font-family: 'Consolas', 'Monaco', 'Courier New', monospace; + font-size: 12px; + line-height: 1.4; + color: #ddd; + background: #0d0d0d; + white-space: pre-wrap; + word-wrap: break-word; + `; + logContainer.id = 'distributed-log-content'; + logContainer.textContent = logData.content; + + // Auto-scroll to bottom + setTimeout(() => { + logContainer.scrollTop = logContainer.scrollHeight; + }, 0); + + // Status bar + const statusBar = document.createElement('div'); + statusBar.style.cssText = ` + padding: 10px 20px; + border-top: 1px solid #444; + font-size: 11px; + color: #888; + `; + statusBar.textContent = `Log file: ${logData.log_file}`; + if (logData.truncated) { + statusBar.textContent += ` (showing last ${logData.lines_shown} lines of ${this.formatFileSize(logData.file_size)})`; + } + + // Assemble modal + content.appendChild(header); + content.appendChild(logContainer); + content.appendChild(statusBar); + modal.appendChild(content); + + // Close on background click + modal.onclick = (e) => { + if (e.target === modal) { + extension.stopLogAutoRefresh(); + modal.remove(); + } + }; + + // Close on Escape key + const handleEscape = (e) => { + if (e.key === 'Escape') { + extension.stopLogAutoRefresh(); + modal.remove(); + document.removeEventListener('keydown', handleEscape); + } + }; + document.addEventListener('keydown', handleEscape); + + document.body.appendChild(modal); + + // Start auto-refresh + extension.startLogAutoRefresh(workerId); + } + + formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; + } + + createWorkerSettingsForm(extension, worker) { + const form = document.createElement("div"); + form.style.cssText = "display: flex; flex-direction: column; gap: 8px;"; + + // Name field + const nameGroup = this.createFormGroup("Name:", worker.name, `name-${worker.id}`); + form.appendChild(nameGroup.group); + + // Remote checkbox + const remoteGroup = document.createElement("div"); + remoteGroup.style.cssText = "display: flex; align-items: center; gap: 8px; margin: 5px 0;"; + + const remoteCheckbox = document.createElement("input"); + remoteCheckbox.type = "checkbox"; + remoteCheckbox.id = `remote-${worker.id}`; + remoteCheckbox.checked = extension.isRemoteWorker(worker); + remoteCheckbox.onchange = (e) => { + const isRemote = e.target.checked; + // Show/hide relevant fields + const hostGroup = document.getElementById(`host-group-${worker.id}`); + const cudaGroup = document.getElementById(`cuda-group-${worker.id}`); + const argsGroup = document.getElementById(`args-group-${worker.id}`); + + if (isRemote) { + hostGroup.style.display = "flex"; + cudaGroup.style.display = "none"; + argsGroup.style.display = "none"; + // If switching to remote and host is localhost, clear it + const hostInput = document.getElementById(`host-${worker.id}`); + if (hostInput.value === "localhost" || hostInput.value === "127.0.0.1") { + hostInput.value = ""; + } + } else { + hostGroup.style.display = "none"; + cudaGroup.style.display = "flex"; + argsGroup.style.display = "flex"; + } + }; + + const remoteLabel = document.createElement("label"); + remoteLabel.htmlFor = `remote-${worker.id}`; + remoteLabel.textContent = "Remote Worker"; + remoteLabel.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;"; + + remoteGroup.appendChild(remoteCheckbox); + remoteGroup.appendChild(remoteLabel); + form.appendChild(remoteGroup); + + // Host field (only for remote workers) + const hostGroup = this.createFormGroup("Host:", worker.host || "", `host-${worker.id}`, "text", "e.g., 192.168.1.100"); + hostGroup.group.id = `host-group-${worker.id}`; + hostGroup.group.style.display = extension.isRemoteWorker(worker) ? "flex" : "none"; + form.appendChild(hostGroup.group); + + // Port field + const portGroup = this.createFormGroup("Port:", worker.port, `port-${worker.id}`, "number"); + form.appendChild(portGroup.group); + + // CUDA Device field (only for local workers) + const cudaGroup = this.createFormGroup("CUDA Device:", worker.cuda_device || 0, `cuda-${worker.id}`, "number"); + cudaGroup.group.id = `cuda-group-${worker.id}`; + cudaGroup.group.style.display = extension.isRemoteWorker(worker) ? "none" : "flex"; + form.appendChild(cudaGroup.group); + + // Extra Args field (only for local workers) + const argsGroup = this.createFormGroup("Extra Args:", worker.extra_args || "", `args-${worker.id}`); + argsGroup.group.id = `args-group-${worker.id}`; + argsGroup.group.style.display = extension.isRemoteWorker(worker) ? "none" : "flex"; + form.appendChild(argsGroup.group); + + // Buttons + const saveBtn = this.createButton("Save", + () => extension.saveWorkerSettings(worker.id), + "background-color: #4a7c4a;"); + saveBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500;"; + + const cancelBtn = this.createButton("Cancel", + () => extension.cancelWorkerSettings(worker.id), + "background-color: #555;"); + cancelBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500;"; + + const deleteBtn = this.createButton("Delete", + () => extension.deleteWorker(worker.id), + "background-color: #7c4a4a;"); + deleteBtn.style.cssText += " padding: 6px 14px; font-size: 12px; font-weight: 500; margin-left: auto;"; + + const buttonGroup = this.createButtonGroup([saveBtn, cancelBtn, deleteBtn], " margin-top: 8px;"); + form.appendChild(buttonGroup); + + return form; + } + + createSettingsToggle() { + const settingsRow = document.createElement("div"); + settingsRow.style.cssText = "display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; user-select: none;"; + + const settingsTitle = document.createElement("h4"); + settingsTitle.textContent = "Settings"; + settingsTitle.style.cssText = "margin: 0; font-size: 14px;"; + + const settingsToggle = document.createElement("span"); + settingsToggle.textContent = "▶"; // Right arrow when collapsed + settingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;"; + + settingsRow.appendChild(settingsToggle); + settingsRow.appendChild(settingsTitle); + + return { settingsRow, settingsToggle }; + } + + + createCheckboxOrIconColumn(config, data, extension) { + const column = this.createCardColumn('checkbox'); + + if (config?.type === 'icon') { + column.style.flex = `0 0 ${config.width || 44}px`; + column.innerHTML = config.content || '+'; + if (config.style) { + const styles = config.style.split(';').filter(s => s.trim()); + styles.forEach(style => { + const [prop, value] = style.split(':').map(s => s.trim()); + if (prop && value) { + column.style[prop.replace(/-([a-z])/g, (g) => g[1].toUpperCase())] = value; + } + }); + } + } else { + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.id = `gpu-${data?.id || 'master'}`; + checkbox.checked = config?.checked !== undefined ? config.checked : data?.enabled; + checkbox.disabled = config?.disabled || false; + checkbox.style.cssText = `cursor: ${config?.disabled ? 'default' : 'pointer'}; width: 16px; height: 16px;`; + + if (config?.opacity) checkbox.style.opacity = config.opacity; + if (config?.title) column.title = config.title; + + if (config?.enabled && !config?.disabled && data?.id) { + checkbox.style.pointerEvents = "none"; + column.style.cursor = "pointer"; + column.onclick = async () => { + checkbox.checked = !checkbox.checked; + await extension.updateWorkerEnabled(data.id, checkbox.checked); + extension.updateSummary(); + }; + } + + column.appendChild(checkbox); + } + + return column; + } + + createStatusDotHelper(config, data, extension) { + let color = config.color || "#666"; + let title = config.title || "Status"; + let id = config.id; + + if (typeof config.initialColor === 'function') { + color = config.initialColor(data); + } + if (typeof config.initialTitle === 'function') { + title = config.initialTitle(data); + } + if (typeof config.id === 'function') { + id = config.id(data); + } + + const dot = this.createStatusDot(id, color, title); + + if (config.border) { + dot.style.border = config.border; + } + + if (config.pulsing && (typeof config.pulsing !== 'function' || config.pulsing(data))) { + dot.classList.add('status-pulsing'); + } + + return dot; + } + + createSettingsToggleHelper(expandedId, extension) { + const arrow = document.createElement("span"); + arrow.className = "settings-arrow"; + arrow.innerHTML = "▶"; + arrow.style.cssText = this.styles.settingsArrow; + + const isExpanded = typeof expandedId === 'function' ? + extension.state.isWorkerExpanded(expandedId(extension)) : + (expandedId === 'master' ? false : extension.state.isWorkerExpanded(expandedId)); + + if (isExpanded) { + arrow.style.transform = "rotate(90deg)"; + } + + return arrow; + } + + createControlsSection(config, data, extension, isRemote) { + if (!config) return null; + + const controlsDiv = document.createElement("div"); + controlsDiv.id = `controls-${data?.id || 'master'}`; + controlsDiv.style.cssText = this.styles.controlsDiv; + + // Always create a wrapper div for consistent layout + const controlsWrapper = document.createElement("div"); + controlsWrapper.style.cssText = "display: flex; gap: 6px; align-items: stretch; width: 100%;"; + + if (config.dynamic && data) { + if (isRemote) { + const remoteInfo = this.createButton("Remote worker", null, "background-color: #333;"); + remoteInfo.style.cssText += " flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; color: #999; cursor: default;"; + remoteInfo.disabled = true; + controlsWrapper.appendChild(remoteInfo); + } else { + const controls = this.createWorkerControls(data.id, { + launch: () => extension.launchWorker(data.id), + stop: () => extension.stopWorker(data.id), + viewLog: () => extension.viewWorkerLog(data.id) + }); + + const launchBtn = controls.querySelector(`#launch-${data.id}`); + const stopBtn = controls.querySelector(`#stop-${data.id}`); + const logBtn = controls.querySelector(`#log-${data.id}`); + + launchBtn.style.cssText += ` flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; ${BUTTON_STYLES.launch}`; + launchBtn.title = "Launch worker (runs in background with logging)"; + + stopBtn.style.cssText += ` flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; ${BUTTON_STYLES.stop} display: none;`; + stopBtn.title = "Stop worker"; + + logBtn.style.cssText += ' flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; display: none; background-color: #685434;'; + + while (controls.firstChild) { + controlsWrapper.appendChild(controls.firstChild); + } + } + } else if (config.type === 'info') { + const infoBtn = this.createButton(config.text, null, config.style || ""); + infoBtn.style.cssText += " flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; cursor: default;"; + infoBtn.disabled = true; + controlsWrapper.appendChild(infoBtn); + } else if (config.type === 'ghost') { + const ghostBtn = document.createElement("button"); + ghostBtn.style.cssText = `flex: 1; padding: 5px 14px; font-size: 11px; font-weight: 500; border-radius: 4px; cursor: default; ${config.style || ""}`; + ghostBtn.textContent = config.text; + ghostBtn.disabled = true; + controlsWrapper.appendChild(ghostBtn); + } + + controlsDiv.appendChild(controlsWrapper); + return controlsDiv; + } + + createSettingsSection(config, data, extension) { + const settingsDiv = document.createElement("div"); + const settingsId = typeof config.id === 'function' ? config.id(data) : config.id; + settingsDiv.id = settingsId; + settingsDiv.className = "worker-settings"; + + const expandedId = typeof config.expandedId === 'function' ? config.expandedId(data) : config.expandedId; + const isExpanded = expandedId === 'master-settings' ? false : extension.state.isWorkerExpanded(expandedId); + + settingsDiv.style.cssText = this.styles.workerSettings; + + if (isExpanded) { + settingsDiv.classList.add("expanded"); + settingsDiv.style.padding = "12px"; + settingsDiv.style.marginTop = "8px"; + settingsDiv.style.marginBottom = "8px"; + } + + let settingsForm; + if (config.formType === 'master') { + settingsForm = this.createMasterSettingsForm(extension, data); + } else if (config.formType === 'worker') { + settingsForm = this.createWorkerSettingsForm(extension, data); + } + + if (settingsForm) { + settingsDiv.appendChild(settingsForm); + } + + return settingsDiv; + } + + createMasterSettingsForm(extension, data) { + const settingsForm = document.createElement("div"); + settingsForm.style.cssText = "display: flex; flex-direction: column; gap: 8px;"; + + const nameResult = this.createFormGroup("Name:", extension.config?.master?.name || "Master", "master-name"); + settingsForm.appendChild(nameResult.group); + + const hostResult = this.createFormGroup("Host:", extension.config?.master?.host || "", "master-host", "text", "Auto-detect if empty"); + settingsForm.appendChild(hostResult.group); + + const saveBtn = this.createButton("Save", async () => { + const nameInput = document.getElementById('master-name'); + const hostInput = document.getElementById('master-host'); + + if (!extension.config.master) extension.config.master = {}; + extension.config.master.name = nameInput.value.trim() || "Master"; + + await extension.api.updateMaster({ + host: hostInput.value.trim(), + name: extension.config.master.name + }); + + // Reload config to refresh any updated values + await extension.loadConfig(); + + document.getElementById('master-name-display').textContent = extension.config.master.name; + this.updateMasterDisplay(extension); + + saveBtn.textContent = "Saved!"; + setTimeout(() => { saveBtn.textContent = "Save"; }, 2000); + }, "background-color: #4a7c4a;"); + saveBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; + + const cancelBtn = this.createButton("Cancel", () => { + document.getElementById('master-name').value = extension.config?.master?.name || "Master"; + document.getElementById('master-host').value = extension.config?.master?.host || ""; + }, "background-color: #555;"); + cancelBtn.style.cssText += " padding: 6px 12px; font-size: 12px;"; + + const buttonGroup = this.createButtonGroup([saveBtn, cancelBtn], " margin-top: 8px;"); + settingsForm.appendChild(buttonGroup); + + return settingsForm; + } + + addPlaceholderHover(card, leftColumn, entityType) { + card.onmouseover = () => { + if (entityType === 'blueprint') { + card.style.borderColor = "#777"; + card.style.backgroundColor = "rgba(255, 255, 255, 0.05)"; + leftColumn.style.color = "#999"; + } else { + card.style.borderColor = "#666"; + card.style.backgroundColor = "rgba(255, 255, 255, 0.02)"; + leftColumn.style.color = "#888"; + leftColumn.style.borderColor = "#666"; + } + }; + + card.onmouseout = () => { + if (entityType === 'blueprint') { + card.style.borderColor = "#555"; + card.style.backgroundColor = "rgba(255, 255, 255, 0.02)"; + leftColumn.style.color = "#777"; + } else { + card.style.borderColor = "#444"; + card.style.backgroundColor = "transparent"; + leftColumn.style.color = "#555"; + leftColumn.style.borderColor = "#444"; + } + }; + } + + renderEntityCard(entityType, data, extension) { + const config = cardConfigs[entityType] || {}; + const isPlaceholder = entityType === 'blueprint' || entityType === 'add'; + const isWorker = entityType === 'worker'; + const isMaster = entityType === 'master'; + const isRemote = isWorker && extension.isRemoteWorker(data); + + const cardOptions = { + onClick: isPlaceholder ? data?.onClick : null + }; + if (isPlaceholder) { + cardOptions.title = entityType === 'blueprint' ? "Click to add your first worker" : "Click to add a new worker"; + } + const card = this.createCard(entityType, cardOptions); + + const leftColumn = this.createCheckboxOrIconColumn(config.checkbox, data, extension); + card.appendChild(leftColumn); + + const rightColumn = this.createCardColumn('content'); + + const infoRow = this.createInfoRow(); + if (config.infoRowPadding) { + infoRow.style.padding = config.infoRowPadding; + } + if (config.minHeight === 'auto') { + infoRow.style.minHeight = 'auto'; + } else if (config.minHeight) { + infoRow.style.minHeight = config.minHeight; + } + if (config.expand) { + infoRow.title = "Click to expand settings"; + infoRow.onclick = () => { + if (isMaster) { + const masterSettingsExpanded = !extension.masterSettingsExpanded; + extension.masterSettingsExpanded = masterSettingsExpanded; + const masterSettingsDiv = document.getElementById("master-settings"); + const arrow = infoRow.querySelector('.settings-arrow'); + if (masterSettingsExpanded) { + masterSettingsDiv.classList.add("expanded"); + masterSettingsDiv.style.padding = "12px"; + masterSettingsDiv.style.marginTop = "8px"; + masterSettingsDiv.style.marginBottom = "8px"; + arrow.style.transform = "rotate(90deg)"; + } else { + masterSettingsDiv.classList.remove("expanded"); + masterSettingsDiv.style.padding = "0 12px"; + masterSettingsDiv.style.marginTop = "0"; + masterSettingsDiv.style.marginBottom = "0"; + arrow.style.transform = "rotate(0deg)"; + } + } else { + extension.toggleWorkerExpanded(data.id); + } + }; + } + + const workerContent = this.createWorkerContent(); + if (entityType === 'add') { + workerContent.style.alignItems = "center"; + } + + const statusDot = this.createStatusDotHelper(config.statusDot, data, extension); + workerContent.appendChild(statusDot); + + const infoSpan = document.createElement("span"); + infoSpan.innerHTML = config.infoText(data, extension); + workerContent.appendChild(infoSpan); + + infoRow.appendChild(workerContent); + + let settingsArrow; + if (config.expand) { + const expandedId = config.settings?.expandedId || (isMaster ? 'master' : data?.id); + settingsArrow = this.createSettingsToggleHelper(expandedId, extension); + if (isMaster && !extension.masterSettingsExpanded) { + settingsArrow.style.transform = "rotate(0deg)"; + } + infoRow.appendChild(settingsArrow); + } + + rightColumn.appendChild(infoRow); + + if (config.hover === true) { + rightColumn.onmouseover = () => { + rightColumn.style.backgroundColor = "#333"; + if (settingsArrow) settingsArrow.style.color = "#fff"; + }; + rightColumn.onmouseout = () => { + rightColumn.style.backgroundColor = "transparent"; + if (settingsArrow) settingsArrow.style.color = "#888"; + }; + } + + const controlsDiv = this.createControlsSection(config.controls, data, extension, isRemote); + if (controlsDiv) { + rightColumn.appendChild(controlsDiv); + } + + if (config.settings) { + const settingsDiv = this.createSettingsSection(config.settings, data, extension); + rightColumn.appendChild(settingsDiv); + } + + card.appendChild(rightColumn); + + if (config.hover === 'placeholder') { + this.addPlaceholderHover(card, leftColumn, entityType); + } + + if (isWorker && !isRemote) { + extension.updateWorkerControls(data.id); + } + + return card; + } +} \ No newline at end of file diff --git a/web/workerUtils.js b/web/workerUtils.js new file mode 100644 index 0000000..122d59e --- /dev/null +++ b/web/workerUtils.js @@ -0,0 +1,266 @@ +import { BUTTON_STYLES } from './constants.js'; + +export async function handleWorkerOperation(extension, button, operation, successText, errorText) { + const originalText = button.textContent; + const originalStyle = button.style.cssText; + + button.textContent = operation.loadingText; + button.disabled = true; + + try { + const urlsToProcess = extension.enabledWorkers.map(w => ({ + name: w.name, + url: extension.getWorkerUrl(w) + })); + + if (urlsToProcess.length === 0) { + button.textContent = "No Workers"; + button.style.backgroundColor = "#c04c4c"; + setTimeout(() => { + button.textContent = originalText; + button.style.cssText = originalStyle; + button.disabled = false; + }, 3000); + return; + } + + const promises = urlsToProcess.map(target => + fetch(`${target.url}${operation.endpoint}`, { + method: 'POST', + mode: 'cors' + }) + .then(response => ({ ok: response.ok, name: target.name })) + .catch(() => ({ ok: false, name: target.name })) + ); + + const results = await Promise.all(promises); + const failures = results.filter(r => !r.ok); + + if (failures.length === 0) { + button.textContent = successText; + button.style.backgroundColor = BUTTON_STYLES.success.split(':')[1].trim().replace(';', ''); + if (operation.onSuccess) operation.onSuccess(); + } else { + button.textContent = errorText; + button.style.backgroundColor = BUTTON_STYLES.error.split(':')[1].trim().replace(';', ''); + extension.log(`${operation.name} failed on: ${failures.map(f => f.name).join(", ")}`, "error"); + } + + setTimeout(() => { + button.textContent = originalText; + button.style.cssText = originalStyle; + }, 3000); + } finally { + button.disabled = false; + } +} + +export async function handleInterruptWorkers(extension, button) { + return handleWorkerOperation(extension, button, { + name: "Interrupt", + endpoint: "/interrupt", + loadingText: "Interrupting...", + onSuccess: () => setTimeout(() => extension.checkAllWorkerStatuses(), 500) + }, "Interrupted!", "Error! See Console"); +} + +export async function handleClearMemory(extension, button) { + return handleWorkerOperation(extension, button, { + name: "Clear memory", + endpoint: "/distributed/clear_memory", + loadingText: "Clearing..." + }, "Success!", "Error! See Console"); +} + +export function findNodesByClass(apiPrompt, className) { + return Object.entries(apiPrompt) + .filter(([, nodeData]) => nodeData.class_type === className) + .map(([nodeId, nodeData]) => ({ id: nodeId, data: nodeData })); +} + +/** + * Find all image references in the workflow + * Looks for inputs named "image" that contain filename strings + */ +export function findImageReferences(extension, apiPrompt) { + const images = new Map(); + // Updated regex to handle: + // - Standard files: "image.png" + // - Subfolder files: "subfolder/image.png" + // - ComfyUI special format: "clipspace/file.png [input]" + const imageExtensions = /\.(png|jpg|jpeg|gif|webp|bmp)(\s*\[\w+\])?$/i; + + for (const [nodeId, node] of Object.entries(apiPrompt)) { + if (node.inputs && node.inputs.image) { + const imageValue = node.inputs.image; + if (typeof imageValue === 'string') { + // Clean special suffixes like [input] or [output] + const cleanValue = imageValue.replace(/\s*\[\w+\]$/, '').trim(); + if (imageExtensions.test(cleanValue)) { + images.set(cleanValue, { + nodeId, + nodeType: node.class_type, + inputName: 'image' + }); + extension.log(`Found image reference: ${cleanValue} in node ${nodeId} (${node.class_type})`, "debug"); + } + } + } + } + + return images; +} + +/** + * Find only upstream nodes (inputs) for distributed collector nodes + * This is used for workers to avoid executing downstream nodes like SaveImage + * @param {Object} apiPrompt - The API prompt containing the workflow + * @param {Array} collectorIds - Array of collector node IDs + * @returns {Set} Set of node IDs that feed into collectors + */ +export function findCollectorUpstreamNodes(apiPrompt, collectorIds) { + const connected = new Set(collectorIds); // Include all collectors + const toProcess = [...collectorIds]; + + // Only traverse upstream (inputs) + while (toProcess.length > 0) { + const nodeId = toProcess.pop(); + const node = apiPrompt[nodeId]; + + // Traverse upstream (inputs) only + if (node && node.inputs) { + for (const [inputName, inputValue] of Object.entries(node.inputs)) { + if (Array.isArray(inputValue) && inputValue.length === 2) { + const sourceNodeId = String(inputValue[0]); + if (!connected.has(sourceNodeId)) { + connected.add(sourceNodeId); + toProcess.push(sourceNodeId); + } + } + } + } + } + + return connected; +} + +/** + * Prune workflow to only include nodes connected to distributed nodes + * @param {Object} apiPrompt - The full workflow API prompt + * @param {Array} distributedNodes - Array of distributed nodes (optional, will find if not provided) + * @returns {Object} Pruned API prompt with only required nodes + */ +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"); + distributedNodes = [...collectorNodes, ...upscaleNodes]; + } + + if (distributedNodes.length === 0) { + // No distributed nodes, return full workflow + return apiPrompt; + } + + // Get all nodes connected to distributed nodes + const distributedIds = distributedNodes.map(node => node.id); + + // For workers, only include upstream nodes (this removes ALL downstream nodes after collectors) + const connectedNodes = findCollectorUpstreamNodes(apiPrompt, distributedIds); + + extension.log(`Pruning workflow: keeping ${connectedNodes.size} of ${Object.keys(apiPrompt).length} nodes (removed all downstream nodes)`, "debug"); + + // Create pruned prompt with only required nodes + const prunedPrompt = {}; + for (const nodeId of connectedNodes) { + prunedPrompt[nodeId] = JSON.parse(JSON.stringify(apiPrompt[nodeId])); + } + + // Check if any distributed node has downstream SaveImage nodes that were removed + // If so, add a PreviewImage node after the collector + for (const distNode of distributedNodes) { + const distNodeId = distNode.id; + + // Check if this distributed node had any downstream nodes in the original workflow + const originalOutputMap = new Map(); + for (const [nodeId, node] of Object.entries(apiPrompt)) { + if (node.inputs) { + for (const [inputName, inputValue] of Object.entries(node.inputs)) { + if (Array.isArray(inputValue) && inputValue.length === 2 && String(inputValue[0]) === distNodeId) { + if (!originalOutputMap.has(distNodeId)) { + originalOutputMap.set(distNodeId, []); + } + originalOutputMap.get(distNodeId).push({nodeId, inputName}); + } + } + } + } + + // If this distributed node had downstream nodes that were removed, add a PreviewImage + if (originalOutputMap.has(distNodeId) && originalOutputMap.get(distNodeId).length > 0) { + // Generate unique numeric ID: max existing numeric key +1 + const existingIds = Object.keys(prunedPrompt) + .filter(k => !isNaN(parseInt(k))) + .map(k => parseInt(k)); + const maxId = existingIds.length > 0 ? Math.max(...existingIds) : 0; + const previewNodeId = String(maxId + 1); + + // Add PreviewImage node connected to the distributed node + prunedPrompt[previewNodeId] = { + inputs: { + images: [distNodeId, 0] // Connect to first output of distributed node + }, + class_type: "PreviewImage", + _meta: { + title: "Preview Image (auto-added)" + } + }; + + extension.log(`Added PreviewImage node ${previewNodeId} after distributed node ${distNodeId} for worker`, "debug"); + } + } + + return prunedPrompt; +} + +/** + * Check if a node has an upstream node of a specific type + * @param {Object} apiPrompt - The workflow API prompt + * @param {string} nodeId - The node to check + * @param {string} upstreamType - The class_type to look for upstream + * @returns {boolean} True if an upstream node of the specified type exists + */ +export function hasUpstreamNode(apiPrompt, nodeId, upstreamType) { + const visited = new Set(); + const toProcess = [nodeId]; + + while (toProcess.length > 0) { + const currentId = toProcess.pop(); + if (visited.has(currentId)) continue; + visited.add(currentId); + + const node = apiPrompt[currentId]; + if (!node) continue; + + // Check inputs for upstream connections + if (node.inputs) { + for (const [inputName, inputValue] of Object.entries(node.inputs)) { + if (Array.isArray(inputValue) && inputValue.length === 2) { + const sourceNodeId = String(inputValue[0]); + const sourceNode = apiPrompt[sourceNodeId]; + + if (sourceNode && sourceNode.class_type === upstreamType) { + return true; + } + + if (!visited.has(sourceNodeId)) { + toProcess.push(sourceNodeId); + } + } + } + } + } + + return false; +} \ No newline at end of file