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