Refine worker UI theme behavior and remote fallback add flow

This commit is contained in:
Robert Wojciechowski
2026-02-28 04:53:20 +00:00
parent 243086391d
commit 2cf3d717d1
14 changed files with 824 additions and 88 deletions
+9 -6
View File
@@ -133,8 +133,9 @@ export const UI_STYLES = {
statusDot: "display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 10px;",
controlsDiv: "padding: 0 12px 12px 12px; display: flex; gap: 6px;",
formGroup: "display: flex; flex-direction: column; gap: 5px;",
formLabel: "font-size: 12px; color: #ccc; font-weight: 500;",
formInput: "padding: 6px 10px; color: white; font-size: 12px; transition: border-color 0.2s;",
formLabel: "font-size: 12px; color: var(--dist-label-text, #ccc); font-weight: 500;",
formInput:
"padding: 6px 10px; color: var(--dist-input-text, white); background: var(--dist-input-bg, transparent); font-size: 12px; transition: border-color 0.2s;",
// Card styles
cardBase: "margin-bottom: 12px; overflow: hidden; display: flex;",
@@ -146,7 +147,7 @@ export const UI_STYLES = {
columnBase: "display: flex; align-items: center; justify-content: center;",
checkboxColumn: "flex: 0 0 44px; display: flex; align-items: center; justify-content: center; cursor: default;",
contentColumn: "flex: 1; display: flex; flex-direction: column; transition: background-color 0.2s ease;",
iconColumn: "width: 44px; flex-shrink: 0; font-size: 20px; color: #666;",
iconColumn: "width: 44px; flex-shrink: 0; font-size: 20px; color: var(--dist-placeholder-add-color, #666);",
// Row and content styles
infoRow: "display: flex; align-items: center; padding: 12px; cursor: pointer; min-height: 64px;",
@@ -156,13 +157,15 @@ export const UI_STYLES = {
buttonGroup: "display: flex; gap: 4px; margin-top: 10px;",
settingsForm: "display: flex; flex-direction: column; gap: 10px;",
checkboxGroup: "display: flex; align-items: center; gap: 8px; margin: 5px 0;",
formLabelClickable: "font-size: 12px; color: #ccc; cursor: pointer;",
formLabelClickable: "font-size: 12px; color: var(--dist-label-text, #ccc); cursor: pointer;",
settingsToggle: "display: flex; align-items: center; gap: 6px; padding: 4px 0; cursor: pointer; user-select: none;",
controlsWrapper: "display: flex; gap: 6px; align-items: stretch; width: 100%;",
// Existing styles
settingsArrow: "font-size: 12px; color: #888; transition: all 0.2s ease; margin-left: auto; padding: 4px;",
infoBox: "color: #999; padding: 5px 14px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;",
settingsArrow:
"font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease; margin-left: auto; padding: 4px;",
infoBox:
"color: var(--dist-info-box-text, #999); padding: 5px 14px; font-size: 11px; text-align: center; flex: 1; font-weight: 500;",
workerSettings: "margin: 0 12px; padding: 0 12px;"
};
+140 -34
View File
@@ -12,6 +12,90 @@
--master-badge-fallback-border: #335533;
--master-badge-delegate-bg: #3a3a3a;
--master-badge-delegate-text: #ffcc66;
--dist-divider: #444;
--dist-muted-text: #888;
--dist-label-text: #ccc;
--dist-settings-arrow: #888;
--dist-settings-arrow-hover: #fff;
--dist-card-bg: #2a2a2a;
--dist-card-title: #f4f5f7;
--dist-card-subtext: #a9afb9;
--dist-card-placeholder-title: #aaa;
--dist-left-col-border: #3a3a3a;
--dist-left-col-bg: rgba(0, 0, 0, 0.1);
--dist-info-box-bg: #333;
--dist-info-box-text: #999;
--dist-input-bg: #2a2a2a;
--dist-input-border: #444;
--dist-input-text: #fff;
--dist-settings-bg: #1e1e1e;
--dist-settings-border: #2a2a2a;
--dist-hover-bg: #333;
--dist-placeholder-blueprint-border: #555;
--dist-placeholder-blueprint-hover-border: #777;
--dist-placeholder-add-border: #444;
--dist-placeholder-add-hover-border: #666;
--dist-placeholder-blueprint-bg: rgba(255, 255, 255, 0.02);
--dist-placeholder-blueprint-hover-bg: rgba(255, 255, 255, 0.05);
--dist-placeholder-add-hover-bg: rgba(255, 255, 255, 0.02);
--dist-placeholder-blueprint-color: #777;
--dist-placeholder-blueprint-hover-color: #999;
--dist-placeholder-add-color: #555;
--dist-placeholder-add-hover-color: #888;
--dist-log-modal-bg: #1e1e1e;
--dist-log-modal-border: #444;
--dist-log-modal-header-border: #444;
--dist-log-modal-title: #fff;
--dist-log-modal-label: #ccc;
--dist-log-modal-body-bg: #0d0d0d;
--dist-log-modal-body-text: #ddd;
--dist-log-modal-status: #888;
}
.distributed-panel--light {
--dist-divider: #c7ced9;
--dist-muted-text: #5b6472;
--dist-label-text: #2f3a4a;
--dist-settings-arrow: #6b7483;
--dist-settings-arrow-hover: #253040;
--dist-card-bg: #f6f8fb;
--dist-card-title: #1e293b;
--dist-card-subtext: #4b5565;
--dist-card-placeholder-title: #3f4a5a;
--dist-left-col-border: #d8deea;
--dist-left-col-bg: #eef2f8;
--dist-info-box-bg: #e8edf5;
--dist-info-box-text: #4f5b6c;
--dist-input-bg: #ffffff;
--dist-input-border: #b8c2d3;
--dist-input-text: #1f2937;
--dist-settings-bg: #eef2f8;
--dist-settings-border: #d5dcea;
--dist-hover-bg: #e6ebf3;
--dist-placeholder-blueprint-border: #aeb8c9;
--dist-placeholder-blueprint-hover-border: #8e9ab0;
--dist-placeholder-add-border: #b7c1d3;
--dist-placeholder-add-hover-border: #919eb4;
--dist-placeholder-blueprint-bg: rgba(80, 100, 140, 0.05);
--dist-placeholder-blueprint-hover-bg: rgba(80, 100, 140, 0.08);
--dist-placeholder-add-hover-bg: rgba(80, 100, 140, 0.05);
--dist-placeholder-blueprint-color: #5f6c82;
--dist-placeholder-blueprint-hover-color: #47556e;
--dist-placeholder-add-color: #6a7588;
--dist-placeholder-add-hover-color: #4c586f;
--master-badge-delegate-bg: #efe7ce;
--master-badge-delegate-text: #6a4f00;
--master-badge-fallback-bg: #dff1df;
--master-badge-fallback-text: #1f5f1f;
--master-badge-fallback-border: #9fc79f;
--dist-log-modal-bg: #f9fbff;
--dist-log-modal-border: #bcc7db;
--dist-log-modal-header-border: #c7d1e3;
--dist-log-modal-title: #1f2937;
--dist-log-modal-label: #4a5568;
--dist-log-modal-body-bg: #f2f5fb;
--dist-log-modal-body-text: #1f2937;
--dist-log-modal-status: #5c6779;
}
.btn--stop {
@@ -50,45 +134,47 @@
}
.entity-card-content--hovered {
background-color: #333 !important;
background-color: var(--dist-hover-bg) !important;
}
.placeholder-card--blueprint {
border-color: #555 !important;
background-color: rgba(255, 255, 255, 0.02) !important;
border-color: var(--dist-placeholder-blueprint-border) !important;
background-color: var(--dist-placeholder-blueprint-bg) !important;
}
.placeholder-card--blueprint.is-hovered {
border-color: #777 !important;
background-color: rgba(255, 255, 255, 0.05) !important;
border-color: var(--dist-placeholder-blueprint-hover-border) !important;
background-color: var(--dist-placeholder-blueprint-hover-bg) !important;
}
.placeholder-card--add {
border-color: #444 !important;
border-color: var(--dist-placeholder-add-border) !important;
background-color: transparent !important;
}
.placeholder-card--add.is-hovered {
border-color: #666 !important;
background-color: rgba(255, 255, 255, 0.02) !important;
border-color: var(--dist-placeholder-add-hover-border) !important;
background-color: var(--dist-placeholder-add-hover-bg) !important;
}
.placeholder-column--blueprint {
color: #777 !important;
color: var(--dist-placeholder-blueprint-color) !important;
border-right-color: var(--dist-placeholder-blueprint-border) !important;
}
.placeholder-column--blueprint.is-hovered {
color: #999 !important;
color: var(--dist-placeholder-blueprint-hover-color) !important;
}
.placeholder-column--add {
color: #555 !important;
border-color: #444 !important;
color: var(--dist-placeholder-add-color) !important;
border-color: var(--dist-placeholder-add-border) !important;
border-right-color: var(--dist-placeholder-add-border) !important;
}
.placeholder-column--add.is-hovered {
color: #888 !important;
border-color: #666 !important;
color: var(--dist-placeholder-add-hover-color) !important;
border-color: var(--dist-placeholder-add-hover-border) !important;
}
.tunnel-button--enable {
@@ -110,42 +196,62 @@
/* ---- Themeable card/column/input defaults (Classic) ---- */
.dist-card {
background: #2a2a2a;
background: var(--dist-card-bg);
color: var(--dist-card-title);
border-radius: 6px;
}
.dist-card--blueprint {
border: 2px dashed #555;
background: rgba(255, 255, 255, 0.02);
border: 2px dashed var(--dist-placeholder-blueprint-border);
background: var(--dist-placeholder-blueprint-bg);
}
.dist-card--add {
border: 1px dashed #444;
border: 1px dashed var(--dist-placeholder-add-border);
background: transparent;
}
.dist-card__left-col {
border-right: 1px solid #3a3a3a;
background: rgba(0, 0, 0, 0.1);
border-right: 1px solid var(--dist-left-col-border);
background: var(--dist-left-col-bg);
}
.dist-info-box {
background-color: #333;
background-color: var(--dist-info-box-bg);
color: var(--dist-info-box-text);
border-radius: 4px;
}
.dist-form-input {
background: #2a2a2a;
border: 1px solid #444;
background: var(--dist-input-bg);
border: 1px solid var(--dist-input-border);
color: var(--dist-input-text);
border-radius: 4px;
}
.worker-settings {
background: #1e1e1e;
border: 1px solid #2a2a2a;
background: var(--dist-settings-bg);
border: 1px solid var(--dist-settings-border);
border-radius: 4px;
}
.dist-worker-info__title {
color: var(--dist-card-title) !important;
font-size: 1.03em;
font-weight: 700;
}
.dist-worker-info__meta {
color: var(--dist-card-subtext) !important;
font-size: 0.88em;
}
.dist-worker-info__fallback {
color: var(--master-badge-fallback-text) !important;
font-size: 0.86em;
font-weight: 600;
}
/* ---- Nodes 2.0 Theme ---- */
.distributed-panel--nodes2 .dist-card {
@@ -235,19 +341,19 @@
}
.log-modal__content {
background: #1e1e1e;
background: var(--dist-log-modal-bg);
border-radius: 8px;
width: 90%;
max-width: 1200px;
height: 80%;
display: flex;
flex-direction: column;
border: 1px solid #444;
border: 1px solid var(--dist-log-modal-border);
}
.log-modal__header {
padding: 15px 20px;
border-bottom: 1px solid #444;
border-bottom: 1px solid var(--dist-log-modal-header-border);
display: flex;
justify-content: space-between;
align-items: center;
@@ -255,7 +361,7 @@
.log-modal__title {
margin: 0;
color: #fff;
color: var(--dist-log-modal-title);
}
.log-modal__header-buttons {
@@ -276,7 +382,7 @@
.log-modal__refresh-label {
font-size: 12px;
color: #ccc;
color: var(--dist-log-modal-label);
cursor: pointer;
white-space: nowrap;
}
@@ -296,15 +402,15 @@
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 12px;
line-height: 1.4;
color: #ddd;
background: #0d0d0d;
color: var(--dist-log-modal-body-text);
background: var(--dist-log-modal-body-bg);
white-space: pre-wrap;
word-wrap: break-word;
}
.log-modal__status {
padding: 10px 20px;
border-top: 1px solid #444;
border-top: 1px solid var(--dist-log-modal-header-border);
font-size: 11px;
color: #888;
color: var(--dist-log-modal-status);
}
+316
View File
@@ -0,0 +1,316 @@
import { app } from "/scripts/app.js";
import { ENDPOINTS } from "./constants.js";
const NODE_CLASS = "DistributedValue";
const WORKER_WIDGET_PREFIX = "_dv_worker_";
// ---------------------------------------------------------------------------
// Config helpers
// ---------------------------------------------------------------------------
async function fetchWorkers() {
try {
const resp = await fetch(ENDPOINTS.CONFIG);
if (!resp.ok) return [];
const config = await resp.json();
return config.workers || [];
} catch {
return [];
}
}
// ---------------------------------------------------------------------------
// Hidden widget accessors (worker_values JSON storage)
// ---------------------------------------------------------------------------
function getWorkerValuesWidget(node) {
return node.widgets?.find((w) => w.name === "worker_values");
}
function getHiddenWidget(node) {
return node._dvHiddenWidget || getWorkerValuesWidget(node);
}
function getWorkerWidgets(node) {
return (node.widgets || []).filter((w) => w.name.startsWith(WORKER_WIDGET_PREFIX));
}
function readStore(node) {
const w = getHiddenWidget(node);
if (!w) return {};
try {
const parsed = JSON.parse(w.value || "{}");
return typeof parsed === "object" && parsed !== null ? parsed : {};
} catch {
return {};
}
}
function writeStore(node, patch) {
const w = getHiddenWidget(node);
if (!w) return;
const store = readStore(node);
Object.assign(store, patch);
w.value = JSON.stringify(store);
}
function serializeWorkerValues(node) {
const store = readStore(node);
// Keep metadata keys (_type, _options) but overwrite worker values
const newStore = { _type: store._type || "STRING" };
if (store._options) newStore._options = store._options;
for (const w of getWorkerWidgets(node)) {
const key = w.name.slice(WORKER_WIDGET_PREFIX.length);
const val = w.value;
if (val !== undefined && val !== null && val !== "") {
newStore[key] = String(val);
}
}
const hidden = getHiddenWidget(node);
if (hidden) hidden.value = JSON.stringify(newStore);
}
// ---------------------------------------------------------------------------
// Hide the raw worker_values widget
// ---------------------------------------------------------------------------
function hideRawWidget(node) {
const w = getWorkerValuesWidget(node);
if (!w) return;
// Hide any DOM elements (multiline creates a textarea)
if (w.element) w.element.style.display = "none";
if (w.inputEl) w.inputEl.style.display = "none";
// Remove from visible widgets array
const idx = node.widgets.indexOf(w);
if (idx !== -1) node.widgets.splice(idx, 1);
// Keep reference
node._dvHiddenWidget = w;
}
// ---------------------------------------------------------------------------
// Connection type detection
// ---------------------------------------------------------------------------
function detectTargetType(node) {
// Find what our output (slot 0) is connected to
if (!node.outputs || !node.outputs[0] || !node.outputs[0].links) {
return { type: "STRING", options: null };
}
const linkIds = node.outputs[0].links;
if (!linkIds || linkIds.length === 0) {
return { type: "STRING", options: null };
}
const graph = node.graph || app.graph;
if (!graph) return { type: "STRING", options: null };
// Use the first connection to determine type
const linkId = linkIds[0];
const link = graph.links?.[linkId] || graph._links?.get?.(linkId);
if (!link) return { type: "STRING", options: null };
const targetNode = graph.getNodeById(link.target_id);
if (!targetNode) return { type: "STRING", options: null };
const targetInputName = targetNode.inputs?.[link.target_slot]?.name;
if (!targetInputName) return { type: "STRING", options: null };
// Find the corresponding widget on the target node to get type info
const targetWidget = targetNode.widgets?.find((w) => w.name === targetInputName);
if (!targetWidget) {
// Check node definition for input type
const nodeDef = targetNode.constructor?.nodeData;
if (nodeDef) {
const inputDef =
nodeDef.input?.required?.[targetInputName] ||
nodeDef.input?.optional?.[targetInputName];
if (inputDef) {
if (Array.isArray(inputDef[0])) {
return { type: "COMBO", options: inputDef[0] };
}
const typeName = inputDef[0];
if (typeName === "INT") return { type: "INT", options: null };
if (typeName === "FLOAT") return { type: "FLOAT", options: null };
}
}
return { type: "STRING", options: null };
}
// Detect type from widget
if (targetWidget.type === "combo") {
const options = targetWidget.options?.values || [];
return { type: "COMBO", options };
}
if (targetWidget.type === "number") {
// Distinguish INT vs FLOAT from the widget options
const step = targetWidget.options?.step;
if (step !== undefined && step % 1 === 0 && (!targetWidget.options?.precision || targetWidget.options.precision === 0)) {
return { type: "INT", options: null };
}
return { type: "FLOAT", options: null };
}
return { type: "STRING", options: null };
}
// ---------------------------------------------------------------------------
// Worker widget creation
// ---------------------------------------------------------------------------
function removeWorkerWidgets(node) {
for (let i = node.widgets.length - 1; i >= 0; i--) {
if (node.widgets[i].name.startsWith(WORKER_WIDGET_PREFIX)) {
node.widgets.splice(i, 1);
}
}
}
function createWorkerWidgets(node, workers, inputType, comboOptions) {
removeWorkerWidgets(node);
const store = readStore(node);
for (let i = 0; i < workers.length; i++) {
const worker = workers[i];
const key = String(i + 1);
const widgetName = `${WORKER_WIDGET_PREFIX}${key}`;
const label = worker.name || worker.id || `Worker ${key}`;
const savedValue = store[key] || "";
let widget;
if (inputType === "COMBO" && Array.isArray(comboOptions) && comboOptions.length > 0) {
widget = node.addWidget(
"combo",
widgetName,
savedValue || comboOptions[0],
(value) => {
widget.value = value;
serializeWorkerValues(node);
},
{ values: comboOptions }
);
} else if (inputType === "INT") {
widget = node.addWidget(
"number",
widgetName,
savedValue ? Number(savedValue) : 0,
(value) => {
widget.value = value;
serializeWorkerValues(node);
},
{ min: -Infinity, max: Infinity, step: 1, precision: 0 }
);
} else if (inputType === "FLOAT") {
widget = node.addWidget(
"number",
widgetName,
savedValue ? Number(savedValue) : 0.0,
(value) => {
widget.value = value;
serializeWorkerValues(node);
},
{ min: -Infinity, max: Infinity, step: 0.1, precision: 3 }
);
} else {
// STRING (default)
widget = node.addWidget(
"string",
widgetName,
savedValue,
(value) => {
widget.value = value;
serializeWorkerValues(node);
},
{}
);
}
widget.label = label;
}
// Persist type metadata
const patch = { _type: inputType };
if (comboOptions) patch._options = comboOptions;
writeStore(node, patch);
// Re-serialize worker values after creating widgets
serializeWorkerValues(node);
// Resize node to fit
const size = node.computeSize();
size[0] = Math.max(size[0], node.size?.[0] || 0);
node.setSize(size);
if (node.setDirtyCanvas) node.setDirtyCanvas(true, true);
}
// ---------------------------------------------------------------------------
// Rebuild widgets based on current connection
// ---------------------------------------------------------------------------
function rebuildWidgets(node, workers) {
const { type, options } = detectTargetType(node);
createWorkerWidgets(node, workers, type, options);
}
// ---------------------------------------------------------------------------
// Extension registration
// ---------------------------------------------------------------------------
app.registerExtension({
name: "Distributed.DistributedValue",
async nodeCreated(node) {
if (node.comfyClass !== NODE_CLASS) return;
try {
const workers = await fetchWorkers();
node._dvWorkers = workers;
// Hide the raw worker_values widget
hideRawWidget(node);
// Read stored type or detect from connection
const store = readStore(node);
const storedType = store._type || "STRING";
const storedOptions = store._options || null;
if (workers.length > 0) {
createWorkerWidgets(node, workers, storedType, storedOptions);
}
// Listen for connection changes to adapt widget type
const originalOnConnectionsChange = node.onConnectionsChange;
node.onConnectionsChange = function (side, slotIndex, connected, linkInfo, ioSlot) {
if (originalOnConnectionsChange) {
originalOnConnectionsChange.call(this, side, slotIndex, connected, linkInfo, ioSlot);
}
// side 2 = output; slot 0 = our "value" output
if (slotIndex === 0 && this._dvWorkers?.length > 0) {
// Small delay to let litegraph finish link setup
setTimeout(() => rebuildWidgets(this, this._dvWorkers), 50);
}
};
// Override configure for workflow load
const originalConfigure = node.configure;
node.configure = function (data) {
const result = originalConfigure
? originalConfigure.call(this, data)
: undefined;
setTimeout(() => {
hideRawWidget(this);
if (this._dvWorkers?.length > 0) {
const s = readStore(this);
createWorkerWidgets(this, this._dvWorkers, s._type || "STRING", s._options || null);
}
}, 100);
return result;
};
} catch (error) {
console.error("Error in DistributedValue extension:", error);
}
},
});
+102
View File
@@ -37,6 +37,7 @@ class DistributedExtension {
// Initialize abort controller for status checks
this.statusCheckAbortController = null;
this.themeMutationObserver = null;
// Inject CSS for pulsing animation
this.injectStyles();
@@ -264,6 +265,7 @@ class DistributedExtension {
this.onPanelOpen();
renderSidebarContent(this, el);
this._applyNodes2Style();
this._applyThemeToneClass();
},
destroy: () => {
this.onPanelClose();
@@ -276,6 +278,8 @@ class DistributedExtension {
if (!this.statusCheckTimeout) {
checkAllWorkerStatuses(this);
}
this._startThemeObserver();
this._applyThemeToneClass();
}
onPanelClose() {
@@ -292,6 +296,7 @@ class DistributedExtension {
clearTimeout(this.statusCheckTimeout);
this.statusCheckTimeout = null;
}
this._stopThemeObserver();
this.panelElement = null;
}
@@ -302,11 +307,108 @@ class DistributedExtension {
this.panelElement.classList.toggle('distributed-panel--nodes2', Boolean(enabled));
}
_parseColorToRgba(colorValue) {
if (!colorValue || typeof colorValue !== "string") {
return null;
}
const color = colorValue.trim().toLowerCase();
if (!color || color === "transparent") {
return null;
}
const rgbMatch = color.match(/^rgba?\(([^)]+)\)$/);
if (rgbMatch) {
const parts = rgbMatch[1].split(",").map((part) => Number(part.trim()));
if (parts.length < 3 || parts.slice(0, 3).some((part) => Number.isNaN(part))) {
return null;
}
const alpha = parts.length >= 4 && Number.isFinite(parts[3]) ? parts[3] : 1;
return {
r: Math.max(0, Math.min(255, parts[0])),
g: Math.max(0, Math.min(255, parts[1])),
b: Math.max(0, Math.min(255, parts[2])),
a: Math.max(0, Math.min(1, alpha)),
};
}
const hexMatch = color.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
if (hexMatch) {
const value = hexMatch[1];
const expanded = value.length === 3
? value.split("").map((c) => `${c}${c}`).join("")
: value;
const r = parseInt(expanded.slice(0, 2), 16);
const g = parseInt(expanded.slice(2, 4), 16);
const b = parseInt(expanded.slice(4, 6), 16);
return { r, g, b, a: 1 };
}
return null;
}
_isPanelLightTheme() {
const fallbackLight = window.matchMedia?.("(prefers-color-scheme: light)")?.matches || false;
if (!this.panelElement) {
return fallbackLight;
}
let current = this.panelElement;
while (current) {
const bg = getComputedStyle(current).backgroundColor;
const rgba = this._parseColorToRgba(bg);
if (rgba && rgba.a > 0.02) {
// Relative luminance approximation (0..1)
const luminance = (0.2126 * rgba.r + 0.7152 * rgba.g + 0.0722 * rgba.b) / 255;
return luminance > 0.58;
}
current = current.parentElement;
}
return fallbackLight;
}
_applyThemeToneClass() {
if (!this.panelElement) {
return;
}
this.panelElement.classList.toggle("distributed-panel--light", this._isPanelLightTheme());
}
_startThemeObserver() {
if (this.themeMutationObserver) {
return;
}
this.themeMutationObserver = new MutationObserver(() => {
this._applyThemeToneClass();
});
this.themeMutationObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class", "style"],
});
if (document.body) {
this.themeMutationObserver.observe(document.body, {
attributes: true,
attributeFilter: ["class", "style"],
});
}
}
_stopThemeObserver() {
if (!this.themeMutationObserver) {
return;
}
this.themeMutationObserver.disconnect();
this.themeMutationObserver = null;
}
_setupNodes2Listener() {
app.ui.settings.addEventListener("Comfy.VueNodes.Enabled.change", (e) => {
const enabled = e.detail?.value ?? false;
if (this.panelElement) {
this.panelElement.classList.toggle('distributed-panel--nodes2', Boolean(enabled));
this._applyThemeToneClass();
}
});
}
+2 -1
View File
@@ -2,7 +2,8 @@ import { BUTTON_STYLES } from "../constants.js";
export function renderActionsSection(extension) {
const actionsSection = document.createElement("div");
actionsSection.style.cssText = "padding-top: 10px; margin-bottom: 15px; border-top: 1px solid #444;";
actionsSection.style.cssText =
"padding-top: 10px; margin-bottom: 15px; border-top: 1px solid var(--dist-divider, #444);";
const buttonRow = document.createElement("div");
buttonRow.style.cssText = "display: flex; gap: 8px;";
+8 -7
View File
@@ -2,7 +2,7 @@ import { createCheckboxSetting, createNumberSetting } from "../ui/buttonHelpers.
export function renderSettingsSection(extension) {
const settingsSection = document.createElement("div");
settingsSection.style.cssText = "border-top: 1px solid #444; margin-bottom: 10px;";
settingsSection.style.cssText = "border-top: 1px solid var(--dist-divider, #444); margin-bottom: 10px;";
const settingsToggleArea = document.createElement("div");
settingsToggleArea.style.cssText = "padding: 16.5px 0; cursor: pointer; user-select: none;";
@@ -16,21 +16,22 @@ export function renderSettingsSection(extension) {
const workerSettingsToggle = document.createElement("span");
workerSettingsToggle.textContent = "▶";
workerSettingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;";
workerSettingsToggle.style.cssText =
"font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease;";
settingsHeader.appendChild(workerSettingsTitle);
settingsHeader.appendChild(workerSettingsToggle);
settingsToggleArea.appendChild(settingsHeader);
settingsToggleArea.onmouseover = () => {
workerSettingsToggle.style.color = "#fff";
workerSettingsToggle.style.color = "var(--dist-settings-arrow-hover, #fff)";
};
settingsToggleArea.onmouseout = () => {
workerSettingsToggle.style.color = "#888";
workerSettingsToggle.style.color = "var(--dist-settings-arrow, #888)";
};
const settingsSeparator = document.createElement("div");
settingsSeparator.style.cssText = "border-bottom: 1px solid #444; margin: 0;";
settingsSeparator.style.cssText = "border-bottom: 1px solid var(--dist-divider, #444); margin: 0;";
const settingsContent = document.createElement("div");
settingsContent.style.cssText =
@@ -59,12 +60,12 @@ export function renderSettingsSection(extension) {
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;";
"grid-column: 1 / span 2; font-size: 11px; color: var(--dist-muted-text, #888); letter-spacing: 0.06em; padding-top: 2px;";
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;";
"grid-column: 1 / span 2; font-size: 11px; color: var(--dist-muted-text, #888); letter-spacing: 0.06em; padding-top: 4px;";
settingsDiv.appendChild(generalLabel);
settingsDiv.appendChild(
+6 -3
View File
@@ -52,8 +52,9 @@ export async function renderSidebarContent(extension, el) {
// Show loading indicator
el.innerHTML = '';
const loadingDiv = document.createElement("div");
loadingDiv.style.cssText = "display: flex; align-items: center; justify-content: center; height: calc(100vh - 100px); color: #888;";
loadingDiv.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" style="color: #888;">
loadingDiv.style.cssText =
"display: flex; align-items: center; justify-content: center; height: calc(100vh - 100px); color: var(--dist-muted-text, #888);";
loadingDiv.innerHTML = `<svg width="24" height="24" viewBox="0 0 24 24" style="color: var(--dist-muted-text, #888);">
<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);
@@ -74,7 +75,8 @@ export async function renderSidebarContent(extension, el) {
// 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;";
toolbar.style.cssText =
"border-bottom: 1px solid var(--dist-divider, #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;";
@@ -107,6 +109,7 @@ export async function renderSidebarContent(extension, el) {
container.appendChild(renderActionsSection(extension));
container.appendChild(renderSettingsSection(extension));
el.appendChild(container);
extension._applyThemeToneClass?.();
// Start checking worker statuses immediately in parallel
setTimeout(() => checkAllWorkerStatuses(extension), 0);
} finally {
+122
View File
@@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { addNewWorker, isRemoteWorker } from "../workerSettings.js";
describe("workerSettings remote classification", () => {
let originalWindow;
beforeEach(() => {
originalWindow = globalThis.window;
globalThis.window = {
location: {
hostname: "127.0.0.1",
},
};
});
afterEach(() => {
globalThis.window = originalWindow;
});
it("treats explicit local worker type as local even with non-local host", () => {
const worker = { type: "local", host: "192.168.1.50" };
expect(isRemoteWorker({}, worker)).toBe(false);
});
it("treats explicit remote worker type as remote", () => {
const worker = { type: "remote", host: "127.0.0.1" };
expect(isRemoteWorker({}, worker)).toBe(true);
});
it("treats cloud worker type as remote", () => {
const worker = { type: "cloud", host: "worker.example.com" };
expect(isRemoteWorker({}, worker)).toBe(true);
});
it("falls back to host heuristic for legacy workers", () => {
expect(isRemoteWorker({}, { host: "127.0.0.1" })).toBe(false);
expect(isRemoteWorker({}, { host: "worker.example.com" })).toBe(true);
});
});
describe("addNewWorker GPU availability guard", () => {
it("falls back to a disabled remote worker when no local CUDA device is available", async () => {
const toastAdd = vi.fn();
const updateWorker = vi.fn().mockResolvedValue({});
const stateUpdateWorker = vi.fn();
const extension = {
cudaDeviceCount: 1,
masterCudaDevice: 0,
panelElement: null,
config: {
workers: [],
master: { cuda_device: 0 },
},
api: { updateWorker },
state: { updateWorker: stateUpdateWorker, setWorkerExpanded: vi.fn() },
app: { extensionManager: { toast: { add: toastAdd } } },
};
await addNewWorker(extension);
expect(updateWorker).toHaveBeenCalledTimes(1);
expect(updateWorker.mock.calls[0][1]).toEqual(
expect.objectContaining({
type: "remote",
enabled: false,
cuda_device: null,
host: "",
})
);
expect(extension.config.workers).toHaveLength(1);
expect(extension.config.workers[0]).toEqual(
expect.objectContaining({
type: "remote",
enabled: false,
cuda_device: null,
host: "",
})
);
expect(stateUpdateWorker).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({ enabled: false })
);
expect(toastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: "warn",
summary: "Remote Worker Added",
})
);
});
it("assigns the first free local CUDA device when adding a worker", async () => {
const toastAdd = vi.fn();
const updateWorker = vi.fn().mockResolvedValue({});
const stateUpdateWorker = vi.fn();
const setWorkerExpanded = vi.fn();
const extension = {
cudaDeviceCount: 3,
masterCudaDevice: 0,
panelElement: null,
config: {
workers: [
{ id: "w-existing", type: "local", port: 8189, cuda_device: 1, enabled: true },
],
master: { cuda_device: 0 },
},
api: { updateWorker },
state: { updateWorker: stateUpdateWorker, setWorkerExpanded },
app: { extensionManager: { toast: { add: toastAdd } } },
};
await addNewWorker(extension);
expect(updateWorker).toHaveBeenCalledTimes(1);
expect(updateWorker.mock.calls[0][1]).toEqual(
expect.objectContaining({
cuda_device: 2,
})
);
});
});
+24 -11
View File
@@ -27,12 +27,10 @@ const cardConfigs = {
const participationEnabled = extension.isMasterParticipationEnabled();
const fallbackActive = extension.isMasterFallbackActive();
let delegateBadge = '';
if (!participationEnabled) {
delegateBadge = fallbackActive
? `<br><small style="color: #6bd06b;">Fallback active • Master executing</small>`
: `<br><small style="color: ${UI_COLORS.SECONDARY_TEXT};">Orchestrator-only mode</small>`;
if (!participationEnabled && fallbackActive) {
delegateBadge = `<br><span class="dist-worker-info__fallback">Fallback active • Master executing</span>`;
}
return `<strong id="master-name-display">${data?.name || extension.config?.master?.name || "Master"}</strong><br><small style="color: ${UI_COLORS.MUTED_TEXT};"><span id="master-cuda-info">${cudaInfo}Port ${port}</span></small>${delegateBadge}`;
return `<span class="dist-worker-info__title" id="master-name-display">${data?.name || extension.config?.master?.name || "Master"}</span><br><span class="dist-worker-info__meta"><span id="master-cuda-info">${cudaInfo}Port ${port}</span></span>${delegateBadge}`;
},
controls: {
type: 'master'
@@ -63,12 +61,15 @@ const cardConfigs = {
if (isCloud) {
// For cloud workers, don't show port (it's always 443)
return `<strong>${data.name}</strong><br><small style="color: ${UI_COLORS.MUTED_TEXT};">${data.host}</small>`;
return `<span class="dist-worker-info__title">${data.name}</span><br><span class="dist-worker-info__meta">${data.host}</span>`;
} else if (isRemote) {
return `<strong>${data.name}</strong><br><small style="color: ${UI_COLORS.MUTED_TEXT};">${data.host}:${data.port}</small>`;
const hostLabel = data.host
? `${data.host}:${data.port}`
: `Unconfigured remote worker • Port ${data.port}`;
return `<span class="dist-worker-info__title">${data.name}</span><br><span class="dist-worker-info__meta">${hostLabel}</span>`;
} else {
const cudaInfo = data.cuda_device !== undefined ? `CUDA ${data.cuda_device} • ` : '';
return `<strong>${data.name}</strong><br><small style="color: ${UI_COLORS.MUTED_TEXT};">${cudaInfo}Port ${data.port}</small>`;
return `<span class="dist-worker-info__title">${data.name}</span><br><span class="dist-worker-info__meta">${cudaInfo}Port ${data.port}</span>`;
}
},
controls: {
@@ -401,10 +402,15 @@ export class DistributedUI {
const modal = createLogModal();
this._logModal = modal;
const themeClass =
extension.panelElement?.classList.contains("distributed-panel--light")
? "distributed-panel--light"
: "";
modal.mount(document.body, {
workerName,
logData,
fetchLog: fetchLog || (async () => extension.api.getWorkerLog(workerId, 1000)),
themeClass,
onClose: () => {
if (this._logModal === modal) {
this._logModal = null;
@@ -427,7 +433,8 @@ export class DistributedUI {
const settingsToggle = document.createElement("span");
settingsToggle.textContent = "▶"; // Right arrow when collapsed
settingsToggle.style.cssText = "font-size: 12px; color: #888; transition: all 0.2s ease;";
settingsToggle.style.cssText =
"font-size: 12px; color: var(--dist-settings-arrow, #888); transition: all 0.2s ease;";
settingsRow.appendChild(settingsToggle);
settingsRow.appendChild(settingsTitle);
@@ -574,16 +581,22 @@ export class DistributedUI {
badge.textContent = message;
badge.classList.add("master-info-badge--fallback");
} else if (!participationEnabled) {
message = "Master disabled: running as orchestrator only.";
message = "Master disabled: running as orchestrator only";
badge.textContent = message;
badge.classList.add("master-info-badge--delegate");
} else {
message = "Master participating in workflows.";
message = "Master participating in workflows";
badge.textContent = message;
}
controlsWrapper.appendChild(badge);
} else if (config.dynamic && data) {
if (isRemote) {
const isCloud = data.type === 'cloud';
const workerTypeText = isCloud ? "Cloud worker" : "Remote worker";
const workerTypeBadge = this.createInfoBox(workerTypeText);
workerTypeBadge.title = "Worker is externally hosted";
controlsWrapper.appendChild(workerTypeBadge);
const logBtn = this.createButton('View Log', () => viewWorkerLog(extension, data.id, true));
logBtn.id = `log-${data.id}`;
logBtn.style.cssText = BUTTON_STYLES.base + BUTTON_STYLES.workerControl;
+4 -3
View File
@@ -15,7 +15,7 @@ export function createCheckboxSetting(id, label, tooltip, checked, onChange) {
const lbl = document.createElement("label");
lbl.htmlFor = id;
lbl.textContent = label;
lbl.style.cssText = "font-size: 12px; color: #ccc; cursor: pointer;";
lbl.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc); cursor: pointer;";
if (tooltip) {
lbl.title = tooltip;
}
@@ -32,7 +32,7 @@ export function createNumberSetting(id, label, tooltip, value, min, step, onChan
const lbl = document.createElement("label");
lbl.htmlFor = id;
lbl.textContent = label;
lbl.style.cssText = "font-size: 12px; color: #ccc;";
lbl.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc);";
if (tooltip) {
lbl.title = tooltip;
}
@@ -42,7 +42,8 @@ export function createNumberSetting(id, label, tooltip, value, min, step, onChan
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.style.cssText =
"width: 80px; padding: 2px 6px; background: var(--dist-input-bg, #222); color: var(--dist-input-text, #ddd); border: 1px solid var(--dist-input-border, #333); border-radius: 3px;";
input.value = value;
input.onchange = onChange;
+3 -2
View File
@@ -70,6 +70,7 @@ export function renderEntityCard(ui, cardConfigs, entityType, data, extension) {
workerContent.appendChild(statusDot);
const infoSpan = document.createElement("span");
infoSpan.classList.add("dist-worker-info");
infoSpan.innerHTML = config.infoText(data, extension);
workerContent.appendChild(infoSpan);
@@ -92,13 +93,13 @@ export function renderEntityCard(ui, cardConfigs, entityType, data, extension) {
rightColumn.onmouseover = () => {
rightColumn.classList.add("entity-card-content--hovered");
if (settingsArrow) {
settingsArrow.style.color = "#fff";
settingsArrow.style.color = "var(--dist-settings-arrow-hover, #fff)";
}
};
rightColumn.onmouseout = () => {
rightColumn.classList.remove("entity-card-content--hovered");
if (settingsArrow) {
settingsArrow.style.color = "#888";
settingsArrow.style.color = "var(--dist-settings-arrow, #888)";
}
};
}
+4 -1
View File
@@ -95,13 +95,16 @@ export function createLogModal() {
}
};
const mount = (container, { workerName, logData, onClose, fetchLog }) => {
const mount = (container, { workerName, logData, onClose, fetchLog, themeClass = "" }) => {
_onClose = onClose;
_fetchLog = fetchLog;
const modal = document.createElement('div');
modal.id = 'distributed-log-modal';
modal.className = 'log-modal';
if (themeClass) {
modal.classList.add(themeClass);
}
const content = document.createElement('div');
content.className = 'log-modal__content';
+3 -2
View File
@@ -14,11 +14,12 @@ export function createWorkerSettingsForm(ui, extension, worker) {
const typeLabel = document.createElement("label");
typeLabel.htmlFor = `worker-type-${worker.id}`;
typeLabel.textContent = "Worker Type:";
typeLabel.style.cssText = "font-size: 12px; color: #ccc;";
typeLabel.style.cssText = "font-size: 12px; color: var(--dist-label-text, #ccc);";
const typeSelect = document.createElement("select");
typeSelect.id = `worker-type-${worker.id}`;
typeSelect.style.cssText = "padding: 4px 8px; background: #333; color: #fff; border: 1px solid #555; border-radius: 4px; font-size: 12px;";
typeSelect.style.cssText =
"padding: 4px 8px; background: var(--dist-input-bg, #333); color: var(--dist-input-text, #fff); border: 1px solid var(--dist-input-border, #555); border-radius: 4px; font-size: 12px;";
const localOption = document.createElement("option");
localOption.value = "local";
+81 -18
View File
@@ -4,14 +4,22 @@ import { parseHostInput } from './urlUtils.js';
import { toggleWorkerExpanded } from './workerLifecycle.js';
export function isRemoteWorker(extension, worker) {
// Check if explicitly marked as cloud worker
if (worker.type === "cloud") {
const workerType = String(worker?.type || "").toLowerCase();
// Explicit type always wins over host heuristics.
if (workerType === "cloud" || workerType === "remote") {
return true;
}
if (workerType === "local") {
return false;
}
// Otherwise check by host (backward compatibility)
const parsed = 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;
const parsed = parseHostInput(worker?.host || window.location.hostname);
const host = String(parsed.host || window.location.hostname || "").toLowerCase();
const currentHost = String(parseHostInput(window.location.hostname).host || window.location.hostname || "").toLowerCase();
const localHosts = new Set(["", "localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"]);
return !(localHosts.has(host) || host === currentHost);
}
export function isCloudWorker(extension, worker) {
@@ -32,6 +40,7 @@ export async function saveWorkerSettings(extension, workerId) {
const rawHost = isRemote ? document.getElementById(`host-${workerId}`).value : window.location.hostname;
const parsedHost = isRemote ? parseHostInput(rawHost) : { host: window.location.hostname, port: null };
const host = isRemote ? parsedHost.host : window.location.hostname;
const hostTrimmed = (host || "").trim();
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;
@@ -51,7 +60,7 @@ export async function saveWorkerSettings(extension, workerId) {
return;
}
if ((workerType === "remote" || workerType === "cloud") && !host.trim()) {
if ((workerType === "remote" || workerType === "cloud") && !hostTrimmed) {
extension.app.extensionManager.toast.add({
severity: "error",
summary: "Validation Error",
@@ -102,7 +111,7 @@ export async function saveWorkerSettings(extension, workerId) {
}
} 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());
const sameHostConflict = extension.config.workers.some((w) => w.id !== workerId && w.port === port && w.host === hostTrimmed);
if (sameHostConflict) {
extension.app.extensionManager.toast.add({
@@ -115,21 +124,28 @@ export async function saveWorkerSettings(extension, workerId) {
}
}
const wasUnconfiguredRemote =
(worker.type === "remote" || worker.type === "cloud") &&
(!String(worker.host || "").trim()) &&
!worker.enabled;
const nextEnabled = isRemote && hostTrimmed && wasUnconfiguredRemote ? true : worker.enabled;
try {
await extension.api.updateWorker(workerId, {
name: name.trim(),
type: workerType,
host: isRemote ? host.trim() : null,
host: isRemote ? hostTrimmed : null,
port,
cuda_device: isRemote ? null : cudaDevice,
extra_args: isRemote ? null : extraArgs ? extraArgs.trim() : "",
enabled: nextEnabled,
});
// Update local config
worker.name = name.trim();
worker.type = workerType;
if (isRemote) {
worker.host = host.trim();
worker.host = hostTrimmed;
delete worker.cuda_device;
delete worker.extra_args;
} else {
@@ -138,14 +154,17 @@ export async function saveWorkerSettings(extension, workerId) {
worker.extra_args = extraArgs ? extraArgs.trim() : "";
}
worker.port = port;
worker.enabled = nextEnabled;
// Sync to state
extension.state.updateWorker(workerId, { enabled: worker.enabled });
extension.state.updateWorker(workerId, { enabled: nextEnabled });
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Settings Saved",
detail: `Worker ${name} settings updated`,
detail: nextEnabled && wasUnconfiguredRemote
? `Worker ${name} configured and enabled`
: `Worker ${name} settings updated`,
life: 3000,
});
@@ -229,6 +248,44 @@ export async function deleteWorker(extension, workerId) {
}
export async function addNewWorker(extension) {
const toInt = (value) => {
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : null;
};
const totalCudaDevices = toInt(extension.cudaDeviceCount);
const masterCudaDevice = toInt(extension.masterCudaDevice ?? extension.config?.master?.cuda_device);
const localWorkers = (extension.config?.workers || []).filter((w) => !isRemoteWorker(extension, w));
let selectedCudaDevice = extension.config.workers.length;
let fallbackToRemote = false;
if (totalCudaDevices !== null && totalCudaDevices > 0) {
const usedCudaDevices = new Set();
for (const worker of localWorkers) {
const cudaIdx = toInt(worker.cuda_device);
if (cudaIdx !== null) {
usedCudaDevices.add(cudaIdx);
}
}
if (masterCudaDevice !== null) {
usedCudaDevices.add(masterCudaDevice);
}
const availableCudaDevices = [];
for (let i = 0; i < totalCudaDevices; i++) {
if (!usedCudaDevices.has(i)) {
availableCudaDevices.push(i);
}
}
if (availableCudaDevices.length === 0) {
fallbackToRemote = true;
selectedCudaDevice = null;
} else {
selectedCudaDevice = availableCudaDevices[0];
}
}
// Generate new worker ID using UUID (fallback for non-secure contexts)
const newId = generateUUID();
@@ -244,8 +301,10 @@ export async function addNewWorker(extension) {
id: newId,
name: `Worker ${extension.config.workers.length + 1}`,
port: nextPort,
cuda_device: extension.config.workers.length,
enabled: true, // Default to enabled for convenience
type: fallbackToRemote ? "remote" : "local",
host: fallbackToRemote ? "" : null,
cuda_device: selectedCudaDevice,
enabled: fallbackToRemote ? false : true, // Remote fallback starts disabled until configured
extra_args: "",
};
@@ -260,16 +319,20 @@ export async function addNewWorker(extension) {
cuda_device: newWorker.cuda_device,
extra_args: newWorker.extra_args,
enabled: newWorker.enabled,
host: newWorker.host,
type: newWorker.type,
});
// Sync to state
extension.state.updateWorker(newId, { enabled: true });
extension.state.updateWorker(newId, { enabled: newWorker.enabled });
extension.app.extensionManager.toast.add({
severity: "success",
summary: "Worker Added",
detail: `New worker created on port ${nextPort}`,
life: 3000,
severity: fallbackToRemote ? "warn" : "success",
summary: fallbackToRemote ? "Remote Worker Added" : "Worker Added",
detail: fallbackToRemote
? `No local GPU available, so a disabled remote worker was added on port ${nextPort}. Configure host and enable it.`
: `New worker created on port ${nextPort}`,
life: fallbackToRemote ? 5000 : 3000,
});
// Refresh UI and expand the new worker