Files

1336 lines
51 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
this.app = app; // Store app reference for toast notifications
// Initialize centralized state
this.state = createStateManager();
// Initialize UI component factory
this.ui = new DistributedUI();
// Initialize API client
this.api = createApiClient(window.location.origin);
// Initialize status check timeout reference
this.statusCheckTimeout = null;
// Initialize abort controller for status checks
this.statusCheckAbortController = null;
// Inject CSS for pulsing animation
this.injectStyles();
this.loadConfig().then(async () => {
this.registerSidebarTab();
this.setupInterceptor();
// Don't start polling until panel opens
// 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}`);
}
}
// Generate UUID with fallback for non-secure contexts
generateUUID() {
if (crypto.randomUUID) {
return crypto.randomUUID();
}
// Fallback for non-secure contexts
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = Math.random() * 16 | 0;
const v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
injectStyles() {
const styleId = 'distributed-styles';
if (!document.getElementById(styleId)) {
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), TIMEOUTS.STATUS_CHECK_DELAY);
} 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) => {
this.panelElement = el;
this.onPanelOpen();
return renderSidebarContent(this, el);
},
destroy: () => {
this.onPanelClose();
}
});
}
onPanelOpen() {
this.log("Panel opened - starting status polling", "debug");
if (!this.statusCheckTimeout) {
this.checkAllWorkerStatuses();
}
}
onPanelClose() {
this.log("Panel closed - stopping status polling", "debug");
// Cancel any pending status checks
if (this.statusCheckAbortController) {
this.statusCheckAbortController.abort();
this.statusCheckAbortController = null;
}
// Clear the timeout
if (this.statusCheckTimeout) {
clearTimeout(this.statusCheckTimeout);
this.statusCheckTimeout = null;
}
this.panelElement = null;
}
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() {
this.checkAllWorkerStatuses();
}
async checkAllWorkerStatuses() {
// Don't continue if panel is closed
if (!this.panelElement) return;
// Create new abort controller for this round of checks
this.statusCheckAbortController = new AbortController();
// Check master status
this.checkMasterStatus();
if (!this.config || !this.config.workers) return;
for (const worker of this.config.workers) {
// Check status for enabled workers OR workers that are launching
if (worker.enabled || this.state.isWorkerLaunching(worker.id)) {
this.checkWorkerStatus(worker);
}
}
// Determine next interval based on current state
let isActive = this.state.getMasterStatus() === 'processing'; // Master is busy
// Check workers for activity
this.config.workers.forEach(worker => {
const ws = this.state.getWorker(worker.id); // Get worker state
if (ws.launching || ws.status?.processing) { // Launching or processing
isActive = true;
}
});
// Set next delay: 1s if active, 5s if idle
const nextInterval = isActive ? 1000 : 5000;
// Schedule the next check
this.statusCheckTimeout = setTimeout(() => this.checkAllWorkerStatuses(), nextInterval);
}
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;
// Cloud workers always use HTTPS
const isCloud = worker.type === 'cloud';
// Detect if we're running on Runpod (for local workers on Runpod infrastructure)
const isRunpodProxy = host.endsWith('.proxy.runpod.net');
// For local workers on Runpod, construct the port-specific proxy URL
let finalHost = host;
if (!worker.host && isRunpodProxy) {
const match = host.match(/^(.*)\.proxy\.runpod\.net$/);
if (match) {
const podId = match[1];
const domain = 'proxy.runpod.net';
finalHost = `${podId}-${worker.port}.${domain}`;
} else {
// Fallback or log error if no match (shouldn't happen)
console.error(`[Distributed] Failed to parse Runpod proxy host: ${host}`);
}
}
// Determine protocol: HTTPS for cloud, Runpod proxies, or port 443
const useHttps = isCloud || isRunpodProxy || worker.port === 443;
const protocol = useHttps ? 'https' : 'http';
// Only add port if non-standard
const defaultPort = useHttps ? 443 : 80;
const needsPort = !isRunpodProxy && worker.port !== defaultPort;
const portStr = needsPort ? `:${worker.port}` : '';
return `${protocol}://${finalHost}${portStr}${endpoint}`;
}
async checkWorkerStatus(worker) {
// Assume caller ensured enabled; proceed with check
const url = this.getWorkerUrl(worker, '/prompt');
const statusDot = document.getElementById(`status-${worker.id}`);
try {
// Combine timeout with abort controller signal
const timeoutSignal = AbortSignal.timeout(TIMEOUTS.STATUS_CHECK);
const signal = this.statusCheckAbortController
? AbortSignal.any([timeoutSignal, this.statusCheckAbortController.signal])
: timeoutSignal;
const response = await fetch(url, {
method: 'GET',
mode: 'cors',
signal: signal
});
if (response.ok) {
const data = await response.json();
const queueRemaining = data.exec_info?.queue_remaining || 0;
const isProcessing = queueRemaining > 0;
// Update status
this.state.setWorkerStatus(worker.id, {
online: true,
processing: isProcessing,
queueCount: queueRemaining
});
// Update status dot based on processing state
if (isProcessing) {
this.ui.updateStatusDot(
worker.id,
"#f0ad4e",
`Online - Processing (${queueRemaining} in queue)`,
false
);
} else {
this.ui.updateStatusDot(worker.id, "#3ca03c", "Online - Idle", false);
}
// Clear launching state since worker is now online
if (this.state.isWorkerLaunching(worker.id)) {
this.state.setWorkerLaunching(worker.id, false);
this.clearLaunchingFlag(worker.id);
}
} else {
throw new Error(`HTTP ${response.status}`);
}
} catch (error) {
// Don't process aborted requests
if (error.name === 'AbortError') {
return;
}
// Worker is offline or unreachable
this.state.setWorkerStatus(worker.id, {
online: false,
processing: false,
queueCount: 0
});
// Check if worker is launching
if (this.state.isWorkerLaunching(worker.id)) {
this.ui.updateStatusDot(worker.id, "#f0ad4e", "Launching...", true);
} else if (worker.enabled) {
// Only update to red if not currently launching AND still enabled
this.ui.updateStatusDot(worker.id, "#c04c4c", "Offline - Cannot connect", false);
}
// If disabled, don't update the dot (leave it gray)
this.log(`Worker ${worker.id} status check failed: ${error.message}`, "debug");
}
// Update control buttons based on new status
this.updateWorkerControls(worker.id);
}
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);
}, TIMEOUTS.LAUNCH);
if (!launchBtn) return;
try {
// Disable button immediately
launchBtn.disabled = true;
const result = await this.api.launchWorker(workerId);
if (result) {
this.log(`Launched ${worker.name} (PID: ${result.pid})`, "info");
if (result.log_file) {
this.log(`Log file: ${result.log_file}`, "debug");
}
this.state.setWorkerManaged(workerId, {
pid: result.pid,
log_file: result.log_file,
started_at: Date.now()
});
// Update controls immediately to hide launch button and show stop/log buttons
this.updateWorkerControls(workerId);
setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK);
}
} catch (error) {
// Check if worker was already running
if (error.message && error.message.includes("already running")) {
this.log(`Worker ${worker.name} is already running`, "info");
this.updateWorkerControls(workerId);
setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK_DELAY);
} else {
this.log(`Error launching worker: ${error.message || error}`, "error");
// Re-enable button on error
if (launchBtn) {
launchBtn.disabled = false;
}
}
}
}
async stopWorker(workerId) {
const worker = this.config.workers.find(w => w.id === workerId);
const stopBtn = document.querySelectorAll(`#controls-${workerId} button`)[1];
// Provide immediate feedback
if (stopBtn) {
stopBtn.disabled = true;
stopBtn.textContent = "Stopping...";
stopBtn.style.backgroundColor = "#666";
}
try {
const result = await this.api.stopWorker(workerId);
if (result) {
this.log(`Stopped worker: ${result.message}`, "info");
this.state.setWorkerManaged(workerId, null);
// Immediately update status to offline
this.ui.updateStatusDot(workerId, "#c04c4c", "Offline");
this.state.setWorkerStatus(workerId, { online: false });
// Flash success feedback
if (stopBtn) {
stopBtn.style.backgroundColor = BUTTON_STYLES.success;
stopBtn.textContent = "Stopped!";
setTimeout(() => {
this.updateWorkerControls(workerId);
}, TIMEOUTS.FLASH_SHORT);
}
// Verify status after a short delay
setTimeout(() => this.checkWorkerStatus(worker), TIMEOUTS.STATUS_CHECK);
} else {
this.log(`Failed to stop worker: ${result.message}`, "error");
// Flash error feedback
if (stopBtn) {
stopBtn.style.backgroundColor = BUTTON_STYLES.error;
stopBtn.textContent = result.message.includes("already stopped") ? "Not Running" : "Failed";
// If already stopped, update status immediately
if (result.message.includes("already stopped")) {
this.ui.updateStatusDot(workerId, "#c04c4c", "Offline");
this.state.setWorkerStatus(workerId, { online: false });
}
setTimeout(() => {
this.updateWorkerControls(workerId);
}, TIMEOUTS.FLASH_MEDIUM);
}
}
} catch (error) {
this.log(`Error stopping worker: ${error}`, "error");
// Reset button on error
if (stopBtn) {
stopBtn.style.backgroundColor = BUTTON_STYLES.error;
stopBtn.textContent = "Error";
setTimeout(() => {
this.updateWorkerControls(workerId);
}, TIMEOUTS.FLASH_MEDIUM);
}
}
}
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 = TIMEOUTS.BUTTON_RESET) {
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;
}
if (this.statusCheckTimeout) {
clearTimeout(this.statusCheckTimeout);
this.statusCheckTimeout = 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
}, TIMEOUTS.FLASH_LONG);
}
}
}
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) {
// Check if explicitly marked as cloud worker
if (worker.type === "cloud") {
return true;
}
// Otherwise check by host (backward compatibility)
const host = worker.host || window.location.hostname;
return host !== "localhost" && host !== "127.0.0.1" && host !== window.location.hostname;
}
isCloudWorker(worker) {
return worker.type === "cloud";
}
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 {
// Detect if we're running on Runpod
const isRunpod = window.location.hostname.endsWith('.proxy.runpod.net');
if (isRunpod) {
this.log("Detected Runpod environment", "info");
}
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: isRunpod ? null : "localhost",
port: 8189 + portOffset,
cuda_device: i,
enabled: true,
extra_args: isRunpod ? "--listen" : ""
};
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");
// Show success notification
if (app.extensionManager?.toast) {
app.extensionManager.toast.add({
severity: "success",
summary: "Workers Auto-populated",
detail: `Automatically created ${newWorkers.length} workers based on detected CUDA devices`,
life: 5000
});
}
// 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;
}
// For Runpod, use the proxy hostname as master host
if (isRunpod) {
const runpodHost = window.location.hostname;
this.log(`Setting Runpod master host: ${runpodHost}`, "info");
// Save the Runpod host
await this.api.updateMaster({ host: runpodHost });
// Update local config
if (!this.config.master) this.config.master = {};
this.config.master.host = runpodHost;
// Show notification
if (app.extensionManager?.toast) {
app.extensionManager.toast.add({
severity: "info",
summary: "Runpod Auto-Configuration",
detail: `Master host set to ${runpodHost} with --listen flag for workers`,
life: 5000
});
}
return; // Skip regular IP detection for Runpod
}
// 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 });
// Update local config immediately
if (!this.config.master) this.config.master = {};
this.config.master.host = data.recommended_ip;
}
} 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 workerType = document.getElementById(`worker-type-${workerId}`).value;
const isRemote = workerType === 'remote' || workerType === 'cloud';
const isCloud = workerType === 'cloud';
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 ((workerType === 'remote' || workerType === 'cloud') && !host.trim()) {
app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail: "Host is required for remote workers",
life: 3000
});
return;
}
if (!isCloud && (isNaN(port) || port < 1 || port > 65535)) {
app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
detail: "Port must be between 1 and 65535",
life: 3000
});
return;
}
// Check for port conflicts
// Remote workers can reuse ports, but local workers cannot share ports with each other or master
if (!isRemote) {
// Check if port conflicts with master
const masterPort = parseInt(window.location.port) || (window.location.protocol === 'https:' ? 443 : 80);
if (port === masterPort) {
app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by the master server`,
life: 3000
});
return;
}
// Check if port conflicts with other local workers
const localPortConflict = this.config.workers.some(w =>
w.id !== workerId &&
w.port === port &&
!w.host // local workers have no host or host is null
);
if (localPortConflict) {
app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by another local worker`,
life: 3000
});
return;
}
} else {
// For remote workers, only check conflicts with other workers on the same host
const sameHostConflict = this.config.workers.some(w =>
w.id !== workerId &&
w.port === port &&
w.host === host.trim()
);
if (sameHostConflict) {
app.extensionManager.toast.add({
severity: "error",
summary: "Port Conflict",
detail: `Port ${port} is already in use by another worker on ${host}`,
life: 3000
});
return;
}
}
try {
await this.api.updateWorker(workerId, {
name: name.trim(),
type: workerType,
host: isRemote ? host.trim() : null,
port: port,
cuda_device: isRemote ? null : cudaDevice,
extra_args: isRemote ? null : (extraArgs ? extraArgs.trim() : "")
});
// Update local config
worker.name = name.trim();
worker.type = workerType;
if (isRemote) {
worker.host = host.trim();
delete worker.cuda_device;
delete worker.extra_args;
} else {
delete worker.host;
worker.cuda_device = cudaDevice;
worker.extra_args = extraArgs ? extraArgs.trim() : "";
}
worker.port = port;
// Sync to state
this.state.updateWorker(workerId, { enabled: worker.enabled });
app.extensionManager.toast.add({
severity: "success",
summary: "Settings Saved",
detail: `Worker ${name} settings updated`,
life: 3000
});
// Refresh the UI
if (this.panelElement) {
renderSidebarContent(this, this.panelElement);
}
} catch (error) {
app.extensionManager.toast.add({
severity: "error",
summary: "Save Failed",
detail: error.message,
life: 5000
});
}
}
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 (fallback for non-secure contexts)
const newId = this.generateUUID();
// Find next available port
const usedPorts = this.config.workers.map(w => w.port);
let nextPort = 8189;
while (usedPorts.includes(nextPort)) {
nextPort++;
}
// Create new worker object
const newWorker = {
id: newId,
name: `Worker ${this.config.workers.length + 1}`,
port: nextPort,
cuda_device: this.config.workers.length,
enabled: true, // Default to enabled for convenience
extra_args: ""
};
// Add to config
this.config.workers.push(newWorker);
// Save immediately
try {
await this.api.updateWorker(newId, {
name: newWorker.name,
port: newWorker.port,
cuda_device: newWorker.cuda_device,
extra_args: newWorker.extra_args,
enabled: newWorker.enabled
});
// Sync to state
this.state.updateWorker(newId, { enabled: true });
app.extensionManager.toast.add({
severity: "success",
summary: "Worker Added",
detail: `New worker created on port ${nextPort}`,
life: 3000
});
// Refresh UI and expand the new worker
this.state.setWorkerExpanded(newId, true);
if (this.panelElement) {
renderSidebarContent(this, this.panelElement);
}
} catch (error) {
app.extensionManager.toast.add({
severity: "error",
summary: "Failed to Add Worker",
detail: error.message,
life: 5000
});
}
}
startLogAutoRefresh(workerId) {
// Stop any existing auto-refresh
this.stopLogAutoRefresh();
// Refresh every 2 seconds
this.logAutoRefreshInterval = setInterval(() => {
this.refreshLog(workerId, true); // silent mode
}, TIMEOUTS.LOG_REFRESH);
}
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();
}
});