1333 lines
51 KiB
JavaScript
1333 lines
51 KiB
JavaScript
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);
|
|
|
|
const prettyKey = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
|
|
let detail;
|
|
if (key === 'worker_timeout_seconds') {
|
|
const secs = parseInt(value, 10);
|
|
detail = `Worker Timeout set to ${Number.isFinite(secs) ? secs : value}s`;
|
|
} else if (typeof value === 'boolean') {
|
|
detail = `${prettyKey} ${value ? 'enabled' : 'disabled'}`;
|
|
} else {
|
|
detail = `${prettyKey} set to ${value}`;
|
|
}
|
|
|
|
app.extensionManager.toast.add({
|
|
severity: "success",
|
|
summary: "Setting Updated",
|
|
detail,
|
|
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 removed
|
|
|
|
// --- 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();
|
|
}
|
|
});
|